From f30a92a8be30cef41e56187be76b2469d2ec6159 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 5 Aug 2026 22:53:47 -0400 Subject: [PATCH 1/3] chat: box only your own messages, and make the cursor findable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tint and pad on every message turned the transcript into a stack of stripes with no room to read. Only what you sent is a lifted box now; everything the agent says or does sits bare on the canvas. - metadata reads as prose ("23s, 4 tokens", not "(23s - 4 tokens)") - the selection highlight is gone; the cyan ▶ carries "you are here" - a turn-opening tool run no longer branches off YOUR message, where it read as work you did - opening a message replaces its "Ran N ..." fold with the calls it stood for, instead of leaving a stale count above them - ⎿ gets a column of breathing room before its text --- src/lib/sessions.ts | 10 +-- src/lib/theme.ts | 12 ++- src/ui/ConnectApp.tsx | 123 +++++++++++++++-------------- src/ui/SessionsApp.tsx | 11 +-- src/ui/transcriptRows.ts | 162 ++++++++++++++++++++------------------- test/connect-app.test.ts | 51 ++++++++---- 6 files changed, 203 insertions(+), 166 deletions(-) diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index b16cbd8..aca0694 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -6,11 +6,11 @@ import type { AgentSession, SupportedModel } from './types' // Pure session-model helpers shared by the connect command and the // multi-session UI (SessionsApp). No I/O here — everything is testable. -// THE selection marker, everywhere: the one bone-bright character that says -// "you are here" — it replaces a sidebar row's status dot, a transcript -// line's gutter icon, and the focused composer's prompt. One char, always the -// accent, so the eye finds the cursor instantly anywhere in the console. The -// thick right-arrow is reserved for selection alone; statuses are colored dots. +// THE selection marker, everywhere: the one character that says "you are here" +// — it replaces a sidebar row's status dot, a transcript line's gutter icon, +// and the focused composer's prompt. Always painted theme.cursor, the cyan that +// means nothing else, so the eye finds it instantly anywhere in the console. +// The thick right-arrow is reserved for selection alone; statuses are dots. export const SELECTION_GLYPH = '▶' // Whether the composer can send to this session, and — when it can't — why. diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 0af4df7..eefbcf9 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -11,8 +11,9 @@ import chalk from 'chalk' // // One rule carried over from the web app (landing globals.css `.dark`): the // accent in dark mode is BONE, not brand blue. Brand ink #175173 scores -// 1.79:1 on the panel — unreadable as terminal text. So "you are here" is -// carried by brightness (bone against stone), not by hue. +// 1.79:1 on the panel — unreadable as terminal text. So emphasis is carried by +// brightness (bone against stone), not by hue. The ▶ cursor is the one +// exception, and takes `cursor` below. // // Because the CLI paints its own canvas, the palette only holds if it is used // for EVERY cell of the frame. Two rules keep it whole on a terminal whose own @@ -86,6 +87,13 @@ export const theme = { foreground: '#f0efe9', muted: '#a8a59c', + // The ▶ cursor, and nothing else. Bone-on-stone was too quiet a step to find + // at a glance on a busy frame, so the cursor carries HUE as well as + // brightness: cyan is the one hue not already spoken for (green = done, + // amber = working, red = failed), so it never reads as a status. 9.7:1 on + // the canvas and 7.2:1 on the active surface, so it holds up highlighted. + cursor: '#5fd3e0', + // Status. Tuned for the charcoal canvas, not the light one. success: '#4ebc7b', error: '#e5544b', diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 3cf5b2f..4c6bd8f 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -671,9 +671,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The rendered transcript lines, in order: collapsed (the default) folds // 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 opened in place with → (openedKeys), where the fold is REPLACED by + // the calls it stood for — "Ran 2 shell commands" above the two commands is + // just a stale count of what you can already see — and ← folds them back. 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. @@ -687,14 +687,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // 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 (!isToolActivity(item)) { + out.push(item) parent = item.key continue } - if (item.key.startsWith('grp:') && parent !== null && openedKeys.has(parent)) { - out.push(...foldRun(item.key, base)) - } + const open = item.key.startsWith('grp:') && parent !== null && openedKeys.has(parent) + if (open) out.push(...foldRun(item.key, base)) + else out.push(item) } return out }, [items, expanded, pendingTools, openedKeys]) @@ -791,7 +791,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { text: liveText, label: 'Generating…', tick: 'elapsed' as const, - suffix: liveTokens != null ? `· ↓ ${formatTokens(liveTokens)} tokens` : '', + suffix: liveTokens != null ? `${formatTokens(liveTokens)} tokens` : '', // The ⏺ line sits directly under the prose it describes. hug: liveText !== '', nested: false, @@ -802,9 +802,19 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { pendingTools.length === 1 ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…` : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…` - // A running tool call nests under the message that made it, in the - // same place its ⎿ result will land. - return { text: '', label, tick: 'tool' as const, suffix: '', hug, nested: true } + // A running tool call nests under the message that made it, in the same + // place its ⎿ result will land — so it nests on the same terms layOutItems + // uses for that result: only under ASSISTANT prose, never off your own + // message, where a ⎿ branch would read as work you did. + const said = visible.filter((i) => !isToolActivity(i)).pop() + return { + text: '', + label, + tick: 'tool' as const, + suffix: '', + hug, + nested: said?.kind === 'assistant', + } } if (working && !infraActivity && (awaitingAgent !== null || sendPending)) { return { @@ -878,7 +888,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // full-colour ◆ rows ABOVE the live activity — the running turn is the // response to THIS message, so its stream belongs below it. for (const q of inFlightSends.filter((q) => q.state === 'accepted')) { - out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true })) + out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true, panel: true })) } if (liveTail.text) { out.push(...pendingMessageRows('live', liveTail.text, cols, { gutter: '' })) @@ -906,6 +916,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { dim: true, right: 'queued', pulse: true, + panel: true, }), ) } @@ -920,6 +931,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { dim: true, right: q.state === 'sending' ? 'sending' : q.state === 'queued' ? 'queued' : 'cancelled', pulse: waiting, + panel: true, }), ) } @@ -1297,13 +1309,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { key={row.id} row={row} cols={cols} - // 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) - } + // Every row of the selected BLOCK, tool rows included: the ▶ marks + // one of them, and the rest read "→ to expand" on their clamp hint. + selected={navKey !== null && navKeyOf(row) === navKey} marker={row.id === markerRowId} // Both ticking values are passed as constants to rows that don't // use them, so React.memo skips those rows entirely: the @@ -1378,7 +1386,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { key={`${composer.text}:${composer.cursor}:${focused && navKey === null}`} color={theme.foreground} > - + {SELECTION_GLYPH}{' '} {composer.text.slice(0, composer.cursor)} @@ -1462,12 +1470,12 @@ function sandboxRows(o: { const { sandbox, infraActivity, settled, cols } = o const key = 'sandbox' const rows: TranscriptRow[] = [] - const width = contentWidth(cols, { panel: true }) - // Every row of the block sits on the panel and reserves the standard gutter, - // so the ▶ marker lands in the headline's mark slot when the block is - // highlighted — the same treatment every other entry gets. + const width = contentWidth(cols) + // Every row of the block reserves the standard gutter, so the ▶ marker lands + // in the headline's mark slot when the block is highlighted — the same + // treatment every other entry gets. const line = (spans: RowSpan[], extra: Partial = {}): void => { - rows.push({ id: `${key}:r${rows.length}`, entryKey: key, panel: true, spans, ...extra }) + rows.push({ id: `${key}:r${rows.length}`, entryKey: key, spans, ...extra }) } // The conversation's opening line: where it lives. Plain text — an OSC 8 // hyperlink here gets broken by ink's wrapping and swallows the label; the @@ -1840,8 +1848,8 @@ export function deliveredUnechoedSends( // scales down with size: under 1s reads as milliseconds ("428ms"), under 5s // keeps one decimal ("1.2s", trimming a trailing .0), and everything longer // reads as whole h/m/s components with zero parts dropped ("10s", "1m 2s", -// "2m", "1h 3m 30s"). The one duration format everywhere in the app, always -// shown parenthesized: "(10s)". Pure, for tests. +// "2m", "1h 3m 30s"). The one duration format everywhere in the app, and it +// reads bare — a readout, not a parenthetical aside. Pure, for tests. export function humanDuration(seconds: number): string { const clamped = Math.max(0, seconds) if (clamped === 0) return '0s' @@ -1986,14 +1994,13 @@ export function deriveSandboxState( if (p.status === 'completed' || p.status === 'failed') { const detail = p.detail && typeof p.detail === 'object' ? (p.detail as Record) : {} - // "full build (2s)", "(42s)", or a bare tier — the duration always - // parenthesized (the app-wide duration format). + // "Preparing image, full build, 2s" — the label then its readout, + // comma-separated like every other metadata line in the app. const tier = cacheTierLabel(detail.cache_tier) const dur = msLabel(p.duration_ms) - const note = [...(tier ? [tier] : []), ...(dur ? [`(${dur})`] : [])].join(' ') const failed = p.status === 'failed' const base = failed ? `${label} failed` : label - const text = note ? (note.startsWith('(') ? `${base} ${note}` : `${base} · ${note}`) : base + const text = [base, ...(tier ? [tier] : []), ...(dur ? [dur] : [])].join(', ') const line = open.get(key) if (line) { // Close the line this phase opened, in place: one line per phase, @@ -2031,8 +2038,11 @@ export function deriveSandboxState( push( record, 'done', - ['Sandbox ready', ...(tier ? [tier] : [])].join(' · ') + - (totalSeconds > 0 ? ` (${humanDuration(totalSeconds)})` : ''), + [ + 'Sandbox ready', + ...(tier ? [tier] : []), + ...(totalSeconds > 0 ? [humanDuration(totalSeconds)] : []), + ].join(', '), ) sandboxDone = true // The box coming up is the session-level outcome too. @@ -2064,10 +2074,10 @@ export function deriveSandboxState( // belt-and-braces guarantee — a row that wrapped would push every row below it // down and slide the window out of sync with the scroll position. // -// The selected row steps onto the lighter active surface, the app-wide "you are -// here" treatment (the focused composer, sidebar rows, dropdown options all -// match). Never inverse: a bone-white bar is far too loud on the charcoal -// canvas. +// Selection is carried by the cyan ▶ in the gutter and NOTHING else: no tint, +// no recolored text. A highlight bar across a multi-row block is a lot of paint +// for "you are here", and it fought with the one panel tint that still means +// something (a message you sent). The marker is one glyph and unmistakable. const RowLine = React.memo(function RowLine({ row, cols, @@ -2078,10 +2088,12 @@ const RowLine = React.memo(function RowLine({ }: { row: TranscriptRow cols: number + // This row belongs to the selected BLOCK. It changes nothing visually — only + // which key the "+N lines" hint names (→ vs ctrl+r). selected: boolean - // Whether THIS row carries the ▶ selection marker in its gutter. Every row of - // the highlighted block is `selected` (they all tint), but only one is the - // marker row — see markerRowId. + // Whether THIS row carries the ▶ marker in its gutter. Every row of the + // selected block is `selected`, but only one is the marker row — see + // markerRowId. marker: boolean // The row's ticking duration, resolved here so the once-a-second tick // repaints this line instead of rebuilding the transcript's rows. @@ -2090,11 +2102,7 @@ const RowLine = React.memo(function RowLine({ // the blink repaints the live lines and leaves the rest of the window alone. pulseOn: boolean }): React.ReactElement { - const background = selected || row.activeRow - ? SURFACE_ACTIVE - : row.panel - ? SURFACE_ELEVATED - : undefined + const background = row.panel ? SURFACE_ELEVATED : undefined // The "+N lines" marker's hint names the key that actually opens it: → when // the line is highlighted, ctrl+r otherwise. const spans: RowSpan[] = row.clampedLines @@ -2110,9 +2118,10 @@ const RowLine = React.memo(function RowLine({ // span with no colour of its own onto real hexes, so nothing on the row is // left to the terminal's own palette. See its comment in transcriptRows. const markColor = (span: RowSpan): string => spanColor(span, pulseOn) - // Durations always render parenthesized, in the right-hand metadata column. + // The right-hand metadata column reads as plain prose — "23s, 4 tokens", no + // parentheses and no interpuncts. It is a readout, not an aside. const right = row.tick - ? { text: `(${[humanDuration(seconds), row.right?.text].filter(Boolean).join(' ')})`, dim: true } + ? { text: [humanDuration(seconds), row.right?.text].filter(Boolean).join(', '), dim: true } : row.right // height=1 is load-bearing: a blank row (a spacer, or a message panel's pad) // has no text, and ink collapses an empty Box to zero height — the row would @@ -2121,7 +2130,7 @@ const RowLine = React.memo(function RowLine({ // width, so the panel reads as a block, not a ragged strip behind the text. return ( - {row.panel && } + {row.indent ? : null} {/* The gutter glyph, or the selection marker in its place on the one @@ -2131,20 +2140,19 @@ const RowLine = React.memo(function RowLine({ itself never changes, so the column holds still and the eye reads a heartbeat rather than a character swapping in and out. */} {marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')} + {/* A ⎿ item's extra breathing room, on every row of it so a wrapped + result stays aligned under its first line. */} + {row.textPad ? : null} {spans.map((span, i) => ( - + {span.text} ))} @@ -2152,15 +2160,12 @@ const RowLine = React.memo(function RowLine({ {right && ( - + {right.text} )} - {row.panel && } + ) }) diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index dd2bc4e..669aaf4 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -591,7 +591,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { {selected === 'new' && navFocused ? ( - {`${SELECTION_GLYPH} ${NAV_NEW_LABEL.slice(2)}`} + {SELECTION_GLYPH} + {` ${NAV_NEW_LABEL.slice(2)}`} ) : ( @@ -618,7 +619,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { - + {cursorHere ? SELECTION_GLYPH : g.glyph} {' '} {' '} - + {hovered ? SELECTION_GLYPH : ' '} {' '} @@ -1125,7 +1126,7 @@ function NewSessionPane({ return ( - + {active ? SELECTION_GLYPH : ' '} {' '} {r.label}: @@ -1157,7 +1158,7 @@ function NewSessionPane({ key={`${text}:${cursor}:${focused && row === 'prompt'}`} color={theme.foreground} > - + {focused && row === 'prompt' && openPicker === null ? SELECTION_GLYPH : ' '}{' '} {text.slice(0, cursor)} diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 658f732..d060d8a 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -24,11 +24,12 @@ import { theme } from '../lib/theme' // shifting, and a wrapped line's continuation aligns under its first. 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. 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. +// Horizontal pad on EVERY transcript row — the text sits one cell off the +// pane's edge, like the composer's interior. Universal, not panel-only: a +// panelled row is one the pad happens to be tinted on, so your messages and +// the agent's share one left edge instead of stepping in and out by a column. +// The VERTICAL pad is a blank tinted row above and below each panel block, +// added in one place (padPanelBlocks) after the rows are assembled. export const MESSAGE_PAD = 1 // Long bodies collapse to this many lines until ctrl+r (or → on the line) @@ -92,19 +93,19 @@ export type TranscriptRow = { // Blank columns before the gutter: an opened fold's children sit one level // in, so they read as the fold's children. indent?: number + // Extra blank columns BETWEEN the gutter and the text. Set on every row of a + // ⎿ item, continuation rows included, so the whole body stays aligned — see + // BRANCH_TEXT_PAD. + textPad?: number spans: RowSpan[] // Right-aligned metadata (a ticking duration, a pipeline state). The row's // spans are fitted to the columns left over. right?: RowSpan - // Sits on a message panel: the elevated tint, with a horizontal pad. + // Sits on a message panel: the elevated tint. Only a message YOU sent does. panel?: boolean - // 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. 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. + // A blank separator row. Off-panel it is bare canvas, so the gap between + // blocks reads as a gap. On a panel (panel + spacer) it is the block's + // vertical pad, and carries the tint. 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 @@ -134,6 +135,12 @@ export const LIVE_GLYPH = '⏺' // It reads as a branch off the prose above, which is what the nesting means. export const BRANCH_GLYPH = '⎿' +// Extra columns between a ⎿ and its text. The glyph's ink runs right up to its +// cell's edge, so the one space every other mark gets is not enough — the body +// reads as touching the branch. Applied to every row of the item, so a wrapped +// result and its "+N lines" marker stay aligned under the first line. +export const BRANCH_TEXT_PAD = 1 + // Columns a nested line shifts right, so its branch glyph sits under the // parent's text rather than under the parent's own mark. export const NEST_INDENT = 2 @@ -141,10 +148,10 @@ export const NEST_INDENT = 2 // Printable columns a row's text may occupy in a pane `cols` wide. export function contentWidth( cols: number, - opts: { panel?: boolean; indent?: number } = {}, + opts: { indent?: number; textPad?: number } = {}, ): number { - const pad = opts.panel ? MESSAGE_PAD * 2 : 0 - return Math.max(8, cols - pad - GUTTER_COLS - (opts.indent ?? 0)) + const taken = MESSAGE_PAD * 2 + GUTTER_COLS + (opts.indent ?? 0) + (opts.textPad ?? 0) + return Math.max(8, cols - taken) } export function spacerRow(entryKey: string, id: string): TranscriptRow { @@ -159,10 +166,10 @@ export function navKeyOf(row: TranscriptRow): string { } // 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 +// rows — the vertical pad around a message YOU sent, 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. +// run of consecutive sends reads 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 @@ -195,27 +202,26 @@ export function itemRows( cols: number, opts: { indent?: number; clamp: boolean; nested?: boolean; attach?: boolean }, ): TranscriptRow[] { - // 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. A ✦ notice - // ("Session asleep", "Stopped the agent") is an event in the conversation, so - // it takes a panel of its own rather than sitting bare on the canvas. - const panel = isMessage(item) || opts.nested === true || item.kind === 'notice' + // Only what YOU said sits on the lifted, padded panel the composer uses. + // Everything the agent says or does — prose, tool chatter, notices — stays + // bare on the canvas, so the transcript reads as dense output with your turns + // marked out of it. + const panel = item.kind === 'user' const indent = opts.indent ?? 0 - const width = contentWidth(cols, { panel, indent }) + // Nested lines are marked by their INDENT, so each keeps the glyph that says + // what it is: ● the call, ⎿ the result that came back. Only a collapsed fold + // ("Ran 2 tool calls") takes the branch glyph — as a notice it would + // otherwise wear ✦, the mark for the infrastructure speaking, which is not + // what a fold is. + const gutter = opts.nested && item.kind === 'notice' ? BRANCH_GLYPH : gutterFor(item) + const textPad = gutter === BRANCH_GLYPH ? BRANCH_TEXT_PAD : 0 + const width = contentWidth(cols, { indent, textPad }) const shown = withRenderedMarkdown(item, width) const clamped = opts.clamp && isCollapsible(shown) ? clampLines(shown.text, COLLAPSE_LINES) : { body: shown.text, more: 0 } const { gutterColor, textColor, dim, bold } = styleFor(shown) - // Nested lines are marked by their INDENT, so each keeps the glyph that says - // what it is: ● the call, ⎿ the result that came back. Only a collapsed fold - // ("Ran 2 tool calls") takes the branch glyph — as a notice it would - // otherwise wear ✦, the mark for the infrastructure speaking, which is not - // what a fold is. - const gutter = - opts.nested && shown.kind === 'notice' ? BRANCH_GLYPH : gutterFor(shown) const rows: TranscriptRow[] = [] // `attach` overrides the item's own spacing: a nested line sits directly @@ -231,6 +237,7 @@ export function itemRows( ? { text: gutter, color: gutterColor, dim: dim && !shown.isError } : undefined, indent, + textPad, spans, panel, ...extra, @@ -263,10 +270,10 @@ export function itemRows( // glyph, attached with no blank row between. Prose, user messages and notices // keep their own gutter mark and their spacing. // -// 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. +// The parent is always an ASSISTANT message. A run with none before it (the +// agent opened its turn with a tool call) stays FLAT rather than hanging off +// the user message that prompted it: your message is a lifted box, and a ⎿ +// branch under it would read as work YOU did. // // 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 @@ -274,12 +281,16 @@ export function itemRows( // // ● 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. +// opened with → the fold is REPLACED by what it stood for, at the same indent: +// +// ● the message a stop +// ● Bash(pytest) a stop of its own now +// ⎿ output part of that call's block (navKey → the call) +// +// So ↑ lands on the message with its tool chatter in tow; → swaps the fold for +// 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 @@ -298,15 +309,15 @@ export function layOutItems( 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. + // The ASSISTANT message the current run hangs off — null until the agent has + // said something, 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)) { out.push({ item, indent: 0, nested: false, attach: false }) - parent = item.key + parent = item.kind === 'assistant' ? item.key : null call = null continue } @@ -322,14 +333,9 @@ export function layOutItems( 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), + indent: nested ? NEST_INDENT : 0, nested, // Attach every line of the run: the first to its parent message, the // rest to the line above. @@ -350,12 +356,11 @@ export function isToolActivity(item: TranscriptItem): boolean { } // A live status line — "Generating…", "Running Bash(pytest…)…" — with its -// ticking readout in the right-hand metadata column. It sits on a panel like -// any other block, so a turn in flight is as legible as the messages around -// it. `hug` drops the spacer above so the line reads as part of the tool burst -// it belongs to. The duration is a `tick` marker rather than text: it changes -// every second, and baking it in here would re-wrap the transcript once a -// second. +// ticking readout in the right-hand metadata column. It is the agent working, so +// it stays bare on the canvas. `hug` drops the spacer above so the line reads as +// part of the tool burst it belongs to. The duration is a `tick` marker rather +// than text: it changes every second, and baking it in here would re-wrap the +// transcript once a second. export function activityRows( key: string, label: string, @@ -364,15 +369,14 @@ 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 — - // on that message's panel, inside its pad. A "Generating…"/"Working…" line - // describes the message itself and stays flat. + // that made the call, exactly where its ⎿ result will land a moment later. 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, panel: true }) - visibleWidth(suffix) - 16) + // 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 rows: TranscriptRow[] = hug || nested ? [] : [spacerRow(key, `${key}:sp`)] rows.push({ id: `${key}:r`, @@ -381,25 +385,33 @@ export function activityRows( indent, spans: [{ text: fitLines(label, width)[0] ?? '', dim: true }], right: { text: suffix, dim: true }, - panel: true, tick, pulse: true, }) return rows } -// An in-flight send, or the streaming assistant response: the same panel a -// committed message sits on, so nothing shifts when the real record lands. -// `pulse` marks the send as still in flight — the same breathing ⏺ a running -// tool wears, so a message the agent hasn't answered yet never reads as settled -// conversation. +// An in-flight send, or the streaming assistant response: laid out exactly like +// the committed record it becomes, so nothing shifts when that record lands — +// which is why `panel` is the caller's call (your send is panelled, the +// streaming response is not). `pulse` marks the send as still in flight — the +// same breathing ⏺ a running tool wears, so a message the agent hasn't answered +// yet never reads as settled conversation. export function pendingMessageRows( key: string, text: string, cols: number, - opts: { gutter: string; dim?: boolean; bold?: boolean; right?: string; pulse?: boolean }, + opts: { + gutter: string + dim?: boolean + bold?: boolean + right?: string + pulse?: boolean + panel?: boolean + }, ): TranscriptRow[] { - const width = contentWidth(cols, { panel: true }) + const panel = opts.panel ?? false + const width = contentWidth(cols) const rows: TranscriptRow[] = [spacerRow(key, `${key}:sp`)] const lines = fitLines(text, width) for (const [i, line] of lines.entries()) { @@ -412,7 +424,7 @@ export function pendingMessageRows( : undefined, spans: [{ text: line, dim: opts.dim, bold: opts.bold }], right: i === lines.length - 1 && opts.right ? { text: opts.right, dim: true } : undefined, - panel: true, + panel, pulse: i === 0 ? opts.pulse : undefined, }) } @@ -608,12 +620,6 @@ export function withRenderedMarkdown(item: TranscriptItem, width: number): Trans return rendered === item.text ? item : { ...item, text: rendered } } -// Messages (user + assistant prose) sit on the lifted panel the composer -// uses; tool chatter and notices stay on the canvas. -function isMessage(item: TranscriptItem): boolean { - return item.kind === 'user' || item.kind === 'assistant' -} - // Which items collapse when long: tool results and user turns (the latter carry // the re-injected run context, which is bulky). Assistant prose stays full. export function isCollapsible(item: TranscriptItem): boolean { diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 6f4b385..c4eabab 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -112,7 +112,7 @@ describe('deriveSandboxState', () => { // Not "Preparing image…" AND "Preparing image ✓" — the same line closes. expect(texts(state)).toEqual([ 'Starting sandbox…', - 'Preparing image · cached image (1.2s)', + 'Preparing image, cached image, 1.2s', 'Fetching repositories…', ]) expect(kinds(state)).toEqual(['step', 'done', 'step']) @@ -138,7 +138,7 @@ describe('deriveSandboxState', () => { // This is the point of the flat log: the output you want while a session // is slow to start is right there, not three keystrokes deep. expect(texts(state)).toEqual([ - 'Building image (42s)', + 'Building image, 42s', '#1 FROM base', '#2 RUN npm ci', 'Post-clone setup…', @@ -181,7 +181,7 @@ describe('deriveSandboxState', () => { ], 0, ) - expect(texts(state)).toEqual(['Running setup failed (4s)']) + expect(texts(state)).toEqual(['Running setup failed, 4s']) expect(kinds(state)).toEqual(['failed']) }) @@ -206,7 +206,7 @@ describe('deriveSandboxState', () => { expect(texts(state)).toEqual([ 'Starting sandbox…', 'Preparing image…', - 'Sandbox ready · cached image (29s)', + 'Sandbox ready, cached image, 29s', ]) // A phase still open when the box came up is no longer live. expect(kinds(state)).toEqual(['step', 'done', 'done']) @@ -550,9 +550,15 @@ describe('layOutItems', () => { expect(out[1]).toMatchObject({ indent: 2, nested: true }) }) - it('nests a turn-opening tool call under the user message that prompted it', () => { - const out = layOutItems([user('u'), call('t1')]) - expect(out[1]).toMatchObject({ indent: 2, nested: true }) + it('leaves a turn-opening tool call flat, never branching off YOUR message', () => { + // Your message is a lifted box; a ⎿ branch under it would read as work you + // did rather than work the agent did. + const out = layOutItems([user('u'), call('t1'), res('r1')]) + expect(out.map((p) => [p.item.key, p.indent, p.nested])).toEqual([ + ['u', 0, false], + ['t1', 0, false], + ['r1', 0, false], + ]) }) it('leaves a run with no parent above it flat', () => { @@ -561,11 +567,13 @@ describe('layOutItems', () => { expect(out.map((p) => p.nested)).toEqual([false, false, false]) }) - 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')], { + it("keeps an opened message's revealed calls at the fold's own indent", () => { + // The fold is REPLACED by its calls (see `visible` in ConnectApp), so there + // is no header above them to step in from. + const out = layOutItems([prose('a'), call('t1'), res('r1')], { openedKeys: new Set(['a']), }) - expect(out.map((p) => p.indent)).toEqual([0, 2, 4, 4]) + expect(out.map((p) => p.indent)).toEqual([0, 2, 2]) }) it('makes a tool call part of its message block, not a stop of its own', () => { @@ -580,19 +588,18 @@ describe('layOutItems', () => { }) 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')], { + const out = layOutItems([prose('a'), 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']) + // The call becomes its own stop and owns its result. + expect(out.map((p) => p.navKey)).toEqual([undefined, 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')], { + const out = layOutItems([prose('a'), call('t1'), res('r1')], { openedKeys: new Set(['a']), }) - expect(out[2].parentKey).toBe('a') + expect(out[1].parentKey).toBe('a') }) it('promotes every call when ctrl+r reveals the whole transcript', () => { @@ -786,7 +793,17 @@ describe('itemRows', () => { clamp: false, }) expect(rows).toHaveLength(1) - expect(rows[0].panel).toBe(true) + }) + + it('panels what you said and leaves everything the agent said on the canvas', () => { + const panelOf = (kind: TranscriptItem['kind'], nested = false): boolean | undefined => + itemRows({ key: 'a', kind, text: 'x' } as TranscriptItem, 40, { clamp: false, nested })[0] + .panel + expect(panelOf('user')).toBe(true) + expect(panelOf('assistant')).toBe(false) + expect(panelOf('notice')).toBe(false) + expect(panelOf('tool', true)).toBe(false) + expect(panelOf('tool_result', true)).toBe(false) }) it('emits one row per line of a multi-line body', () => { From 96b6463f276b7060f4fd30c8f3f1a80d49252591 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 5 Aug 2026 23:17:12 -0400 Subject: [PATCH 2/3] Fix four regressions from the density pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separating "which message does this run BELONG to" from "does it indent under that message" is the root fix for the first three: I had collapsed both onto one variable, so refusing to indent under your message also took away the run's owner. - a turn-opening run belongs to your message again, so → opens it and ← steps back out; it just doesn't branch off it visually - opening a long user message no longer unfolds the run below it as a side effect of un-clamping the text - a flat fold still wears ⎿, not the ✦ infrastructure mark - the ▶ falls to the topmost VISIBLE row of the selected block: with no highlight bar left, a block taller than the window showed no cursor --- src/ui/ConnectApp.tsx | 19 +++++++++++++------ src/ui/transcriptRows.ts | 39 ++++++++++++++++++++++++--------------- test/connect-app.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 4c6bd8f..9ca6f8f 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -979,14 +979,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return rowViewport(allRows.length, viewBudget, anchor) }, [allRows, viewBudget, scrollAnchor]) - // The one row that wears the ▶ marker: the highlighted block's FIRST row with - // a gutter glyph. The whole block tints, but the marker points at a single - // line — a block with nested tool activity has a glyph on the call and on its - // ⎿ result, and marking both reads as two separate selections. + // The one row that wears the ▶ marker: the selected block's FIRST row with a + // gutter glyph, since the marker replaces that glyph in place. Only one row + // takes it — a block with nested tool activity has a glyph on the call and on + // its ⎿ result, and marking both reads as two separate selections. + // + // Restricted to rows ON SCREEN, because the marker is now the ONLY thing that + // says "you are here" (there is no highlight bar any more). A block taller + // than the window is bottom-aligned by the ↑ snap, which puts its first row + // above the frame — so the marker falls to the topmost visible row of the + // block, and the selection stays legible instead of vanishing. const markerRowId = useMemo(() => { if (navKey === null) return null - return allRows.find((r) => navKeyOf(r) === navKey && r.gutter)?.id ?? null - }, [allRows, navKey]) + const onScreen = allRows.slice(view.start, view.end).filter((r) => navKeyOf(r) === navKey) + return (onScreen.find((r) => r.gutter) ?? onScreen.find((r) => !r.spacer))?.id ?? null + }, [allRows, navKey, view.start, view.end]) // Move the window by `delta` ROWS. Reaching the last row re-pins it to the // bottom, so streamed content follows again. diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index d060d8a..7c2c703 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -210,10 +210,11 @@ export function itemRows( const indent = opts.indent ?? 0 // Nested lines are marked by their INDENT, so each keeps the glyph that says // what it is: ● the call, ⎿ the result that came back. Only a collapsed fold - // ("Ran 2 tool calls") takes the branch glyph — as a notice it would - // otherwise wear ✦, the mark for the infrastructure speaking, which is not - // what a fold is. - const gutter = opts.nested && item.kind === 'notice' ? BRANCH_GLYPH : gutterFor(item) + // (key grp:*, "Ran 2 tool calls") takes the branch glyph — as a notice it + // would otherwise wear ✦, the mark for the infrastructure speaking, which is + // not what a fold is. Keyed on the fold itself, not on `nested`: a + // turn-opening run is flat, and it is still a fold. + const gutter = item.key.startsWith('grp:') ? BRANCH_GLYPH : gutterFor(item) const textPad = gutter === BRANCH_GLYPH ? BRANCH_TEXT_PAD : 0 const width = contentWidth(cols, { indent, textPad }) const shown = withRenderedMarkdown(item, width) @@ -270,14 +271,18 @@ export function itemRows( // glyph, attached with no blank row between. Prose, user messages and notices // keep their own gutter mark and their spacing. // -// The parent is always an ASSISTANT message. A run with none before it (the -// agent opened its turn with a tool call) stays FLAT rather than hanging off -// the user message that prompted it: your message is a lifted box, and a ⎿ -// branch under it would read as work YOU did. +// INDENT and OWNERSHIP are decided separately, because they answer different +// questions. A run belongs to (is opened by, travels with) whatever message +// came last, YOURS INCLUDED — the agent often opens a turn with a tool call, +// and a run that belonged to nothing could not be reached with →. But it only +// INDENTS under assistant prose: your message is a lifted box, and a ⎿ branch +// under it would read as work YOU did, so a turn-opening run stays flat and +// separated by its own blank row. Only a run at the very top of the transcript, +// with no message above it at all, belongs to nothing. // -// 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: +// Ownership is 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) @@ -309,19 +314,23 @@ export function layOutItems( opts: { openedKeys?: ReadonlySet; revealAll?: boolean } = {}, ): PlacedItem[] { const out: PlacedItem[] = [] - // The ASSISTANT message the current run hangs off — null until the agent has - // said something, where a run has nothing to hang off and stays flat. + // The message the current run BELONGS to — what → opens and ↑/↓ land on. + // Either sender's; null only at the head of the transcript. let parent: string | null = null + // Whether that message was the agent's, which is what decides the visual + // nesting: only the agent's prose gets a ⎿ branch under it. + let parentIsAgent = false // 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)) { out.push({ item, indent: 0, nested: false, attach: false }) - parent = item.kind === 'assistant' ? item.key : null + parent = item.key + parentIsAgent = item.kind === 'assistant' call = null continue } - const nested = parent !== null + const nested = parent !== null && parentIsAgent const revealed = opts.revealAll === true || (parent !== null && opts.openedKeys?.has(parent) === true) if (item.kind === 'tool') call = item.key diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index c4eabab..847ca9b 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -561,6 +561,14 @@ describe('layOutItems', () => { ]) }) + it('still lets that flat run BELONG to your message, so → can open it', () => { + // Indent and ownership are separate: the run doesn't branch off your + // message visually, but it is reached by opening it. Owning nothing would + // make the run unreachable without ctrl+r. + const out = layOutItems([user('u'), fold('t1')]) + expect(out[1].navKey).toBe('u') + }) + it('leaves a run with no parent above it flat', () => { // Replayed history can start mid-burst; there is nothing to hang off. const out = layOutItems([call('t1'), res('r1'), prose('a')]) @@ -832,6 +840,27 @@ describe('itemRows', () => { expect(withGutter[0].gutter?.text).toBe('◆') }) + it('marks a fold ⎿ whether or not it nests, since a flat one is still a fold', () => { + const foldItem: TranscriptItem = { key: 'grp:t1', kind: 'notice', text: 'Ran 1 shell command' } + for (const nested of [true, false]) { + const rows = itemRows(foldItem, 40, { clamp: false, nested }) + expect(rows[0].gutter?.text, `nested=${nested}`).toBe('⎿') + } + // A real ✦ notice keeps its own mark either way. + const notice: TranscriptItem = { key: 'n', kind: 'notice', text: 'Session asleep' } + expect(itemRows(notice, 40, { clamp: false, nested: true })[0].gutter?.text).toBe('✦') + }) + + it('pads every row of a ⎿ item, so a wrapped body stays aligned', () => { + const rows = itemRows({ key: 'r', kind: 'tool_result', text: 'a\nb', gutter: '⎿' }, 40, { + clamp: false, + }) + expect(rows.map((r) => r.textPad)).toEqual([1, 1]) + // And nothing else gets it. + expect(itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40, { clamp: false })[0].textPad) + .toBe(0) + }) + it('leads with a spacer row when the item wants space before it', () => { const rows = itemRows({ key: 'a', kind: 'notice', text: 'note', spaceBefore: true }, 40, { clamp: false, From 540d64036e181fa4c403c4474ce7148f5e7f8fe3 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 5 Aug 2026 23:30:38 -0400 Subject: [PATCH 3/3] Let a tool run branch off a thinking block, not just prose With extended thinking on, thinking -> tool_use -> tool_result is the usual turn shape, so treating thinking as not-the-agent flattened almost every run and took the hierarchy with it. Both the committed rows and the live activity line now share one isAgentSpeech predicate; when they disagreed, the running line sat flat and jumped a level as its result landed. --- src/ui/ConnectApp.tsx | 9 +++++---- src/ui/transcriptRows.ts | 21 +++++++++++++++------ test/connect-app.test.ts | 9 +++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 9ca6f8f..3e8142c 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -43,6 +43,7 @@ import { contentWidth, entryRange, GUTTER_COLS, + isAgentSpeech, isCollapsible, isToolActivity, itemRows, @@ -803,9 +804,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…` : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…` // A running tool call nests under the message that made it, in the same - // place its ⎿ result will land — so it nests on the same terms layOutItems - // uses for that result: only under ASSISTANT prose, never off your own - // message, where a ⎿ branch would read as work you did. + // place its ⎿ result will land — so it takes the SAME predicate + // layOutItems uses for that result. Any disagreement here shows up as the + // live line sitting flat and then jumping a level when the result lands. const said = visible.filter((i) => !isToolActivity(i)).pop() return { text: '', @@ -813,7 +814,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { tick: 'tool' as const, suffix: '', hug, - nested: said?.kind === 'assistant', + nested: said != null && isAgentSpeech(said), } } if (working && !infraActivity && (awaitingAgent !== null || sendPending)) { diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 7c2c703..2cecaef 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -275,10 +275,11 @@ export function itemRows( // questions. A run belongs to (is opened by, travels with) whatever message // came last, YOURS INCLUDED — the agent often opens a turn with a tool call, // and a run that belonged to nothing could not be reached with →. But it only -// INDENTS under assistant prose: your message is a lifted box, and a ⎿ branch -// under it would read as work YOU did, so a turn-opening run stays flat and -// separated by its own blank row. Only a run at the very top of the transcript, -// with no message above it at all, belongs to nothing. +// INDENTS under something the AGENT said (isAgentSpeech: prose or ✻ thinking): +// your message is a lifted box, and a ⎿ branch under it would read as work YOU +// did, so a turn-opening run stays flat and separated by its own blank row. +// Only a run at the very top of the transcript, with no message above it at +// all, belongs to nothing. // // Ownership is 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 → @@ -318,7 +319,7 @@ export function layOutItems( // Either sender's; null only at the head of the transcript. let parent: string | null = null // Whether that message was the agent's, which is what decides the visual - // nesting: only the agent's prose gets a ⎿ branch under it. + // nesting: only what the agent said gets a ⎿ branch under it. let parentIsAgent = false // The call a ⎿ result belongs to, so a result travels with its own call. let call: string | null = null @@ -326,7 +327,7 @@ export function layOutItems( if (!isToolActivity(item)) { out.push({ item, indent: 0, nested: false, attach: false }) parent = item.key - parentIsAgent = item.kind === 'assistant' + parentIsAgent = isAgentSpeech(item) call = null continue } @@ -364,6 +365,14 @@ export function isToolActivity(item: TranscriptItem): boolean { return item.kind === 'tool' || item.kind === 'tool_result' || item.key.startsWith('grp:') } +// Whether a message is one the AGENT said, which is what a tool run may branch +// off with its ⎿. Its prose and its ✻ thinking both count — with extended +// thinking on, thinking is what most runs actually follow. Your own message +// does not: it is a lifted box, and a branch under it reads as work YOU did. +export function isAgentSpeech(item: TranscriptItem): boolean { + return item.kind === 'assistant' || item.kind === 'thinking' +} + // A live status line — "Generating…", "Running Bash(pytest…)…" — with its // ticking readout in the right-hand metadata column. It is the agent working, so // it stays bare on the canvas. `hug` drops the spacer above so the line reads as diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 847ca9b..d09e4ef 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -561,6 +561,15 @@ describe('layOutItems', () => { ]) }) + it('nests under a ✻ thinking block too — thinking is the agent speaking', () => { + // With extended thinking on, thinking → tool_use → tool_result is the usual + // turn shape, so treating thinking as not-the-agent would flatten almost + // every run in the transcript. + const think: TranscriptItem = { key: 'th', kind: 'thinking', text: 'hmm', gutter: '✻' } + const out = layOutItems([think, fold('t1')]) + expect(out[1]).toMatchObject({ indent: 2, nested: true, attach: true, navKey: 'th' }) + }) + it('still lets that flat run BELONG to your message, so → can open it', () => { // Indent and ownership are separate: the run doesn't branch off your // message visually, but it is reached by opening it. Owning nothing would