From cfc742891a94ec26f9ce9e024b220eaaded00203 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 27 Jul 2026 14:38:55 -0400 Subject: [PATCH] connect: nest tool calls inside the message that made them, and lift each block onto its own panel Pressing up in the chat used to land on a tool call, which reads as a turn in the conversation when it is really work the agent did mid-message. Now a tool call belongs to its message's block: up/down walk messages, right on a message reveals its calls as stops of their own, right again opens one call's output, and left walks back out a level. Each lifted block also gets a blank tinted row above and below, shared between a message and the tool run attached under it rather than one pad each. --- src/ui/ConnectApp.tsx | 138 ++++++++++++++++++++++--------- src/ui/transcriptRows.ts | 174 ++++++++++++++++++++++++++++++--------- test/connect-app.test.ts | 43 +++++++++- 3 files changed, 277 insertions(+), 78 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index ba8e510..723ad58 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -44,10 +44,13 @@ import { entryRange, GUTTER_COLS, isCollapsible, + isToolActivity, itemRows, layOutItems, LIVE_GLYPH, MESSAGE_PAD, + navKeyOf, + padPanelBlocks, pendingMessageRows, rowViewport, snapToEntry, @@ -618,29 +621,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { }, [inputActive, mouseCapture, stdout]) // The rendered transcript lines, in order: collapsed (the default) folds - // consecutive tool activity into "Ran N …" notices, except folds opened in - // place with → (openedKeys), which render their tool calls right below the - // fold line — indented one level (2 columns), so the expansion reads as - // the fold's children — and ← closes them again. Expanded (ctrl+r) shows - // everything, flat. `indented` carries the keys of fold-child lines. - const { visible, indented } = useMemo(() => { + // consecutive tool activity into "Ran N …" notices, except the runs under a + // MESSAGE opened in place with → (openedKeys), which render their tool calls + // right below the fold line — indented one level (2 columns), so the + // expansion reads as the fold's children — and ← closes them again. The + // message is what opens, not the fold: a run of tool calls is work that + // message did, so it is reached by opening the message (see layOutItems). + // Expanded (ctrl+r) shows everything, flat. + const visible = useMemo(() => { const pendingKeys = new Set(pendingTools.map((t) => t.key)) const base = pendingKeys.size ? items.filter((i) => !pendingKeys.has(i.key)) : items - if (expanded) return { visible: items, indented: new Set() } + if (expanded) return items const folded = collapseToolRuns(base) - if (openedKeys.size === 0) return { visible: folded, indented: new Set() } + if (openedKeys.size === 0) return folded const out: TranscriptItem[] = [] - const indentedKeys = new Set() + // The message a fold hangs off: opening THAT is what reveals the run. + let parent: string | null = null for (const item of folded) { out.push(item) - if (item.key.startsWith('grp:') && openedKeys.has(item.key)) { - for (const child of foldRun(item.key, base)) { - out.push(child) - indentedKeys.add(child.key) - } + if (!isToolActivity(item)) { + parent = item.key + continue + } + if (item.key.startsWith('grp:') && parent !== null && openedKeys.has(parent)) { + out.push(...foldRun(item.key, base)) } } - return { visible: out, indented: indentedKeys } + return out }, [items, expanded, pendingTools, openedKeys]) const infraActivity = statusActivityText(statusWord) @@ -794,16 +801,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) } // Tool activity is nested under the message that produced it (layOutItems - // decides what hangs off what), so a call and its result read as work the - // agent did mid-message rather than as turns of their own. - for (const placed of layOutItems(visible, { indentedKeys: indented })) { + // decides what hangs off what, and what ↑/↓ can land on), so a call and its + // result read as work the agent did mid-message rather than as turns of + // their own. + for (const placed of layOutItems(visible, { openedKeys, revealAll: expanded })) { + const rows = itemRows(placed.item, cols, { + indent: placed.indent, + nested: placed.nested, + attach: placed.attach, + // Opening a block un-clamps what it owns as well as itself: → on a + // revealed tool call shows the full output of the ⎿ result under it, + // which is the line that actually carries the body. + clamp: + !expanded && + !openedKeys.has(placed.item.key) && + !(placed.navKey !== undefined && openedKeys.has(placed.navKey)), + }) + // A line inside another's block carries that block's nav key, so ↑/↓ + // land on the block and this line travels with it. out.push( - ...itemRows(placed.item, cols, { - indent: placed.indent, - nested: placed.nested, - attach: placed.attach, - clamp: !expanded && !openedKeys.has(placed.item.key), - }), + ...(placed.navKey || placed.parentKey + ? rows.map((r) => ({ ...r, navKey: placed.navKey, parentKey: placed.parentKey })) + : rows), ) } // Sends the agent has TAKEN (delivered, echo record still in flight): @@ -837,14 +856,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { }), ) } - return out + // Every lifted block gets its blank tinted row above and below, here so a + // message and the tool run attached under it share one pad. + return padPanelBlocks(out) }, [ infraActivity, sandbox, sandboxSettled, sandboxLogOpen, visible, - indented, expanded, openedKeys, inFlightSends, @@ -852,19 +872,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { cols, ]) - // Everything ↑/↓ can land on, top to bottom: the entries with rows on the - // list, minus turn summaries ("turn complete · 3s · $0.03") — informational - // trailers, not content, so the walk skips them (they still render and - // scroll) — and minus the live tail, which moves under you as it streams. + // Everything ↑/↓ can land on, top to bottom: the BLOCKS with rows on the list + // (a nested tool line is part of its message's block, not a stop of its own — + // see layOutItems), minus turn summaries ("turn complete · 3s · $0.03") — + // informational trailers, not content, so the walk skips them (they still + // render and scroll) — and minus the live tail, which moves under you as it + // streams. const navKeys = useMemo(() => { const skip = new Set(visible.filter((i) => i.kind === 'summary').map((i) => i.key)) const seen = new Set() const out: string[] = [] for (const row of allRows) { - if (skip.has(row.entryKey) || row.entryKey.startsWith('live')) continue - if (seen.has(row.entryKey)) continue - seen.add(row.entryKey) - out.push(row.entryKey) + const key = navKeyOf(row) + if (skip.has(key) || key.startsWith('live')) continue + if (seen.has(key)) continue + seen.add(key) + out.push(key) } return out }, [allRows, visible]) @@ -921,6 +944,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { [allRows, view, scrollByRows], ) + // Whether a block has tool activity nested under it that → can reveal: rows + // that name it as their block but aren't its own (see layOutItems). + const hasToolRun = useCallback( + (key: string): boolean => allRows.some((r) => r.navKey === key), + [allRows], + ) + + // The block a stop sits inside, for ← to step out to: a tool call revealed + // under an opened message names that message. + const parentOf = useCallback( + (key: string): string | null => + allRows.find((r) => navKeyOf(r) === key && r.parentKey)?.parentKey ?? null, + [allRows], + ) + const insertAtCursor = useCallback((ch: string): void => { setComposer(({ text, cursor }) => ({ text: text.slice(0, cursor) + ch + text.slice(cursor), @@ -1014,16 +1052,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // settled block (a live one is already showing it). setSandboxLogOpen(true) } else { + // → opens the highlighted block one level: a message reveals the + // tool calls it made (which ↑/↓ then step through one at a time), a + // call reveals its full output, a clamped body un-clamps. const item = visible.find((i) => i.key === navKey) - if (item && (navKey.startsWith('grp:') || isCollapsible(item))) { + if (item && (hasToolRun(navKey) || isCollapsible(item))) { setOpenedKeys((prev) => new Set(prev).add(navKey)) } } return } if (key.leftArrow) { - // ← closes the thing opened in place; with nothing open it's inert - // (the session nav lives BELOW the composer — ↓ walks to it). + // ← closes the highlighted block, or — with nothing of its own open — + // steps back OUT to the block it sits inside, closing that (a + // revealed tool call returns the highlight to its message). Inert at + // the top level with nothing open; the session nav lives BELOW the + // composer, so ↓ is what walks to it. if (navKey === 'sandbox') { setSandboxLogOpen(false) } else if (openedKeys.has(navKey)) { @@ -1032,6 +1076,17 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { next.delete(navKey) return next }) + } else { + const parent = parentOf(navKey) + if (parent) { + setOpenedKeys((prev) => { + const next = new Set(prev) + next.delete(parent) + return next + }) + setNavKey(parent) + ensureVisible(parent) + } } return } @@ -1171,7 +1226,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { key={row.id} row={row} cols={cols} - selected={navKey !== null && row.entryKey === navKey && !row.spacer} + // The whole block lifts, tool rows included — the highlight is what + // says "this message and the work it did". A panel's pad rows + // (spacer + panel) lift with it; canvas spacers between blocks + // never highlight. + selected={ + navKey !== null && navKeyOf(row) === navKey && (!row.spacer || row.panel === true) + } // Both ticking values are passed as constants to rows that don't // use them, so React.memo skips those rows entirely: the // once-a-second clock and the pulse repaint the live lines, not @@ -1338,6 +1399,7 @@ function sandboxRows(o: { entryKey: key, spans: [{ text: '✦ Connected to ellipsis.dev', bold: true }], }) + rows.push(spacerRow(key, `${key}:hdr-sp`)) const ready = (sandbox?.done ?? false) && !infraActivity // A live status word overrides a stale done-headline: on a wake the status // flips before the new session_starting record lands, and "Session ready!" diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 50e375e..e9e6f09 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -25,11 +25,10 @@ import { theme } from '../lib/theme' export const GUTTER_COLS = 2 // Horizontal pad inside a chat message's panel — the text sits one cell off -// the tint's edge, like the composer's interior. There is deliberately no -// VERTICAL pad: a blank tinted row above and below every message costs two -// rows of the window each time, and the tint alone already separates the -// message from the canvas around it. The blank separator between blocks -// (spacerRow) is the breathing room. +// the tint's edge, like the composer's interior. The VERTICAL pad is a blank +// tinted row above and below each panel block, added in one place +// (padPanelBlocks) after the rows are assembled, so a message and the tool +// run nested under it share one pad rather than getting one each. export const MESSAGE_PAD = 1 // Long bodies collapse to this many lines until ctrl+r (or → on the line) @@ -53,9 +52,17 @@ export type TranscriptRow = { // Unique per row, for React keys. id: string // The entry (a transcript item's key, or 'sandbox') this row belongs to: - // what ↑/↓ highlights, and what the scroll anchor holds onto so streamed - // appends and re-wraps can't slide the window. + // what the scroll anchor holds onto so streamed appends and re-wraps can't + // slide the window. entryKey: string + // The entry ↑/↓ selects when this row is highlighted, when that is not the + // row's own entry: a tool call nested under a message is part of THAT + // message's block, so the walk lands on the message and the call comes with + // it. Absent means the row is its own nav stop. + navKey?: string + // For a row that IS a stop nested inside another (a tool call revealed under + // an opened message): the stop ← steps out to. + parentKey?: string // The gutter glyph, set on an entry's FIRST row only — a multi-row item // shows one sender icon, and its continuation rows align under it. gutter?: RowSpan @@ -71,8 +78,10 @@ export type TranscriptRow = { // On the active surface regardless of the transcript selection — the // startup block's selected phase, which has its own cursor. activeRow?: boolean - // A blank separator row: never tinted, never highlighted, so the gap - // between blocks stays canvas even when the block below it is selected. + // A blank separator row. Off-panel it is never tinted or highlighted, so + // the gap between blocks stays canvas even when the block below it is + // selected. On a panel (panel + spacer) it is the block's vertical pad: it + // carries the tint, and the selection treatment when its block is selected. spacer?: boolean // The "+N lines" marker under a clamped body. The key that opens it depends // on whether the line is highlighted (→) or not (ctrl+r), which the renderer @@ -119,6 +128,42 @@ export function spacerRow(entryKey: string, id: string): TranscriptRow { return { id, entryKey, spans: [], spacer: true } } +// The entry ↑/↓ lands on for a row: the block it belongs to, which for a +// nested tool line is the message (or the call) it hangs off rather than its +// own entry. +export function navKeyOf(row: TranscriptRow): string { + return row.navKey ?? row.entryKey +} + +// One blank tinted row above and below every maximal run of consecutive panel +// rows — the vertical pad around each lifted block, matching the composer's +// interior pad. Applied to the ASSEMBLED list rather than inside itemRows so a +// message and the tool run attached under it read as one padded block instead +// of each bringing its own pad. Pure, for tests. +export function padPanelBlocks(rows: readonly TranscriptRow[]): TranscriptRow[] { + const out: TranscriptRow[] = [] + // A pad row inherits the edge row's BLOCK, not just its entry: a pad added + // below a message's nested tool line belongs to that message, and leaving + // navKey off would make the tool line a ↑/↓ stop of its own again. + const pad = (edge: TranscriptRow, side: string): TranscriptRow => ({ + id: `${edge.id}:${side}`, + entryKey: edge.entryKey, + navKey: edge.navKey, + spans: [], + panel: true, + spacer: true, + }) + for (const row of rows) { + const prev = out[out.length - 1] + if (row.panel && !prev?.panel) out.push(pad(row, 'padT')) + if (!row.panel && prev?.panel) out.push(pad(prev, 'padB')) + out.push(row) + } + const last = out[out.length - 1] + if (last?.panel) out.push(pad(last, 'padB')) + return out +} + // One transcript item as its screen rows: the separator above it, its body // pre-wrapped to the column it occupies, and the "+N lines" marker when a long // body is clamped. @@ -127,7 +172,10 @@ export function itemRows( cols: number, opts: { indent?: number; clamp: boolean; nested?: boolean; attach?: boolean }, ): TranscriptRow[] { - const panel = isMessage(item) + // Nested tool activity sits ON the parent message's panel: the call and its + // result are work done while writing that message, so they live inside the + // same lifted, padded block rather than on the canvas beside it. + const panel = isMessage(item) || opts.nested === true const indent = opts.indent ?? 0 const width = contentWidth(cols, { panel, indent }) const shown = withRenderedMarkdown(item, width) @@ -193,32 +241,78 @@ export function itemRows( // A run with no assistant message before it (the agent opened the turn with a // tool call) still nests — under the user message that prompted it — because // the indent is what says "this is work, not talk". Only a run at the very top -// of the transcript, with no parent at all, stays flat. Pure, for tests. +// of the transcript, with no parent at all, stays flat. +// +// Nesting also decides what ↑/↓ can LAND on, because a tool call is not a stop +// of its own — it belongs to the message that made it. Three levels, each +// opened by → on the level above: +// +// ● the message a stop; ↑/↓ walk these +// ⎿ Ran 3 tool calls part of the message's block (navKey → the message) +// ● Bash(pytest) a stop once the message is opened +// ⎿ output part of that call's block (navKey → the call) +// +// So ↑ lands on the message with its tool chatter in tow; → reveals the calls +// and ↑/↓ then step through them one at a time; → on a call opens its output; +// ← walks back out (parentKey). Pure, for tests. +export type PlacedItem = { + item: TranscriptItem + indent: number + nested: boolean + attach: boolean + // The block this line belongs to, when that is not the line itself. Absent + // means the line is its own ↑/↓ stop. + navKey?: string + // The stop ← steps out to, for a line that IS a stop nested inside another. + parentKey?: string +} export function layOutItems( items: readonly TranscriptItem[], - opts: { indentedKeys?: ReadonlySet } = {}, -): { item: TranscriptItem; indent: number; nested: boolean; attach: boolean }[] { - const out: { item: TranscriptItem; indent: number; nested: boolean; attach: boolean }[] = [] - // Whether anything at all precedes the current run — a run at the head of - // the transcript has nothing to hang off. - let hasParent = false + // `openedKeys` are the lines opened with →: an opened MESSAGE reveals its + // calls as stops. `revealAll` is ctrl+r, which reveals every one of them. + opts: { openedKeys?: ReadonlySet; revealAll?: boolean } = {}, +): PlacedItem[] { + const out: PlacedItem[] = [] + // The message the current run hangs off — null at the head of the transcript, + // where a run has nothing to hang off and stays flat. + let parent: string | null = null + // The call a ⎿ result belongs to, so a result travels with its own call. + let call: string | null = null for (const item of items) { - if (isToolActivity(item)) { - const nested = hasParent - out.push({ - item, - // A fold opened with → indents its children one level FURTHER, so the - // expansion still reads as the fold's own children. - indent: (nested ? NEST_INDENT : 0) + (opts.indentedKeys?.has(item.key) ? NEST_INDENT : 0), - nested, - // Attach every line of the run: the first to its parent message, the - // rest to the line above. - attach: nested, - }) + if (!isToolActivity(item)) { + out.push({ item, indent: 0, nested: false, attach: false }) + parent = item.key + call = null continue } - out.push({ item, indent: 0, nested: false, attach: false }) - hasParent = true + const nested = parent !== null + const revealed = + opts.revealAll === true || (parent !== null && opts.openedKeys?.has(parent) === true) + if (item.kind === 'tool') call = item.key + // Who owns this line — the stop it travels with, or null when it IS one. + // Collapsed, everything belongs to the message. Revealed, each ● call + // becomes a stop and its ⎿ result travels with it. A fold ("Ran N …") is + // never a stop either way: it stands in for the run, so it reads as part of + // the message, and → on the message is what opens it. + let owner = parent + if (revealed && item.kind === 'tool') owner = null + else if (revealed && item.kind === 'tool_result') owner = call + // A revealed call sits one level further in than the fold it came out of, + // so the expansion still reads as that fold's children, and its result + // indents with it. The fold line itself doesn't move — it is the header the + // children hang under. ctrl+r has no fold to nest below, so nothing shifts. + const deeper = revealed && !opts.revealAll && item.kind !== 'notice' + out.push({ + item, + indent: (nested ? NEST_INDENT : 0) + (deeper ? NEST_INDENT : 0), + nested, + // Attach every line of the run: the first to its parent message, the + // rest to the line above. + attach: nested, + navKey: owner ?? undefined, + // ← on a revealed call steps back out to the message it hangs off. + parentKey: owner === null ? (parent ?? undefined) : undefined, + }) } return out } @@ -243,14 +337,15 @@ export function activityRows( cols: number, hug: boolean, // The line describes a TOOL CALL in flight, so it nests under the message - // that made the call, exactly where its ⎿ result will land a moment later. - // A "Generating…"/"Working…" line describes the message itself and stays flat. + // that made the call, exactly where its ⎿ result will land a moment later — + // on that message's panel, inside its pad. A "Generating…"/"Working…" line + // describes the message itself and stays flat. nested = false, ): TranscriptRow[] { const indent = nested ? NEST_INDENT : 0 // Reserve the widest the readout gets ("(1h 3m 30s · ↓ 12.3k tokens)") so // the label doesn't reflow as the clock ticks. - const width = Math.max(8, contentWidth(cols, { indent }) - visibleWidth(suffix) - 16) + const width = Math.max(8, contentWidth(cols, { indent, panel: nested }) - visibleWidth(suffix) - 16) const rows: TranscriptRow[] = hug || nested ? [] : [spacerRow(key, `${key}:sp`)] rows.push({ id: `${key}:r`, @@ -259,6 +354,7 @@ export function activityRows( indent, spans: [{ text: fitLines(label, width)[0] ?? '', dim: true }], right: { text: suffix, dim: true }, + panel: nested, tick, pulse: true, }) @@ -394,9 +490,11 @@ export function anchorAt(rows: readonly TranscriptRow[], index: number): ScrollA return { entryKey: row.entryKey, rowOffset: Math.max(0, index - first) } } -// The row range an entry occupies, skipping its leading spacer — that blank -// row is a separator, so bringing an entry to the top of the window should -// land on its first line of content, not on the gap above it. Pure, for tests. +// The row range a nav BLOCK occupies — the entry's own rows plus any nested +// under it (a message's tool calls travel with it, so the snap brings the whole +// block into frame) — skipping its leading spacer: that blank row is a +// separator, so bringing a block to the top of the window should land on its +// first line of content, not on the gap above it. Pure, for tests. export function entryRange( rows: readonly TranscriptRow[], entryKey: string, @@ -404,7 +502,7 @@ export function entryRange( let first = -1 let last = -1 for (const [i, row] of rows.entries()) { - if (row.entryKey !== entryKey) continue + if (navKeyOf(row) !== entryKey) continue if (first < 0 && row.spacer) continue if (first < 0) first = i last = i diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index e140e59..2da1ce6 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -541,13 +541,52 @@ describe('layOutItems', () => { expect(out.map((p) => p.nested)).toEqual([false, false, false]) }) - it('indents an opened fold\'s children one level FURTHER than the fold', () => { + it("indents an opened message's revealed calls one level FURTHER than the fold", () => { const out = layOutItems([prose('a'), fold('t1'), call('t1'), res('r1')], { - indentedKeys: new Set(['t1', 'r1']), + openedKeys: new Set(['a']), }) expect(out.map((p) => p.indent)).toEqual([0, 2, 4, 4]) }) + it('makes a tool call part of its message block, not a stop of its own', () => { + // ↑ lands on the message; the call and its result travel with it. + const out = layOutItems([prose('a'), call('t1'), res('r1')]) + expect(out.map((p) => p.navKey)).toEqual([undefined, 'a', 'a']) + }) + + it('makes a collapsed fold part of the message block too', () => { + const out = layOutItems([prose('a'), fold('t1')]) + expect(out[1].navKey).toBe('a') + }) + + it('promotes revealed calls to stops of their own, results still travelling with them', () => { + const out = layOutItems([prose('a'), fold('t1'), call('t1'), res('r1')], { + openedKeys: new Set(['a']), + }) + // The fold stays part of the message; the call becomes its own stop and + // owns its result. + expect(out.map((p) => p.navKey)).toEqual([undefined, 'a', undefined, 't1']) + }) + + it('points a revealed call back at its message, so ← steps out', () => { + const out = layOutItems([prose('a'), fold('t1'), call('t1'), res('r1')], { + openedKeys: new Set(['a']), + }) + expect(out[2].parentKey).toBe('a') + }) + + it('promotes every call when ctrl+r reveals the whole transcript', () => { + const out = layOutItems([prose('a'), call('t1'), res('r1')], { revealAll: true }) + expect(out.map((p) => p.navKey)).toEqual([undefined, undefined, 't1']) + }) + + it('leaves an unopened message closed, so its calls stay off the walk', () => { + const out = layOutItems([prose('a'), fold('t1'), prose('b'), fold('t2')], { + openedKeys: new Set(['b']), + }) + expect(out.map((p) => p.navKey)).toEqual([undefined, 'a', undefined, 'b']) + }) + it('keeps prose, user messages and notices flat', () => { const notice: TranscriptItem = { key: 'n', kind: 'notice', text: 'Session asleep' } const out = layOutItems([prose('a'), user('u'), notice])