diff --git a/README.md b/README.md index e994007..0f3ca56 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ is migrated on first use — your existing login becomes a host named for its AP base. The config file also carries UI preferences. `"sessionBar"` scopes the session -list, which the interactive UI opens as a full screen with `ctrl+j`: +list, which the interactive UI opens as a full screen with `esc`: ```json { diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 38e6269..b456a62 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -37,7 +37,6 @@ import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { fitLines, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' import { inputSurface, theme } from '../lib/theme' -import { useAltScreen } from './altScreen' import { completedText, isCommandInput, @@ -48,31 +47,23 @@ import { import { VERSION } from '../lib/constants' import { activityRows, - anchorAt, - anchorIndex, BRANCH_GLYPH, BRANCH_TEXT_PAD, contentWidth, - entryRange, GUTTER_COLS, isAgentSpeech, - isCollapsible, isToolActivity, itemRows, layOutItems, LIVE_GLYPH, MESSAGE_PAD, - navKeyOf, NEST_INDENT, pendingMessageRows, - rowViewport, settledItemKeys, settledRowCount, - snapAnchorForEntry, spacerRow, spanColor, type RowSpan, - type ScrollAnchor, type TranscriptRow, } from './transcriptRows' @@ -83,21 +74,18 @@ import { // what you send. Rendering shape lives in @ellipsis-dev/sdk/store (pure); this // component owns the data flow, the composer, and the colours. // -// TWO VIEWS of the same transcript, because they want opposite things from the -// terminal: +// ONE VIEW of the transcript: settled rows are handed to , which prints +// them ONCE into the terminal's own scrollback and never repaints them. That is +// what makes wheel/trackpad scrolling, select/copy and clickable links work +// natively — they are the terminal's, not reimplementations. The price is that a +// printed row is frozen: it cannot re-wrap or re-fold. Only the unsettled tail +// plus the footer repaint. // -// * THE CHAT (resting). Settled rows are handed to , which prints them -// ONCE into the terminal's own scrollback and never repaints them. That is -// what makes wheel/trackpad scrolling, select/copy and clickable links work -// natively — they are the terminal's, not reimplementations. The price is -// that a printed row is frozen: it cannot re-wrap, re-fold, or take a -// selection marker. Only the unsettled tail plus the footer repaint. -// * THE BROWSER (ctrl+r). A windowed view of the whole conversation on the -// ALTERNATE screen, where rows can be repainted: folding tool runs open and -// shut, walking entries with ↑/↓, app-read wheel scrolling. esc restores the -// chat's screen exactly as it was. -// -// `windowed` is the flag that says which one this frame is painting. +// There used to be a second, windowed view on the alternate screen (ctrl+r) that +// bought back repaintability — entry navigation, folding, app-read scrolling. It +// cost a scroll/anchor/selection machine bigger than the chat itself for a screen +// that duplicated what the terminal's own scrollback already shows, so it is +// gone. Long bodies stay clamped, and their full text is up in the scrollback. // // Data flow: ONE SessionTranscriptStore (pre-seeded by the caller with the // stored records + session, so the first paint is instant) is fed by the @@ -142,10 +130,9 @@ export interface ConnectAppProps { // Whether the app owns the keyboard. The host keeps exactly one input handler // active (session picker or chat); default true for the solo app. focused?: boolean - // Hand focus to the host's session picker. Fired by ctrl+j, by esc with - // nothing open (no browser, no transcript nav), and by ↓ at the bottom edge - // (the composer's last line, or a watch-only follow). Absent in the solo app, - // which has no picker to open. + // Hand focus to the host's session picker. Fired by esc, and by ↓ at the + // bottom edge (the composer's last line, or a watch-only follow). Absent in + // the solo app, which has no picker to open. onFocusNav?: () => void // Print a header above this session's first flushed row. Set by the host when // this chat REPLACES another session's chat: both print into the same @@ -177,10 +164,6 @@ const COMPOSER_INTERIOR_ROWS = 1 // vertical pad so the caret and text start well clear of the panel edge. const COMPOSER_PAD_X = 2 -// Rows one wheel notch moves. Terminals report a notch per tick, and one row -// per tick makes a trackpad feel like it's dragging through treacle. -const WHEEL_ROWS = 3 - // The startup block's entry key — it is one block, not a transcript item, so it // owns a fixed key rather than a feed_seq one. const SANDBOX_KEY = 'sandbox' @@ -277,60 +260,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The composer's text and caret position (0..text.length), one state so // rapid keypresses between renders can't desync them. Left/right move the // caret, up/down walk the lines of a multi-line input like a normal text - // editor, and up on line 1 hands focus to the transcript (navKey below). + // editor. const [composer, setComposer] = useState({ text: '', cursor: 0 }) - // ctrl+r toggles full vs. collapsed tool output across the whole transcript. - const [expanded, setExpanded] = useState(false) - // Transcript navigation: the key of the highlighted line ('sandbox' for the - // startup block, else a TranscriptItem key), or null while the composer has - // focus. Up from the composer's first line enters at the newest line; down - // past the newest line (or esc) returns to the composer. The highlighted - // line renders the cyan selection glyph in its gutter. - const [navKey, setNavKey] = useState(null) // Local ✦ lines in the transcript, for things the CLIENT did that the record // log will never carry: so far, /stop. It belongs in the conversation because // it is an event in the conversation — the notice bar above the composer is // for transient status, and scrolls away with nothing to show you asked. const [chatNotes, setChatNotes] = useState([]) - // Lines opened in place with → while highlighted: a grp:* fold expands into - // its tool calls, a clamped long body un-clamps. ← closes them again. - const [openedKeys, setOpenedKeys] = useState>(new Set()) - // The transcript viewport: the ROW pinned to the top of the window (as an - // entry + row offset, so appends and re-wraps can't slide it), or null to - // follow the bottom — the default, so streamed content stays in view. The - // wheel and ↑/↓ move it a row at a time; the highlight snaps it so the - // selected entry comes into frame. - const [scrollAnchor, setScrollAnchor] = useState(null) - // The transcript BROWSER: a windowed view of the whole conversation, opened - // over the alternate screen with ctrl+r and closed with esc. It exists because - // the scrollback view gives up the things a repaintable window can do — - // folding tool runs open and shut, walking entries with ↑/↓, re-wrapping on - // resize — so those move here instead of disappearing. - // - // While it is open the flush is withheld: a row flushed onto the alt - // screen would die with that buffer, so the primary buffer would come back - // missing exactly the rows that settled while you were reading. Held back, - // they flush on the way out. - const [windowed, setWindowed] = useState(false) - useAltScreen(windowed) - // Everything ↑/↓ can land on, read through a ref so opening the browser can - // select the newest entry without the selection effect re-firing every time a - // record lands and dragging the highlight back down the transcript. - const navKeysRef = useRef([]) - // Opening the browser lands on the newest entry, so →/← have something to - // open the moment the screen appears. Leaving drops the selection, the scroll - // position and the expand toggle: re-opening starts at the bottom of the - // conversation rather than wherever it was parked a conversation ago. - useEffect(() => { - if (windowed) { - const keys = navKeysRef.current - if (keys.length > 0) setNavKey(keys[keys.length - 1]) - return - } - setNavKey(null) - setScrollAnchor(null) - setExpanded(false) - }, [windowed]) // Messages you've sent that the server hasn't acknowledged yet — shown // IMMEDIATELY as dim rows at the bottom of the transcript, so a send always // appears in the chat the moment you hit enter. From the first @@ -401,11 +337,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { () => deriveSandboxState(snapshot.records, props.minRenderFeedSeq), [snapshot.records, props.minRenderFeedSeq], ) - // Whether a SETTLED block is showing its log again (→ opens it, ← closes it). - // A live start always shows the log — there is nothing to collapse until the - // session is up. - const [sandboxLogOpen, setSandboxLogOpen] = useState(false) - // Bodies of the server's PENDING inbox messages — the durable queued signal. const serverQueued = useMemo( () => snapshot.messages.filter((m) => m.status === 'pending').map((m) => m.body), @@ -656,10 +587,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { exit() return } - if (command.id === 'transcript') { - setWindowed(true) - return - } if (command.id === 'sessions') { if (props.onFocusNav) props.onFocusNav() else setNotice('no other sessions here · this is a single-session connect') @@ -715,14 +642,8 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ) // The composer renders whenever sending is possible. - // The browser drops the composer: it is a reader, and those rows are better - // spent on conversation. The keyboard handler stays active (it owns the - // browser's own keys), and its composer-editing branches are gated below. - const composerVisible = canSend && isRawModeSupported && !windowed - // The browser keeps the keyboard even though it has no composer — it is - // driven entirely by keys, esc included, so gating input on the composer - // would strand you on the alt screen. - const inputActive = (composerVisible || windowed) && focused + const composerVisible = canSend && isRawModeSupported + const inputActive = composerVisible && focused // The slash-command menu: the commands the typed line still matches. Derived, // not stored, so it cannot get out of step with the input — it is open exactly @@ -747,86 +668,40 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { setComposer({ text, cursor: text.length }) }, []) - // Mouse reporting (SGR) — the browser's wheel scrolling, and ONLY the - // browser's. It owns the alternate screen, whose scrollback is empty by - // definition, so the wheel is useless there unless the app reads it. The chat - // never arms it: down there the transcript is in the terminal's real - // scrollback, and capturing the mouse would take native scrolling, select/copy - // and clickable links away to reimplement what the terminal just did for free. - useEffect(() => { - if (!windowed || !inputActive || !stdout?.isTTY) return - stdout.write('\u001B[?1000h\u001B[?1006h') - return () => { - stdout.write('\u001B[?1006l\u001B[?1000l') - } - }, [windowed, inputActive, stdout]) - - // 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), 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. + // The rendered transcript lines, in order: consecutive tool activity folds + // into "Ran N …" notices, so a turn's work reads as one line instead of a + // wall of calls. 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 items - const folded = collapseToolRuns(base) - if (openedKeys.size === 0) return folded - const out: TranscriptItem[] = [] - // The message a fold hangs off: opening THAT is what reveals the run. - let parent: string | null = null - for (const item of folded) { - if (!isToolActivity(item)) { - out.push(item) - parent = item.key - continue - } - 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]) + return collapseToolRuns(base) + }, [items, pendingTools]) const infraActivity = statusActivityText(statusWord) // The startup story has settled: the start finished and nothing is live. This // is when the block collapses to its static one-line summary. const sandboxSettled = (sandbox?.done ?? false) && !infraActivity - useEffect(() => { - // A fresh start (wake/retry) re-opens the live hierarchy and re-arms the - // collapse for when it settles again. - if (!sandboxSettled) setSandboxLogOpen(false) - }, [sandboxSettled]) // How the pane's rows are divided: the footer (notice + composer + meta // line) is fixed, the chat window takes the rest, and the top padding is the // give — it shrinks, to nothing if it must, so the frame always fits. // // Fitting is not cosmetic: an over-tall frame scrolls ink's render region and // smears stale rows up the terminal. So the window's budget is whatever is - // left AFTER the footer, never a floor that could exceed the pane, and the - // window itself renders exactly that many rows (see rowViewport). + // left AFTER the footer, never a floor that could exceed the pane. // ctrl+c interrupts the turn, then quits: the first press sends the same // /stop the composer's command does, the second exits. Active whenever this // pane owns the keyboard, watch-only follows included (nothing to stop there, // but ctrl+c still has to be the way out). const ctrlCArmed = useCtrlCQuit( - isRawModeSupported && focused && (composerVisible || windowed || !hasHost), + isRawModeSupported && focused && (composerVisible || !hasHost), () => { if (working && canSend) submit('/stop') }, ) // Armed, the meta line below the composer becomes the ctrl+c prompt (it // says what a second press does), and the arm expires after a few seconds. - // The browser is for reading, not sending: it drops the composer for the rows - // (a whole screen of conversation is the point of opening it) and says so on - // the notice line, which is where the app's one line of transient guidance - // already lives. - const browserNotice = '↑↓ scroll · → open · ← close · ctrl+r expand all · esc back to the chat' - const shownNotice = windowed ? browserNotice : notice - const { viewBudget, padRows, composerRows, noticeRows, menuRows } = useMemo(() => { + const shownNotice = notice + const { viewBudget, composerRows, noticeRows, menuRows } = useMemo(() => { // Both wrapping parts of the footer are measured as the rows they will // actually OCCUPY, not as the newlines they contain: a notice ("stream // error: …") and a typed paragraph both wrap, and counting either as one @@ -855,22 +730,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const typedRows = fitLines(composer.text, composerTextCols(cols)).length const wanted = Math.max(COMPOSER_INTERIOR_ROWS, typedRows) + 2 const composerRows = composerVisible ? Math.max(1, Math.min(wanted, free - 1)) : 0 - const forContent = Math.max(1, free - composerRows) - // The browser fills the screen it took over, so it keeps a row of top - // padding; the chat is content-sized and grows downward, so it has no window - // edge to protect and takes none. - const pad = windowed ? Math.max(0, Math.min(1, forContent - 1)) : 0 - return { viewBudget: forContent - pad, padRows: pad, composerRows, noticeRows, menuRows } - }, [ - rows, - cols, - bottomSlack, - windowed, - composerVisible, - composer.text, - shownNotice, - menu.length, - ]) + return { + viewBudget: Math.max(1, free - composerRows), + composerRows, + noticeRows, + menuRows, + } + }, [rows, cols, bottomSlack, composerVisible, composer.text, shownNotice, menu.length]) // The live tail: the in-progress response and the one activity line under // it. Three distinct, factual signals — never whimsy — each rendered on the @@ -894,15 +760,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const liveTokens = snapshot.liveOutputTokens const generating = statusWord === 'working' && (liveText !== '' || liveTokens != null) const runningTool = statusWord === 'working' && !generating && pendingTools.length > 0 - // Whether the activity line hugs the block above it: in expanded mode the - // pending ● call itself is the last line; collapsed, when the trailing - // fold ("Ran N …", key grp:*) — or an opened fold's trailing tool line — - // is the same burst the pending call belongs to. + // Whether the activity line hugs the block above it: when the trailing fold + // ("Ran N …", key grp:*) is the same burst the pending call belongs to. const last = visible[visible.length - 1] - const hug = expanded - ? pendingTools.length > 0 - : last != null && - (last.key.startsWith('grp:') || last.kind === 'tool' || last.kind === 'tool_result') + const hug = + last != null && + (last.key.startsWith('grp:') || last.kind === 'tool' || last.kind === 'tool_result') if (generating) { return { text: liveText, @@ -950,7 +813,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { statusWord, pendingTools, visible, - expanded, working, infraActivity, awaitingAgent, @@ -961,44 +823,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // committed transcript, then the tail that only exists while a turn is live // (accepted sends, the streaming response, the activity line, queued sends). // The tail rides in the same list rather than rendering below the window, so - // it can't overflow the frame — and so scrolling up through it works like - // scrolling up through anything else. + // it can't overflow the frame. const allRows = useMemo(() => { const out: TranscriptRow[] = [] if (infraActivity || sandbox) { - out.push( - ...sandboxRows({ - sandbox, - infraActivity, - settled: sandboxSettled, - expanded: sandboxLogOpen, - cols, - }), - ) + out.push(...sandboxRows({ sandbox, infraActivity, settled: sandboxSettled, cols })) } // Tool activity is nested under the message that produced it (layOutItems - // 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. + // 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)) { out.push( - ...(placed.navKey || placed.parentKey - ? rows.map((r) => ({ ...r, navKey: placed.navKey, parentKey: placed.parentKey })) - : rows), + ...itemRows(placed.item, cols, { + indent: placed.indent, + nested: placed.nested, + attach: placed.attach, + }), ) } // Sends the agent has TAKEN (delivered, echo record still in flight): @@ -1062,44 +902,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { infraActivity, sandbox, sandboxSettled, - sandboxLogOpen, visible, - expanded, - openedKeys, inFlightSends, pendingPrompt, liveTail, cols, ]) - // 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) { - 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]) - navKeysRef.current = navKeys - - // The window on screen this frame. A stale anchor (its entry folded away or - // scrolled off the record log) falls back to following the bottom. - const view = useMemo(() => { - const anchor = scrollAnchor ? anchorIndex(allRows, scrollAnchor) : null - return rowViewport(allRows.length, viewBudget, anchor) - }, [allRows, viewBudget, scrollAnchor]) - // ---- the scrollback split ---- // Rows whose content is FINAL go to : printed once, into the // terminal's own scrollback, never repainted. Everything after them is the @@ -1110,19 +919,17 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // summary then, so the flushed copy is the one that lasts). It sits at the // top of the list, so nothing below it can flush while it is still moving. const settledKeys = useMemo(() => { - if (windowed) return new Set() const keys = settledItemKeys(visible, working) if (sandboxSettled) keys.add(SANDBOX_KEY) return keys - }, [windowed, visible, working, sandboxSettled]) + }, [visible, working, sandboxSettled]) // The rows already handed to , held APPEND-ONLY in a ref, and the // entries they covered. Both are needed, and neither can be replaced by // re-slicing allRows each frame: // // * prints `items.slice(printedCount)` and re-syncs printedCount // from items.length. So the list may only GROW, and the rows already in it - // may never change. Hand it a shorter list — which withholding the flush - // for the browser does — and it re-prints everything on the way back. + // may never change. Hand it a shorter list and it re-prints everything. // * The rows on the terminal are FROZEN TEXT, wrapped at the width they were // printed at. allRows re-wraps on resize, so a re-slice would hand // different rows for the same content and print them again. What @@ -1137,7 +944,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { props.scrollbackBreak ? [sessionBreakRow(sessionId, cols)] : [], ) const flushedEntries = useRef>(new Set()) - if (!windowed) { + { const settledRows = allRows.slice(0, settledRowCount(allRows, settledKeys)) const fresh = settledRows.filter((r) => !flushedEntries.current.has(r.entryKey)) if (fresh.length > 0) { @@ -1157,76 +964,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // filter always sees the boundary this frame just committed to. }, [allRows, viewBudget, staticRows]) - // 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 - 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. - const scrollByRows = useCallback( - (delta: number): void => { - const next = view.start + delta - // Reaching the last screenful re-pins to the bottom, so streamed content - // follows again instead of the window sitting one row short of it. - if (next >= allRows.length - view.capacity) setScrollAnchor(null) - else setScrollAnchor(anchorAt(allRows, Math.max(0, next))) - }, - [allRows, view], - ) - - // Snap the window so a highlighted entry is readable, given which way the - // highlight is travelling (`dir`: 1 for ↓, -1 for ↑). See snapToEntry. - const ensureVisible = useCallback( - (key: string, dir: 1 | -1 = 1): void => { - const move = snapAnchorForEntry(allRows, key, view, view.capacity, dir) - if (move) setScrollAnchor(move.anchor) - }, - [allRows, view], - ) - - // An entry too tall for the window is scrolled THROUGH before the highlight - // leaves it: while part of it is still out of frame in the direction you're - // heading, ↑/↓ move the window a row instead of jumping to the next entry. - // Returns whether it handled the keypress. - const revealMore = useCallback( - (key: string, delta: number): boolean => { - const range = entryRange(allRows, key) - if (!range) return false - const more = delta < 0 ? range.first < view.start : range.last >= view.end - if (!more) return false - scrollByRows(delta) - return true - }, - [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), @@ -1236,27 +973,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { useInput( (ch, key) => { - // SGR mouse reports (enabled above) arrive as escape sequences that - // ink's key parser passes through as plain text — catch them before - // they reach any text handling. Wheel up/down (buttons 64/65) scroll - // the viewport; everything else (clicks, drags) is swallowed. - if (ch && MOUSE_SEQ_RE.test(ch)) { - let delta = 0 - for (const m of ch.matchAll(/\[<(\d+);\d+;\d+[Mm]/g)) { - if (m[1] === '64') delta -= WHEEL_ROWS - else if (m[1] === '65') delta += WHEEL_ROWS - } - if (delta !== 0 && windowed) scrollByRows(delta) - return - } - // Page keys scroll the window a frame at a time, from the composer or - // the transcript alike — the fast way through a long conversation. In - // the scrollback view the terminal's own page keys do this, over the real - // scrollback, so the app leaves them alone. - if (key.pageUp || key.pageDown) { - if (windowed) scrollByRows(key.pageUp ? -view.capacity : view.capacity) - return - } // The command menu is the INNERMOST modal, so it takes its keys before // anything else: ↑/↓ walk it, tab/enter complete the highlighted command, // esc dismisses it by clearing the slash that opened it. Everything else @@ -1288,141 +1004,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } } if (key.escape) { - // Modal-first, outermost modal first: the browser is a screen of its - // own, so esc closes it before anything inside it is considered. - if (windowed) { - setWindowed(false) - return - } - // Transcript navigation drops back to the composer, then esc leaves - // the pane. - if (navKey !== null) { - setNavKey(null) - setScrollAnchor(null) - return - } // Hosted: hand focus to the session nav (stopping is the composer's // /stop command). Solo: esc-while-working keeps meaning stop. if (props.onFocusNav) props.onFocusNav() else if (working) submit('/stop') return } - // ctrl+r means "show me everything", and what that takes depends on where - // the transcript lives. In a repaintable window it expands every collapsed - // body and tool fold in place. In the scrollback view the printed rows - // can't be re-folded, so ctrl+r OPENS THE BROWSER — the windowed view of - // the whole conversation on the alt screen — where it can. Inside the - // browser it goes back to being the expand toggle. - if (key.ctrl && ch === 'r') { - if (windowed) setExpanded((v) => !v) - else setWindowed(true) - return - } - // ctrl+j opens the session picker. It needs a key of its own now that the - // list is a screen rather than a band you can see: esc and ↓ still reach - // it, but neither says it is there. - if (key.ctrl && ch === 'j' && props.onFocusNav) { - props.onFocusNav() - return - } - if (navKey !== null) { - // Transcript navigation: ↑/↓ walk the entries, snapping the window so - // the highlighted one is readable; →/enter opens the highlighted one, - // ← closes it, typing drops back to the composer. - const idx = navKeys.indexOf(navKey) - if (key.upArrow) { - // A message taller than the window is READ before it is left: ↑ - // scrolls up inside it while any of it is still below the frame, - // and only moves to the previous entry once its top is on screen. - if (revealMore(navKey, -1)) return - const target = idx === -1 ? navKeys.length - 1 : Math.max(0, idx - 1) - if (navKeys.length > 0) { - setNavKey(navKeys[target]) - ensureVisible(navKeys[target], -1) - } - return - } - if (key.downArrow) { - if (revealMore(navKey, 1)) return - if (idx === -1 || idx >= navKeys.length - 1) { - setNavKey(null) - setScrollAnchor(null) - } else { - setNavKey(navKeys[idx + 1]) - ensureVisible(navKeys[idx + 1], 1) - } - return - } - if (key.rightArrow || key.return) { - if (navKey === 'sandbox') { - // One level, one keystroke: → shows the startup log again on a - // 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 && (hasToolRun(navKey) || isCollapsible(item))) { - setOpenedKeys((prev) => new Set(prev).add(navKey)) - } - } - return - } - if (key.leftArrow) { - // ← 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)) { - setOpenedKeys((prev) => { - const next = new Set(prev) - 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 - } - if (ch && !key.ctrl && !key.meta && composerVisible) { - setNavKey(null) - setScrollAnchor(null) - insertAtCursor(ch) - } - return - } - // The browser has no composer and no session nav to hand focus to: with - // nothing highlighted yet, ↑/↓ scroll the window a notch (entering the - // ↑ walk above once something is selected). esc — handled at the top — is - // the only way out. - if (windowed) { - if (key.upArrow) scrollByRows(-1) - else if (key.downArrow) scrollByRows(1) - return - } - // Below here are the composer's own keys. Watch-only (no composer): - // ↑ still enters transcript navigation, ↓ leaves for the session nav; - // everything else is inert. + // Below here are the composer's own keys. Watch-only (no composer): ↓ + // leaves for the session nav; everything else is inert. if (!composerVisible) { - if (key.upArrow && windowed && navKeys.length > 0) { - setNavKey(navKeys[navKeys.length - 1]) - setScrollAnchor(null) - } else if (key.downArrow && props.onFocusNav) { - props.onFocusNav() - } + if (key.downArrow && props.onFocusNav) props.onFocusNav() return } if (key.return) { @@ -1437,16 +1028,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return } if (key.upArrow) { - // Up inside a multi-line input climbs a line; up on line 1 moves - // focus into the transcript, landing on the newest line. Not in - // the scrollback view: the transcript up there is the terminal's, not - // the app's, so there is nothing to put a highlight on. + // Up inside a multi-line input climbs a line; on line 1 there is + // nowhere to go — the transcript above is the terminal's scrollback, + // which its own keys and wheel move. const up = cursorLineUp(composer.text, composer.cursor) if (up !== null) setComposer((c) => ({ ...c, cursor: up })) - else if (windowed && navKeys.length > 0) { - setNavKey(navKeys[navKeys.length - 1]) - setScrollAnchor(null) - } return } if (key.downArrow) { @@ -1517,64 +1103,39 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // the squeeze resolves inside (the overflow-hidden viewport absorbs it). {/* The settled transcript, printed once into the terminal's own scrollback and never repainted — which is what makes the wheel, the - trackpad, and select/copy work natively above the live frame. Empty - (and inert) in a hosted pane, which can't own the scrollback, and - while the browser holds the alt screen. */} - {!windowed && ( - - {(row) => ( - // Flushed rows are frozen: no selection marker, no live tick, no - // pulse — every one of those repaints, and a printed row can't - // repaint. `seconds` is 0 and `pulseOn` true so a row that WAS - // live prints in its settled state. - - )} - - )} - {padRows > 0 && } - {/* The chat window: ONE flat list of rows, sliced to exactly the rows - that fit. Everything lives in it — the startup block, the - transcript, in-flight sends, the live activity lines — so nothing - can render past the frame and every line on screen is scrollable. - Dim markers count what is out of frame above/below, and they sit - inside the budget so they never push a row out. justify-end puts a - short transcript's slack ABOVE the rows, so a new session's messages - hug the composer and grow upward; a full window is unaffected — - rowViewport already emits exactly the rows that fit. */} + trackpad, and select/copy work natively above the live frame. */} + + {(row) => ( + // Flushed rows are frozen: no live tick, no pulse — both repaint, and + // a printed row can't. `seconds` is 0 and `pulseOn` true so a row that + // WAS live prints in its settled state. + + )} + + {/* The live region: the unsettled tail of the same flat row list — the + startup block while it runs, the streaming response, in-flight sends, + the live activity lines. It is sized to its content and grows downward, + so it must not claim the terminal's leftover height. */} - {windowed && view.showAbove && ( - - {` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`} - - )} - {(windowed ? allRows.slice(view.start, view.end) : liveRows).map((row) => ( + {liveRows.map((row) => ( ))} - {windowed && view.showBelow && ( - - {` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`} - - )} {/* flexShrink=0: when a mis-estimated transcript slice overflows the fixed pane, the squeeze lands on the (overflow-hidden) viewport @@ -1641,29 +1197,23 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { > {/* One parent Text so a multi-line input flows as a single block (sibling Texts in a row Box would render as columns). The - caret is the inverse cell at the cursor, hidden while the - transcript has focus (navKey) and while the pane itself is - unfocused (the sidebar has the keyboard). A caret sitting on - a newline renders as an inverse space at that line's end. The - key remounts the node on every content change: ink reuses the - previous measurement when nested text mutates in place, and - the stale (narrower) width wraps the caret onto the border - row below. */} - {/* The prompt is the selection glyph while the composer is where - you are (focused, no transcript highlight) — the same cyan - marker as everywhere else — and dim when it isn't. */} + caret is the inverse cell at the cursor, hidden while the pane + itself is unfocused (the sidebar has the keyboard). A caret + sitting on a newline renders as an inverse space at that line's + end. The key remounts the node on every content change: ink + reuses the previous measurement when nested text mutates in + place, and the stale (narrower) width wraps the caret onto the + border row below. */} {/* The explicit colour on the parent is what the bare text children below inherit, and it gives the inverse caret a known pair of colours to swap. */} - - {SELECTION_GLYPH}{' '} - + {SELECTION_GLYPH} {composer.text.slice(0, composer.cursor)} - {focused && navKey === null && ( + {focused && ( {composer.cursor < composer.text.length && composer.text[composer.cursor] !== '\n' @@ -1672,7 +1222,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { )} {composer.cursor < composer.text.length - ? focused && navKey === null && composer.text[composer.cursor] !== '\n' + ? focused && composer.text[composer.cursor] !== '\n' ? composer.text.slice(composer.cursor + 1) : composer.text.slice(composer.cursor) : ''} @@ -1692,8 +1242,8 @@ function formatTokens(n: number): string { // Where the caret lands after ↑ in the composer: the same column on the // previous line (clamped to that line's length, text-editor style), or null -// when the caret is already on the first line — the signal to move focus up -// into the transcript. Pure, for tests. +// when the caret is already on the first line — there is nowhere further up to +// go. Pure, for tests. export function cursorLineUp(text: string, cursor: number): number | null { const lineStart = cursor > 0 ? text.lastIndexOf('\n', cursor - 1) + 1 : 0 if (lineStart === 0) return null @@ -1733,11 +1283,6 @@ function sessionBreakRow(sessionId: string, cols: number): TranscriptRow { } } -// One or more SGR mouse reports (\x1b[ = {}): void => { rows.push({ id: `${key}:r${rows.length}`, entryKey: key, spans, ...extra }) } @@ -1822,10 +1365,10 @@ function sandboxRows(o: { // drilling. While the agent is coming up you see the last SANDBOX_LOG_ROWS // lines of everything that has happened — including build and setup output, // which is the whole point of showing it — headed by a count of what scrolled - // past. Once it settles the block collapses to its summary line, and → on the - // highlighted block re-opens the same log to re-read it. + // past. Once it settles the block collapses to its summary line; the log + // itself stays up in the scrollback, where it was printed. if (!sandbox) return rows - const show = settled && !o.expanded ? [] : lastLines(sandbox.log, SANDBOX_LOG_ROWS) + const show = settled ? [] : lastLines(sandbox.log, SANDBOX_LOG_ROWS) const hidden = sandbox.log.length - show.length if (show.length > 0 && hidden > 0) { line([ @@ -1890,23 +1433,6 @@ function fit(text: string, width: number): string { return fitLines(text, Math.max(4, width))[0] ?? '' } -// The run of tool/tool_result items a collapsed fold stands for. A fold's key -// is grp: (see the SDK's collapseToolRuns), so the run is -// the consecutive tool activity starting at that item in the unfolded list. -// Pure, for tests. -export function foldRun(foldKey: string, items: readonly TranscriptItem[]): TranscriptItem[] { - const firstKey = foldKey.slice('grp:'.length) - const start = items.findIndex((i) => i.key === firstKey) - if (start < 0) return [] - const run: TranscriptItem[] = [] - for (let i = start; i < items.length; i++) { - const item = items[i] - if (item.kind !== 'tool' && item.kind !== 'tool_result') break - run.push(item) - } - return run -} - // A sandbox_output step identifier — payload.step ?? payload.phase — as a // human startup-phase label. Steps are null/'post_start'/'post_clone' and // phases 'setup'/'clone'/'hooks'; 'image.setup' is the legacy image step. @@ -2404,29 +1930,15 @@ export function deriveSandboxState( // One screen row. Exactly one terminal line by construction: the text was // pre-fitted to the pane (see transcriptRows), and wrap="truncate" is the // 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. -// -// Selection is carried by the cyan ▶ in the gutter and NOTHING else: no fill, no -// recoloured text. Partly taste — a highlight bar is a lot of paint for "you are -// here" — and partly forced: a row printed into scrollback can never be -// repainted, so it could not carry a highlight that comes and goes anyway. +// down and misalign the rows the frame budgeted for. const RowLine = React.memo(function RowLine({ row, cols, - selected, - marker, seconds, pulseOn, }: { 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 ▶ 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. seconds: number @@ -2434,15 +1946,10 @@ const RowLine = React.memo(function RowLine({ // the blink repaints the live lines and leaves the rest of the window alone. pulseOn: boolean }): React.ReactElement { - // The "+N lines" marker's hint names the key that actually opens it: → when - // the line is highlighted, ctrl+r otherwise. + // A clamped body says how much it cut, and nothing more: there is no key that + // un-clamps it, and the full text is up in the scrollback where it printed. const spans: RowSpan[] = row.clampedLines - ? [ - { - text: `… +${row.clampedLines} lines (${selected ? '→' : 'ctrl+r'} to expand)`, - dim: true, - }, - ] + ? [{ text: `… +${row.clampedLines} lines`, dim: true }] : row.spans // Every span, gutter mark and right-hand readout below paints an explicit // brand colour: `spanColor` resolves the pulse's off beat, a `dim` span and a @@ -2456,7 +1963,7 @@ const RowLine = React.memo(function RowLine({ : row.right // height=1 is load-bearing: a blank separator row has no text, and ink // collapses an empty Box to zero height — the row would silently vanish, - // leaving the window short of the rows the scroll math counted. + // leaving the frame short of the rows it budgeted for. // // No background: a row prints into the terminal's scrollback and is never // repainted, so any fill it carries is permanent and cannot be kept in step @@ -2467,17 +1974,15 @@ const RowLine = React.memo(function RowLine({ {row.indent ? : null} - {/* The gutter glyph, or the selection marker in its place on the one - marker row — same 1-char slot, so text never shifts when the - highlight lands. - A live row's mark pulses by DIMMING on the off beat: the glyph - itself never changes, so the column holds still and the eye reads - a heartbeat rather than a character swapping in and out. */} + {/* The row's sender glyph. A live row's mark pulses by DIMMING on the + off beat: the glyph 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 ?? '')} + {row.gutter?.text ?? ''} {/* A ⎿ item's extra breathing room, on every row of it so a wrapped diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 1eccbbe..ef32d32 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -59,18 +59,14 @@ import { ConnectApp } from './ConnectApp' // the trackpad and select/copy are the terminal's own — which is the reason // for the screen split. Nothing may be pinned above or below it: rows // scrolling past would run straight through any such band. -// * the session picker (ctrl+j, or esc / ↓ out of the chat) takes over the +// * the session picker (esc or ↓ out of the chat) takes over the // ALTERNATE screen: the full session list, with the chat left untouched on // the primary buffer behind it. enter opens a session and returns. -// * the transcript browser (ctrl+r, owned by ConnectApp) is the other -// alternate-screen view: the windowed transcript with folding and ↑/↓ nav, -// which the scrollback view cannot do. // * the new-session composer and the loading placeholder do own their frame, // so they keep the header band and the frame inset. // -// Focus is modal and esc steps outward: inside the chat esc closes the browser, -// then transcript navigation, then opens the picker. Exactly one useInput -// handler is active at a time. +// Focus is modal: esc in the chat opens the picker, esc in the picker goes back. +// Exactly one useInput handler is active at a time. // // Liveness: ONE WebSocket — the focused session's, owned by its ConnectApp — // plus a 5s REST poll of the session list for the nav. Transcript stores are @@ -130,7 +126,7 @@ export interface SessionsAppProps { initialConfigName?: string initialNotice?: string // Which sessions reach the picker. `hidden` drops it entirely — focus then - // never leaves the chat, so esc, ↓ at the bottom edge and ctrl+j do nothing. + // never leaves the chat, so esc and ↓ at the bottom edge do nothing. // Set under "sessionBar" in the config file. sessionBar: ResolvedSessionBar // Builds the start request for a composer-spawned session (the entry point @@ -255,8 +251,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // Both openings start with the main pane focused: a connect lands you in // the conversation, a bare `agent` lands you in the new-session composer. // 'nav' = the session picker owns the keyboard, which now means it is OPEN: - // the session list is a screen of its own on the alternate buffer (ctrl+j, or - // ↓/esc out of the chat) rather than a band pinned under every frame. + // the session list is a screen of its own on the alternate buffer (↓ or esc + // out of the chat) rather than a band pinned under every frame. // // It moved there because the chat gave up its viewport: the transcript is // printed into the terminal's real scrollback now, and scrollback is @@ -609,11 +605,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { shownChats.current.add(mainPane.sessionId) main = ( // The chat, owning the terminal rather than sitting in a pane: no - // width/height box around it and no pane props, which is what puts - // ConnectApp in its scrollback view (settled transcript printed into the - // terminal's own scrollback, native wheel and select/copy, and ctrl+r for - // the full-screen browser). It renders its own meta line again, since - // there is no header band above it to carry one. + // width/height box around it and no pane props, so its settled transcript + // prints into the terminal's own scrollback and the wheel and select/copy + // are the terminal's. It renders its own meta line again, since there is + // no header band above it to carry one. >(Promise.resolve()) diff --git a/src/ui/commands.ts b/src/ui/commands.ts index 13af94d..6c457ae 100644 --- a/src/ui/commands.ts +++ b/src/ui/commands.ts @@ -6,7 +6,7 @@ // forwarded. Silently sending "/stpo" to the agent as prose is the failure mode // that rule exists to prevent. -export type CommandId = 'stop' | 'transcript' | 'sessions' | 'exit' +export type CommandId = 'stop' | 'sessions' | 'exit' export type SlashCommand = { id: CommandId @@ -18,12 +18,11 @@ export type SlashCommand = { detail: string } -// Every command, in menu order: the two that act on the session first, then the -// screens, then the way out. +// Every command, in menu order: the one that acts on the session first, then the +// screen, then the way out. export const SLASH_COMMANDS: readonly SlashCommand[] = [ { id: 'stop', name: 'stop', detail: 'interrupt the agent, keeping the conversation' }, - { id: 'transcript', name: 'transcript', detail: 'browse the full transcript (ctrl+r)' }, - { id: 'sessions', name: 'sessions', detail: 'switch sessions (ctrl+j)' }, + { id: 'sessions', name: 'sessions', detail: 'switch sessions (esc)' }, { id: 'exit', name: 'exit', aliases: ['quit'], detail: 'leave the CLI; the session keeps running' }, ] diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index d3232ed..5503892 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -3,26 +3,22 @@ import { fitLines, hasMarkdown, renderMarkdown, visibleWidth } from '../lib/mark import { SELECTION_GLYPH } from '../lib/sessions' import { theme } from '../lib/theme' -// The transcript as a flat list of SCREEN ROWS — the unit the chat window -// scrolls by. Everything in the window (the startup block, messages, tool -// chatter, in-flight sends, the live activity lines) is flattened to rows -// before it renders, each row exactly one terminal line tall and no wider -// than the pane. +// The transcript as a flat list of SCREEN ROWS. Everything on screen (the +// startup block, messages, tool chatter, in-flight sends, the live activity +// lines) is flattened to rows before it renders, each row exactly one terminal +// line tall and no wider than the pane. // -// Rows, not entries, because a message can be taller than the window: an -// entry-granular viewport can only show an entry whole or not at all, so a -// long message becomes unreadable — it fills the frame, and one scroll notch -// throws all of it away. Row-granular, the window can sit anywhere inside it. +// Rows, not entries, because the live frame is capped in ROWS: an entry-granular +// cap could only keep or drop a whole message, so one long message would either +// overflow the frame or vanish from it. // -// Rows are also EXACT, which is what lets the window pack itself full: text is -// pre-wrapped here at the width it will occupy (fitLines) and the renderer -// truncates instead of wrapping, so a slice of N rows always paints N lines. -// An estimate-based budget has to leave slack for its own rounding errors, and -// that slack shows up as dead space and phantom "… 1 newer" markers. +// Rows are also EXACT: text is pre-wrapped here at the width it will occupy +// (fitLines) and the renderer truncates instead of wrapping, so a slice of N rows +// always paints N lines. That is what lets the live frame be budgeted precisely — +// an over-tall frame scrolls ink's render region and smears stale rows. // The 2-column gutter a transcript line reserves for its sender glyph (▶/●/⎿), -// so the selection marker can replace the glyph in place without the text -// shifting, and a wrapped line's continuation aligns under its first. +// so a wrapped line's continuation aligns under its first. export const GUTTER_COLS = 2 // Horizontal pad on EVERY transcript row — the text sits one cell off the @@ -30,8 +26,9 @@ export const GUTTER_COLS = 2 // share one left edge instead of stepping in and out by a column. export const MESSAGE_PAD = 1 -// Long bodies collapse to this many lines until ctrl+r (or → on the line) -// expands them. +// Long bodies collapse to this many lines. Nothing un-collapses them: the full +// text printed into the terminal's scrollback on its way past, which is where it +// is read. const COLLAPSE_LINES = 6 // A run of same-styled text inside a row. Rows carry spans rather than one @@ -72,23 +69,14 @@ export function spanColor( 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 the scroll anchor holds onto so streamed appends and re-wraps can't - // slide the window. + // The entry (a transcript item's key, or 'sandbox') this row belongs to: the + // unit the scrollback flush is decided by, so an entry is printed once, whole. 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 - // Blank columns before the gutter: an opened fold's children sit one level - // in, so they read as the fold's children. + // Blank columns before the gutter: a nested tool line sits one level in, so it + // reads as a branch off the message above. 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 @@ -100,10 +88,7 @@ export type TranscriptRow = { right?: RowSpan // A blank separator row: the gap between blocks. 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 - // knows and the row builder deliberately doesn't — otherwise every arrow - // keypress would rebuild the whole transcript. + // The "+N lines" count on the marker row under a clamped body. clampedLines?: number // A ticking duration, appended to the row's text at render time. Kept out of // the row's spans so the once-a-second tick repaints one line instead of @@ -151,20 +136,13 @@ 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 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. export function itemRows( item: TranscriptItem, cols: number, - opts: { indent?: number; clamp: boolean; nested?: boolean; attach?: boolean }, + opts: { indent?: number; nested?: boolean; attach?: boolean } = {}, ): TranscriptRow[] { const indent = opts.indent ?? 0 // Nested lines are marked by their INDENT, so each keeps the glyph that says @@ -177,10 +155,9 @@ export function itemRows( 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 clamped = isCollapsible(shown) + ? clampLines(shown.text, COLLAPSE_LINES) + : { body: shown.text, more: 0 } const { gutterColor, textColor, dim, bold } = styleFor(shown) const rows: TranscriptRow[] = [] @@ -229,88 +206,34 @@ export function itemRows( // glyph, attached with no blank row between. Prose, user messages and notices // keep their own gutter mark and their spacing. // -// 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 something the AGENT said (isAgentSpeech: prose or ✻ thinking): -// a ⎿ branch under your own message 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 → -// on the level above: -// -// ● the message a stop; ↑/↓ walk these -// ⎿ Ran 3 tool calls part of the message's block (navKey → the message) -// -// 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. +// INDENT is decided by what came last: a run only indents under something the +// AGENT said (isAgentSpeech: prose or ✻ thinking), because a ⎿ branch under your +// own message would read as work YOU did — so a turn-opening run stays flat and +// separated by its own blank row. 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[], - // `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[] { +export function layOutItems(items: readonly TranscriptItem[]): PlacedItem[] { const out: PlacedItem[] = [] - // 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 + // Whether the last message was the agent's, which is what decides the visual // 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 + let nestUnder = false for (const item of items) { if (!isToolActivity(item)) { out.push({ item, indent: 0, nested: false, attach: false }) - parent = item.key - parentIsAgent = isAgentSpeech(item) - call = null + nestUnder = isAgentSpeech(item) continue } - const nested = parent !== null && parentIsAgent - 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 out.push({ item, - indent: nested ? NEST_INDENT : 0, - nested, + indent: nestUnder ? NEST_INDENT : 0, + nested: nestUnder, // 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, + attach: nestUnder, }) } return out @@ -408,102 +331,19 @@ export function pendingMessageRows( return rows } -// The window of rows on screen, and how many are hidden beyond each edge. -// -// `anchor` is the index of the row pinned to the TOP, or null to follow the -// bottom (the default, so streamed content stays in view). The window is -// always packed FULL: anchored near the end of the list it backs up to fill -// the budget rather than leaving the bottom of the frame empty. -// -// The "… N earlier/newer" markers live inside the budget, so their rows come -// out of the window that needs them — resolved by re-fitting until it stops -// changing (there are at most two, so it settles at once). -// -// GUARANTEE: content rows plus marker rows never exceed `budget`, for any -// input. The whole layout rests on it — one row too many and ink's frame -// outgrows the pane, which scrolls the render region and smears stale rows up -// the terminal. A budget with no room to spare drops a marker rather than -// overflow, which is why showAbove/showBelow are separate from the hidden -// counts. Pure, for tests. -export function rowViewport( - total: number, - budget: number, - anchor: number | null, -): { - start: number - end: number - capacity: number - hiddenAbove: number - hiddenBelow: number - showAbove: boolean - showBelow: boolean -} { - const room = Math.max(1, budget) - if (total === 0) { - return { - start: 0, - end: 0, - capacity: room, - hiddenAbove: 0, - hiddenBelow: 0, - showAbove: false, - showBelow: false, - } - } - // One content row always shows, so the markers can claim what the budget has - // beyond it and no more. - const markerRoom = Math.max(0, room - 1) - let markers = 0 - let start = 0 - let end = 0 - let capacity = room - for (let pass = 0; pass < 3; pass++) { - capacity = room - markers - if (anchor === null) { - end = total - start = Math.max(0, end - capacity) - } else { - start = Math.max(0, Math.min(anchor, total - 1)) - end = Math.min(total, start + capacity) - // Packed full against the bottom edge: back up rather than leave the - // last rows of the frame blank. - if (end === total) start = Math.max(0, total - capacity) - } - const want = (start > 0 ? 1 : 0) + (end < total ? 1 : 0) - const next = Math.min(want, markerRoom) - if (next === markers) break - markers = next - } - // With room for only one marker, "earlier" wins: that there is history above - // is the more useful fact, and following the bottom is the common case. - const showAbove = start > 0 && markers >= 1 - const showBelow = end < total && markers >= (start > 0 ? 2 : 1) - return { - start, - end, - capacity, - hiddenAbove: start, - hiddenBelow: total - end, - showAbove, - showBelow, - } -} - // ---------------------------------------------------------------- scrollback // -// In scrollback mode the settled part of the transcript is printed ONCE, into -// the terminal's own scrollback, and never repainted (ink's ). That -// buys native wheel/trackpad scrolling and native select/copy, and it costs -// mutability: a row that has been flushed can't re-wrap, re-fold, or take a -// selection marker. So the flush point has to be a row that CANNOT change -// again, and these two helpers are what decide it. - -// The items whose rows are final. An item's rows can still change for two -// reasons: a collapsed fold ("Ran 2 tool calls") grows as more tool activity -// lands under the same message, and the message that owns the open run is the -// one → can still unfold. So while a turn is in flight the LAST message and -// everything after it stay live, and everything before it is final. With no -// turn in flight nothing can grow, so all of it is final. +// The settled part of the transcript is printed ONCE, into the terminal's own +// scrollback, and never repainted (ink's ). That buys native +// wheel/trackpad scrolling and native select/copy, and it costs mutability: a +// row that has been flushed can't re-wrap or re-fold. So the flush point has to +// be a row that CANNOT change again, and these two helpers are what decide it. + +// The items whose rows are final. An item's rows can still change while a turn +// is in flight: a collapsed fold ("Ran 2 tool calls") grows as more tool activity +// lands under the same message. So the LAST message and everything after it stay +// live, and everything before it is final. With no turn in flight nothing can +// grow, so all of it is final. // // Note this is per-MESSAGE, not per-turn: the moment the agent starts a new // message the previous one and its whole tool run flush together, which is what @@ -537,102 +377,6 @@ export function settledRowCount( return n } -// The scroll position as (entry, row within that entry) rather than a flat row -// index, so appends, re-wraps and expansions can't slide the window: the row -// you parked on stays the row on screen. -export type ScrollAnchor = { entryKey: string; rowOffset: number } - -// The flat row index an anchor points at, or null when its entry is gone (the -// caller falls back to following the bottom). -export function anchorIndex(rows: readonly TranscriptRow[], anchor: ScrollAnchor): number | null { - const first = rows.findIndex((r) => r.entryKey === anchor.entryKey) - if (first < 0) return null - return Math.max(0, Math.min(first + anchor.rowOffset, rows.length - 1)) -} - -// The anchor for a flat row index. -export function anchorAt(rows: readonly TranscriptRow[], index: number): ScrollAnchor | null { - const row = rows[index] - if (!row) return null - const first = rows.findIndex((r) => r.entryKey === row.entryKey) - return { entryKey: row.entryKey, rowOffset: Math.max(0, index - first) } -} - -// 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, -): { first: number; last: number } | null { - let first = -1 - let last = -1 - for (const [i, row] of rows.entries()) { - if (navKeyOf(row) !== entryKey) continue - if (first < 0 && row.spacer) continue - if (first < 0) first = i - last = i - } - return first < 0 ? null : { first, last } -} - -// Where the window must sit for `entryKey` to be readable, given where it sits -// now and which way the highlight is travelling (`dir`: 1 for ↓, -1 for ↑) — -// the ↑/↓ snap. -// -// An entry already fully in frame doesn't move the window at all. One that -// FITS but sits off-frame comes in from the side it is on: from above to the -// top of the window, from below to the bottom edge. One TOO TALL to fit lands -// on the edge you are heading towards, so the walk keeps its direction of -// travel: ↓ lands on its FIRST line (you read a long message from its -// beginning) and ↑ on its LAST (you back into the end of it). From there -// ↑/↓ scroll THROUGH the rest of it a row at a time — see revealMore — so the -// two together read the entry continuously in whichever direction you started. -// -// Returns the flat row index to pin to the top, or null to leave the window -// alone. Pure, for tests. -export function snapToEntry( - rows: readonly TranscriptRow[], - entryKey: string, - view: { start: number; end: number }, - capacity: number, - dir: 1 | -1 = 1, -): number | null { - const range = entryRange(rows, entryKey) - if (!range) return null - // Already readable whole: don't jostle the window. - if (range.first >= view.start && range.last < view.end) return null - const bottomAligned = Math.max(0, range.last - capacity + 1) - const height = range.last - range.first + 1 - if (height >= capacity) return dir < 0 ? bottomAligned : range.first - return range.first < view.start ? range.first : bottomAligned -} - -// snapToEntry's row index as the scroll move to apply: null to leave the window -// where it is, or the anchor to park on — itself null when the snap lands on the -// last screenful, which means following the bottom again so streamed content -// keeps arriving in view. -// -// That bottom-follow is keyed on WHERE THE SNAP LANDS, not on the entry being -// the newest one: an entry taller than the window is snapped to an interior row -// (its first line, when walking down into it), and pinning to the bottom there -// would throw the snap away and show the entry's end instead of its beginning. -// Pure, for tests. -export function snapAnchorForEntry( - rows: readonly TranscriptRow[], - entryKey: string, - view: { start: number; end: number }, - capacity: number, - dir: 1 | -1 = 1, -): { anchor: ScrollAnchor | null } | null { - const target = snapToEntry(rows, entryKey, view, capacity, dir) - if (target === null) return null - if (target >= rows.length - capacity) return { anchor: null } - return { anchor: anchorAt(rows, target) } -} - // Agent and user prose rendered as markdown (bold, headings, bullets, tables, // fenced code), pre-wrapped to the column it will occupy. Only these two kinds // go through it: tool lines and system notices are the SDK's own formatting, @@ -647,7 +391,7 @@ export function withRenderedMarkdown(item: TranscriptItem, width: number): Trans // 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 { +function isCollapsible(item: TranscriptItem): boolean { return ( (item.kind === 'tool_result' || item.kind === 'user') && item.text.split('\n').length > COLLAPSE_LINES diff --git a/test/commands.test.ts b/test/commands.test.ts index 189cbc9..23710e6 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -28,7 +28,7 @@ describe('matchCommands', () => { it('narrows by prefix', () => { expect(names('/st')).toEqual(['stop']) expect(names('/s')).toEqual(['stop', 'sessions']) - expect(names('/tr')).toEqual(['transcript']) + expect(names('/e')).toEqual(['exit']) }) it('matches aliases but shows the canonical name', () => { diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 3ac1355..6cef0a6 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -6,7 +6,6 @@ import { deliveredUnechoedSends, deriveSandboxState, lastLines, - foldRun, hookPhrase, humanDuration, reshapeTranscript, @@ -14,17 +13,11 @@ import { sessionLogText, } from '../src/ui/ConnectApp' import { - anchorAt, - anchorIndex, - entryRange, gutterFor, itemRows, layOutItems, - rowViewport, settledItemKeys, settledRowCount, - snapAnchorForEntry, - snapToEntry, spanColor, withRenderedMarkdown, type TranscriptRow, @@ -592,15 +585,7 @@ describe('layOutItems', () => { // 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 - // make the run unreachable without ctrl+r. - const out = layOutItems([user('u'), fold('t1')]) - expect(out[1].navKey).toBe('u') + expect(out[1]).toMatchObject({ indent: 2, nested: true, attach: true }) }) it('leaves a run with no parent above it flat', () => { @@ -609,53 +594,6 @@ describe('layOutItems', () => { expect(out.map((p) => p.nested)).toEqual([false, false, false]) }) - 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, 2]) - }) - - 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'), call('t1'), res('r1')], { - openedKeys: new Set(['a']), - }) - // 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'), call('t1'), res('r1')], { - openedKeys: new Set(['a']), - }) - expect(out[1].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]) @@ -809,82 +747,9 @@ describe('settledRowCount', () => { }) }) -describe('rowViewport', () => { - it('follows the bottom by default, filling the window', () => { - expect(rowViewport(10, 4, null)).toMatchObject({ start: 7, end: 10, hiddenBelow: 0 }) - // The "N above" marker costs a row, so only 3 content rows fit in 4. - expect(rowViewport(10, 4, null).hiddenAbove).toBe(7) - }) - - it('shows everything when it fits, with no markers', () => { - expect(rowViewport(3, 10, null)).toMatchObject({ - start: 0, - end: 3, - hiddenAbove: 0, - hiddenBelow: 0, - }) - }) - - it('anchors a row to the top when scrolled', () => { - // Rows 4..6 with both markers eating a row each out of the 5-row budget. - expect(rowViewport(20, 5, 4)).toMatchObject({ start: 4, end: 7 }) - }) - - it('packs the window full at the bottom edge instead of leaving dead rows', () => { - // Anchoring row 18 of 20 in a 6-row budget would show 2 rows and waste 4; - // it backs up so the frame is full. - const view = rowViewport(20, 6, 18) - expect(view.end).toBe(20) - expect(view.end - view.start).toBe(5) // one row goes to the "above" marker - expect(view.hiddenBelow).toBe(0) - }) - - it('handles an empty list', () => { - expect(rowViewport(0, 5, null)).toMatchObject({ start: 0, end: 0 }) - }) - - // The layout's load-bearing invariant: one row too many and ink's frame - // outgrows the pane, which scrolls the render region and smears stale rows. - it('never renders more rows than the budget, for any input', () => { - const bad: string[] = [] - for (let total = 0; total <= 40; total++) { - for (let budget = 1; budget <= 20; budget++) { - const anchors: (number | null)[] = [null] - for (let a = -2; a <= total + 2; a++) anchors.push(a) - for (const anchor of anchors) { - const v = rowViewport(total, budget, anchor) - const rendered = - v.end - v.start + (v.showAbove ? 1 : 0) + (v.showBelow ? 1 : 0) - if (total > 0 && rendered > budget) { - bad.push(`total=${total} budget=${budget} anchor=${anchor}: ${rendered} rows`) - } - if (v.hiddenAbove !== v.start || v.hiddenBelow !== total - v.end) { - bad.push(`counts disagree with slice: ${JSON.stringify(v)}`) - } - if (v.start > v.end) bad.push(`inverted slice: ${JSON.stringify(v)}`) - } - } - } - expect(bad.slice(0, 10)).toEqual([]) - }) - - it('can always reach the very top and the very bottom', () => { - for (let total = 1; total <= 30; total++) { - for (let budget = 1; budget <= 12; budget++) { - // Anchored at row 0 the window starts at the top, with nothing hidden - // above it; following the bottom, nothing is hidden below. - expect(rowViewport(total, budget, 0).hiddenAbove).toBe(0) - expect(rowViewport(total, budget, null).hiddenBelow).toBe(0) - } - } - }) -}) - describe('itemRows', () => { it('spends no rows on vertical padding, so exchanges pack tightly', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40, { - clamp: false, - }) + const rows = itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40) expect(rows).toHaveLength(1) }) @@ -893,27 +758,23 @@ describe('itemRows', () => { // sender is carried by the gutter glyph alone. it('marks the sender with a gutter glyph and paints no background', () => { const gutterOf = (kind: TranscriptItem['kind'], nested = false): string | undefined => - itemRows({ key: 'a', kind, text: 'x' } as TranscriptItem, 40, { clamp: false, nested })[0] + itemRows({ key: 'a', kind, text: 'x' } as TranscriptItem, 40, { nested })[0] .gutter?.text expect(gutterOf('user')).toBe('▶') expect(gutterOf('assistant')).toBe('●') expect(gutterOf('notice')).toBe('✦') - for (const row of itemRows({ key: 'a', kind: 'user', text: 'x' }, 40, { clamp: false })) { + for (const row of itemRows({ key: 'a', kind: 'user', text: 'x' }, 40)) { expect(row).not.toHaveProperty('panel') } }) it('emits one row per line of a multi-line body', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'one\ntwo\nthree' }, 40, { - clamp: false, - }) + const rows = itemRows({ key: 'a', kind: 'assistant', text: 'one\ntwo\nthree' }, 40) expect(rows.map((r) => r.spans[0].text)).toEqual(['one', 'two', 'three']) }) it('never emits a row wider than the pane', () => { - const rows = itemRows({ key: 'a', kind: 'assistant', text: 'x'.repeat(200) }, 40, { - clamp: false, - }) + const rows = itemRows({ key: 'a', kind: 'assistant', text: 'x'.repeat(200) }, 40) for (const row of rows) { const width = row.spans.reduce((n, s) => n + stripAnsi(s.text).length, 0) expect(width).toBeLessThanOrEqual(40) @@ -921,9 +782,7 @@ describe('itemRows', () => { }) it('puts the gutter glyph on the first content row only', () => { - const rows = itemRows({ key: 'a', kind: 'user', text: 'one\ntwo' }, 40, { - clamp: false, - }) + const rows = itemRows({ key: 'a', kind: 'user', text: 'one\ntwo' }, 40) const withGutter = rows.filter((r) => r.gutter) expect(withGutter).toHaveLength(1) expect(withGutter[0].gutter?.text).toBe('▶') @@ -932,165 +791,44 @@ describe('itemRows', () => { 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 }) + const rows = itemRows(foldItem, 40, { 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('✦') + expect(itemRows(notice, 40, { 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, - }) + const rows = itemRows({ key: 'r', kind: 'tool_result', text: 'a\nb', gutter: '⎿' }, 40) 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) + expect(itemRows({ key: 'a', kind: 'assistant', text: 'hi' }, 40)[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, - }) + const rows = itemRows({ key: 'a', kind: 'notice', text: 'note', spaceBefore: true }, 40) expect(rows[0].spacer).toBe(true) }) it('clamps a long body, marking how many lines are hidden', () => { const text = Array.from({ length: 10 }, (_, i) => `l${i}`).join('\n') - const item = { key: 'r', kind: 'tool_result' as const, text } - const collapsed = itemRows(item, 40, { clamp: true }) - expect(collapsed).toHaveLength(7) // 6 lines + the "+N lines" marker - // The row carries the COUNT; the renderer writes the hint, because which - // key opens it (→ vs ctrl+r) depends on the highlight — a render concern. - expect(collapsed[6].clampedLines).toBe(4) - expect(itemRows(item, 40, { clamp: false })).toHaveLength(10) + const rows = itemRows({ key: 'r', kind: 'tool_result' as const, text }, 40) + expect(rows).toHaveLength(7) // 6 lines + the "+N lines" marker + // The row carries the COUNT; the renderer writes the text around it. + expect(rows[6].clampedLines).toBe(4) }) it('measures visible columns, not escape sequences', () => { // A markdown-rendered line carries ANSI codes that occupy no columns. // Counting them would over-count rows and desync the window. const styled = `\u001b[1m${'x'.repeat(30)}\u001b[22m` - const rows = itemRows({ key: 'a', kind: 'assistant', text: styled }, 40, { - clamp: false, - }) + const rows = itemRows({ key: 'a', kind: 'assistant', text: styled }, 40) expect(rows.filter((r) => r.spans.length > 0)).toHaveLength(1) }) }) -describe('row anchors', () => { - const rows: TranscriptRow[] = [ - { id: '0', entryKey: 'a', spans: [] }, - { id: '1', entryKey: 'b', spans: [], spacer: true }, - { id: '2', entryKey: 'b', spans: [] }, - { id: '3', entryKey: 'b', spans: [] }, - { id: '4', entryKey: 'c', spans: [] }, - ] - - it('round-trips a row index through an entry-relative anchor', () => { - const anchor = anchorAt(rows, 3) - expect(anchor).toEqual({ entryKey: 'b', rowOffset: 2 }) - expect(anchorIndex(rows, anchor!)).toBe(3) - }) - - it('survives rows being prepended above the anchor', () => { - const anchor = anchorAt(rows, 3)! - const grown = [{ id: 'x', entryKey: 'z', spans: [] }, ...rows] - // Same content row, new flat index — this is what keeps a streamed - // append from sliding the window. - expect(anchorIndex(grown, anchor)).toBe(4) - }) - - it('reports a vanished entry so the caller can follow the bottom', () => { - expect(anchorIndex(rows, { entryKey: 'gone', rowOffset: 0 })).toBeNull() - }) - - it('skips an entry leading spacer, which is a separator not content', () => { - expect(entryRange(rows, 'b')).toEqual({ first: 2, last: 3 }) - }) -}) - -describe('snapToEntry', () => { - // Entry 'b' is 10 rows tall, taller than a 4-row window. - const rows: TranscriptRow[] = [ - { id: 'a', entryKey: 'a', spans: [] }, - ...Array.from({ length: 10 }, (_, i) => ({ id: `b${i}`, entryKey: 'b', spans: [] })), - { id: 'c', entryKey: 'c', spans: [] }, - ] - - it('brings an entry entered from above to the top of the window', () => { - expect(snapToEntry(rows, 'a', { start: 5, end: 9 }, 4)).toBe(0) - }) - - it('shows a too-tall entry from its FIRST line, so it reads from the top', () => { - expect(snapToEntry(rows, 'b', { start: 0, end: 4 }, 4)).toBe(1) - }) - - it('aligns an entry arriving from below to the bottom edge', () => { - // 'c' is one row at index 11, entering a 4-row window that ends at 8. - expect(snapToEntry(rows, 'c', { start: 4, end: 8 }, 4)).toBe(8) - }) - - it('leaves the window alone for an entry already fully in frame', () => { - const short: TranscriptRow[] = [ - { id: 'a', entryKey: 'a', spans: [] }, - { id: 'b', entryKey: 'b', spans: [] }, - { id: 'c', entryKey: 'c', spans: [] }, - ] - expect(snapToEntry(short, 'b', { start: 0, end: 3 }, 3)).toBeNull() - }) - - it('backs ↑ into a too-tall entry at its LAST line, keeping the walk going up', () => { - // 'b' spans rows 1..10. Walking UP out of 'c' lands on 'b's bottom edge - // (rows 7..10 on a 4-row window), not its far-away first line. - expect(snapToEntry(rows, 'b', { start: 8, end: 12 }, 4, -1)).toBe(7) - }) - - it('walks ↓ into a too-tall entry at its FIRST line', () => { - expect(snapToEntry(rows, 'b', { start: 0, end: 4 }, 4, 1)).toBe(1) - }) -}) - -describe('snapAnchorForEntry', () => { - // A short entry, then one 10 rows tall — taller than the 4-row window. - const shortThenLong: TranscriptRow[] = [ - { id: 'a', entryKey: 'a', spans: [] }, - ...Array.from({ length: 10 }, (_, i) => ({ id: `b${i}`, entryKey: 'b', spans: [] })), - ] - - it('parks on the TOP of a trailing too-tall entry walked into from above', () => { - // ↓ off the short entry onto the long last one: its first line, NOT the - // bottom-follow that being the newest entry used to force. - expect(snapAnchorForEntry(shortThenLong, 'b', { start: 0, end: 4 }, 4, 1)).toEqual({ - anchor: { entryKey: 'b', rowOffset: 0 }, - }) - }) - - it('follows the bottom when the snap really does land on the last screenful', () => { - // The same trailing entry, but short enough to fit: bottom-aligning it IS - // the bottom of the log, so keep streaming content in view. - const shortTail: TranscriptRow[] = [ - ...Array.from({ length: 6 }, (_, i) => ({ id: `a${i}`, entryKey: 'a', spans: [] })), - { id: 'b0', entryKey: 'b', spans: [] }, - ] - expect(snapAnchorForEntry(shortTail, 'b', { start: 0, end: 4 }, 4, 1)).toEqual({ anchor: null }) - }) - - it('parks on the BOTTOM of a too-tall entry walked into from below', () => { - // (short) (long) (short) with the highlight on the trailing short one: ↑ - // lands on the long entry's END — rows 7..10 of an 11-row block. - const withTail: TranscriptRow[] = [...shortThenLong, { id: 'c', entryKey: 'c', spans: [] }] - expect(snapAnchorForEntry(withTail, 'b', { start: 8, end: 12 }, 4, -1)).toEqual({ - anchor: { entryKey: 'b', rowOffset: 6 }, - }) - }) - - it('reports no move for an entry already fully in frame', () => { - expect(snapAnchorForEntry(shortThenLong, 'a', { start: 0, end: 4 }, 4, -1)).toBeNull() - }) -}) - describe('withRenderedMarkdown', () => { it('styles assistant and user prose that contains markdown', () => { const item = { key: 'a', kind: 'assistant' as const, text: 'a **bold** word' } @@ -1120,21 +858,6 @@ describe('withRenderedMarkdown', () => { }) }) -describe('foldRun', () => { - const tool = (key: string): TranscriptItem => ({ key, kind: 'tool', text: 'Bash' }) - const result = (key: string): TranscriptItem => ({ key, kind: 'tool_result', text: 'ok' }) - const prose = (key: string): TranscriptItem => ({ key, kind: 'assistant', text: 'hi' }) - - it('returns the consecutive tool run starting at the fold anchor', () => { - const items = [prose('a'), tool('t1'), result('r1'), tool('t2'), result('r2'), prose('b')] - expect(foldRun('grp:t1', items).map((i) => i.key)).toEqual(['t1', 'r1', 't2', 'r2']) - }) - - it('is empty when the anchor is gone from the unfolded list', () => { - expect(foldRun('grp:missing', [prose('a')])).toEqual([]) - }) -}) - describe('spanColor', () => { // Nothing on a row may fall through to the terminal's own foreground: under a // light theme that is the same near-black as the canvas painted beneath it. @@ -1177,7 +900,7 @@ describe('itemRows colours', () => { it('resolves each kind to its brand colour, never to the terminal default', () => { for (const [kind, expected] of bodyColor) { - const rows = itemRows({ key: `k:${kind}`, kind, text: 'some text' }, 60, { clamp: false }) + const rows = itemRows({ key: `k:${kind}`, kind, text: 'some text' }, 60) const body = rows.filter((r) => r.spans.some((s) => s.text.includes('some text'))) expect(body.length, kind).toBeGreaterThan(0) for (const row of body) { diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts index 7a4da19..7711007 100644 --- a/test/connect-render.test.ts +++ b/test/connect-render.test.ts @@ -260,24 +260,24 @@ describe('ConnectApp — the scrollback view', () => { await settle() let frame = stripAnsi(output().slice(before)) expect(frame).toContain('/stop') - expect(frame).toContain('/transcript') + expect(frame).toContain('/sessions') expect(frame).toContain('interrupt the agent') // Typing narrows it to one. Measured from HERE, not from the start: the byte // stream keeps every earlier frame, so a cumulative slice would still hold // the full list printed a moment ago. const beforeNarrow = output().length - stdin.write('tr') + stdin.write('se') await settle() frame = stripAnsi(output().slice(beforeNarrow)) - expect(frame).toContain('/transcript') + expect(frame).toContain('/sessions') expect(frame).not.toContain('/stop') // Tab completes the highlighted command into the input. const beforeTab = output().length stdin.write('\t') await settle() - expect(stripAnsi(output().slice(beforeTab))).toContain('/transcript') + expect(stripAnsi(output().slice(beforeTab))).toContain('/sessions') app.unmount() })