diff --git a/README.md b/README.md index 7f1643b..5ee205c 100644 --- a/README.md +++ b/README.md @@ -162,13 +162,12 @@ 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 at the bottom of the interactive UI: +list, which the interactive UI opens as a full screen with `ctrl+j`: ```json { "sessionBar": { "hidden": false, - "rows": 5, "days": 7, "repo": "cwd", "statuses": "all", @@ -177,9 +176,8 @@ list at the bottom of the interactive UI: } ``` -`hidden` drops the bar entirely and gives its rows to the chat window. `rows` is -how many sessions it lists (a short terminal shows fewer). `days` hides sessions -that have not moved in that long; `0` means no age cutoff. `repo` is `"cwd"` to +`hidden` drops the list entirely, so the chat never hands focus to it. `days` +hides sessions that have not moved in that long; `0` means no age cutoff. `repo` is `"cwd"` to list only sessions on the repository your shell is in, or `"any"` for all of them. `statuses` is `"unfinished"` to leave out the sessions that finished, errored, or were stopped, or `"all"` to keep them. `sources` lists only sessions diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 9a23913..aaab961 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -139,14 +139,11 @@ export async function runConnect( // Written by the app when it exits because the conversation closed (terminal; // nothing left to reconnect to), so the detach sign-off below stays honest. const exitState = { closed: false } - // Start the app at the top of a fresh window: newlines scroll whatever is on - // screen (the shell prompt, anything a caller printed) into scrollback, then - // the cursor homes to row 1. Without this the first paint begins mid-screen, - // overflows the window, and the app's opening lines (the sandbox startup - // notes) end up stranded above the fold. - if (process.stdout.isTTY) { - process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H') - } + // No screen-clearing dance here any more: the app prints its settled + // transcript INTO this terminal's scrollback (see ConnectApp's scrollback + // mode), so the conversation grows down the terminal from wherever the shell + // prompt left off, exactly like ordinary command output. Homing the cursor + // would throw away the scrollback the app now relies on. const app = render( React.createElement(ConnectApp, { api: client, @@ -204,9 +201,8 @@ export async function runConnect( if (canSend && !exitState.closed) { // The session keeps running after a detach; hand back the exact command - // that re-opens this conversation. No leading newline: the single row this - // line scrolls is absorbed by the app's top padding (see ConnectApp), so - // the sign-off never scrolls the app's first content line out of the window. + // that re-opens this conversation. It prints under the app's last frame, + // like any other command's parting line. console.log(`resume with: agent session connect ${sessionId}`) } } diff --git a/src/lib/config.ts b/src/lib/config.ts index cbebb76..bd7ce13 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -35,10 +35,8 @@ export interface Host { // How the interactive UI's session bar is scoped. Every field is optional; a // missing one takes the SESSION_BAR_DEFAULTS value below. export interface SessionBarConfig { - // Drop the bar entirely, giving its rows to the chat window. + // Drop the session list entirely: the chat then never hands focus to it. hidden?: boolean - // How many session rows the bar shows (a short terminal shows fewer). - rows?: number // Only sessions that moved in the last N days; 0 means no age cutoff. days?: number // "cwd" lists only sessions on the repository the shell is in, falling back @@ -243,7 +241,6 @@ export const SESSION_BAR_DEFAULTS: Required> & sources: string[] | undefined } = { hidden: false, - rows: 5, days: 7, repo: 'cwd', statuses: 'all', @@ -266,10 +263,6 @@ export function sessionBar(): ResolvedSessionBar { : undefined return { hidden: raw.hidden === true, - rows: - typeof raw.rows === 'number' && isFinite(raw.rows) && raw.rows >= 1 - ? Math.floor(raw.rows) - : SESSION_BAR_DEFAULTS.rows, days: typeof raw.days === 'number' && isFinite(raw.days) && raw.days >= 0 ? Math.floor(raw.days) diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 426bfe1..05c39cd 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -203,7 +203,6 @@ export function mergeSidebarSessions( // still show it. export function sessionBarQuery( bar: { - rows: number days: number repo: 'cwd' | 'any' statuses: 'all' | 'unfinished' @@ -213,9 +212,9 @@ export function sessionBarQuery( ): ListAgentSessionsQuery { const query: ListAgentSessionsQuery = { author_id: context.authorId ?? undefined, - // Enough rows to band and scroll past the visible window, without paying - // for a page nobody scrolls to. - limit: Math.max(SESSION_BAR_FETCH, bar.rows), + // Enough rows to band and scroll past a screenful, without paying for a page + // nobody scrolls to. + limit: SESSION_BAR_FETCH, } if (bar.days > 0) query.days = bar.days if (bar.repo === 'cwd' && context.detectedRepo) query.repo = context.detectedRepo @@ -224,7 +223,7 @@ export function sessionBarQuery( return query } -// How many rows the bar fetches to fill its window from. +// How many rows the session list fetches to fill its screen from. export const SESSION_BAR_FETCH = 50 // Attention transitions: a session that WAS in flight and now waits for a diff --git a/src/lib/theme.ts b/src/lib/theme.ts index eefbcf9..3e358d1 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -10,91 +10,47 @@ import chalk from 'chalk' // only ever renders the dark palette — there is no light variant to switch to. // // 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 emphasis is carried by +// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores 1.79:1 +// on a dark surface — 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 -// theme is light: +// EXACTLY ONE SURFACE IS PAINTED: the composer's (`inputSurface` below). The CLI +// used to paint a canvas behind everything and lift panels onto it, which worked +// while the app owned every cell of a fixed frame. It no longer does — the +// transcript is printed into the terminal's own scrollback, where a row is never +// repainted, so a fill there outlives the frame that drew it and stale bands +// survive a resize or a shorter frame with nothing able to clean them up. // -// 1. Every glyph takes a color from this file. Ink leaves a `` with no -// `color` prop on the terminal's DEFAULT foreground, which under a light -// theme is near-black — the same near-black we just painted the canvas -// with, so the text vanishes. `dimColor` on its own is that bug plus an -// \x1b[2m: secondary copy takes `muted`, never a bare `dimColor`. (dim is -// fine ON TOP of an explicit color, where it only shades a known hue.) -// 2. Surfaces reach ink already quantized for the terminal's color depth — -// see `surfaceFor`, which is why the three surface entries below are -// computed rather than literal. - -// The surfaces as authored. Call sites never read these: they take the -// `theme.*` entries, which are these run through `surfaceFor`. -const BRAND_SURFACES = { - canvas: '#1c1b1a', - panel: '#262523', - panelActive: '#343330', -} as const - -// A surface hex ink can paint at `level` without losing the step between one -// surface and the next. +// The composer is the exception because it is the one region that is ALWAYS +// repainted and NEVER flushed: it lives in the live frame for the whole session, +// so its fill is redrawn on every frame and disappears with the app. Transcript +// rows, the header, and list highlights are all either printed or sized around +// printed rows, so they carry no fill. // -// chalk resolves a hex onto the 256-color palette two different ways: to the -// 24-rung GREYSCALE RAMP (indexes 232-255, ~10 units apart) when r, g and b -// are equal, and otherwise to the 6x6x6 COLOR CUBE, whose darkest step above -// black is rgb(95,95,95). The brand surfaces are WARM greys — their channels -// differ by a point or two — so on a terminal that does 256 colors but not -// truecolor (Terminal.app, tmux without RGB, mosh, plain conhost) all three -// land on cube index 59 simultaneously: the near-black canvas paints as a mid -// grey slab, and the panel and active steps disappear along with every "you -// are here" highlight that was carried by them. -// -// Averaging the channels is invisible at this brightness (a warm near-black -// and a neutral near-black are the same wall of dark) and puts each surface -// back on its own rung: 234, 235, 236. Truecolor terminals get the authored -// warmth untouched; a 16-color terminal renders both spellings as its palette -// black, so the substitution costs nothing there either. -export function surfaceFor(hex: string, level: number): string { - if (level >= 3) return hex - const value = hex.replace('#', '') - const channels = [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16)) - if (channels.some(Number.isNaN)) return hex - const mean = Math.round((channels[0] + channels[1] + channels[2]) / 3) - return `#${mean.toString(16).padStart(2, '0').repeat(3)}` -} - -// `chalk.level` is read once, at import: ink colorizes through this very chalk -// instance (it is a hoisted single copy), so what it can render is what we -// quantize for. -const COLOR_LEVEL: number = chalk.level +// One rule survives from the canvas days, and it still matters: every glyph takes +// a colour from this file. Ink leaves a `` with no `color` prop on the +// terminal's DEFAULT foreground, so a hardcoded assumption either way breaks one +// theme; `dimColor` on its own is that bug plus an \x1b[2m, so secondary copy +// takes `muted`, never a bare `dimColor`. (dim is fine ON TOP of an explicit +// colour, where it only shades a known hue.) export const theme = { - // The app canvas and the lifted panel an input sits on. ~1.1:1 apart: barely - // a lift, which is the point — a panel should separate, not stripe. - canvas: surfaceFor(BRAND_SURFACES.canvas, COLOR_LEVEL), - panel: surfaceFor(BRAND_SURFACES.panel, COLOR_LEVEL), - // One step lighter than `panel`: the brand border hairline, doing duty as - // the "you are here" surface (highlighted message, focused composer, - // selected nav row). Selection is a brightness step between surfaces — - // never the full inverse flash, which reads bone-white and far too loud. - panelActive: surfaceFor(BRAND_SURFACES.panelActive, COLOR_LEVEL), - // Type. `foreground` is body copy and doubles as the accent (see above); // `muted` is every secondary string (meta, hints, timestamps) — and, since - // rule 1 above rules out a bare `dimColor`, it is also how a quiet line - // reads quiet. 7.4:1 on the canvas, so quiet still means legible. + // the rule above rules out a bare `dimColor`, it is also how a quiet line + // reads quiet. 7.4:1 on the brand charcoal, so quiet still means legible. 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. + // The ▶ cursor, and nothing else — which, with no highlight bar to fall back + // on, is now the ONLY thing that says "you are here". Bone-on-stone was too + // quiet a step to find at a glance, 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. cursor: '#5fd3e0', - // Status. Tuned for the charcoal canvas, not the light one. + // Status. success: '#4ebc7b', error: '#e5544b', // In-flight. brand/tokens.json has no dedicated "working" color; this is @@ -108,12 +64,12 @@ export const theme = { syntaxString: '#c8c6bc', } -// The elevated surface an input area sits on. Named separately from -// `theme.panel` because call sites mean "this is an input", not "this is -// #262523" — the composers in ConnectApp/SessionsApp both use it. -export const SURFACE_ELEVATED = theme.panel - -// The elevated surface, active: the focused composer, the highlighted -// transcript message, the selected nav row. One brightness step above -// SURFACE_ELEVATED — enough to read "you are here" without the inverse flash. -export const SURFACE_ACTIVE = theme.panelActive +// The composer's fill — the app's ONE painted surface (see the note above for why +// it is the only one that can be). The brand panel step, neutralized: chalk sends +// any hex whose channels differ to the 6x6x6 colour cube, whose darkest step +// above black is rgb(95,95,95), so the authored warm #262523 paints as a MID GREY +// slab on a terminal that does 256 colours but not truecolor (Terminal.app, tmux +// without RGB, mosh, conhost). Equal channels route to the greyscale ramp +// instead, where a near-black stays near-black. Truecolor terminals lose only the +// warmth, which is invisible at this brightness. +export const inputSurface = chalk.level >= 3 ? '#262523' : '#252525' diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index e9a6d4a..f4dc8f3 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -6,7 +6,7 @@ import React, { useState, useSyncExternalStore, } from 'react' -import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink' +import { Box, Static, Text, useApp, useInput, useStdin, useStdout } from 'ink' import { streamSession, sessionStatusWord, @@ -34,9 +34,17 @@ import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' -import { fitLines } from '../lib/markdown' +import { fitLines, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' -import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' +import { inputSurface, theme } from '../lib/theme' +import { useAltScreen } from './altScreen' +import { + completedText, + isCommandInput, + matchCommands, + resolveCommand, + type SlashCommand, +} from './commands' import { VERSION } from '../lib/constants' import { activityRows, @@ -53,9 +61,10 @@ import { LIVE_GLYPH, MESSAGE_PAD, navKeyOf, - padPanelBlocks, pendingMessageRows, rowViewport, + settledItemKeys, + settledRowCount, snapAnchorForEntry, spacerRow, spanColor, @@ -71,6 +80,22 @@ 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: +// +// * 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. +// // Data flow: ONE SessionTranscriptStore (pre-seeded by the caller with the // stored records + session, so the first paint is instant) is fed by the // SDK's streamSession — records arrive PUSHED as records_append frames, the @@ -110,28 +135,21 @@ export interface ConnectAppProps { // conversation closed, so the caller skips the "detached — still running" // sign-off (the session is not still running). exitState?: { closed: boolean } - // ---- pane hosting (the multi-session UI) ---- - // When set, the app renders inside a fixed-size pane instead of sizing to - // the terminal: wrap math and the viewport budget use these instead of - // stdout's rows/columns. - paneWidth?: number - paneHeight?: number - // Blank rows above the first line, at MOST: the padding is the layout's - // give, so a pane too short for it gets fewer (or none). The solo app keeps - // TOP_PAD's slack against terminal row-accounting quirks; a pane host owns - // its own edges. - topPad?: number - // Whether this pane owns the keyboard. The host keeps exactly one input - // handler active (sidebar or chat); default true for the solo app. + // ---- hosting (the multi-session UI) ---- + // 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 - // Leave the pane and hand focus to the host's session nav (the bar below - // the composer). Fired by esc with nothing open (no panel, no transcript - // nav) and by ↓ at the bottom edge (the composer's last line, or a - // watch-only follow). Absent in the solo app, where neither leaves. + // 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. onFocusNav?: () => void - // Drop the bottom meta line (status · cost · model · id · version): the - // host renders it in its own header instead. - hideMetaLine?: boolean + // 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 + // scrollback, one under the other, so without a break the second reads as a + // continuation of the first. Absent for the first chat of the process, which + // has nothing above it. + scrollbackBreak?: boolean // Terminal outcomes, reported to the host INSTEAD of exiting the app: // 'closed' = the conversation closed; 'preflight' = the session died // before it became connectable; 'ended' = a watch-only stream finished. @@ -147,12 +165,6 @@ function isWorkingStatus(status: string): boolean { return ['scheduled', 'starting', 'working', 'retrying'].includes(status) } -// Blank rows rendered above the app's first line: one of visual breathing room -// above the ✦ startup header, plus one of sacrificial slack — see the termRows -// comment in ConnectApp for what the slack absorbs. Deliberately thin: every -// row here is a row the conversation doesn't get. -const TOP_PAD = 2 - // Text rows inside the composer panel, before its 1-cell pad above and below. // One row: the input grows as you type past it, and the rows it isn't using // belong to the conversation. @@ -166,6 +178,10 @@ const COMPOSER_PAD_X = 2 // 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' + // Half the period of the live ⏺ pulse: the glyph dims for this long, then // brightens for this long. ~1.4s a cycle — slow enough to read as breathing // rather than flashing, and it lands off the 1s duration tick so the two @@ -191,14 +207,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const { isRawModeSupported } = useStdin() const { stdout } = useStdout() - // Terminal height, tracked across resizes, so the app fills the whole - // window Claude Code-style: composer + meta pinned to the bottom edge (one - // row below is left for the shell cursor), the transcript growing through - // the space between, and TOP_PAD blank rows of padding above the first - // line. The padding is deliberate slack: terminals that consume an extra - // row (observed in practice) and the caller's post-exit sign-off line - // ("resume with: …") each scroll one padding row into scrollback instead - // of the app's first line, so the sandbox startup notes stay visible. + // Terminal size, tracked across resizes. It bounds the LIVE region — the + // unsettled tail, the composer and the meta line — rather than sizing a window + // the whole conversation has to fit in: the settled transcript above has been + // handed to the terminal, which is what makes the wheel work, and it is the + // terminal's business how tall that is now. const [termRows, setTermRows] = useState(stdout?.rows ?? 24) const [termCols, setTermCols] = useState(stdout?.columns ?? 80) useEffect(() => { @@ -213,15 +226,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } }, [stdout]) - // Hosted-in-a-pane vs owning the terminal (the solo `connect`). A pane host - // fixes the size and owns the window edges (no shell-row or sign-off slack), - // so hosted mode drops the solo app's -1 bottom row and defaults topPad to 1. - const hosted = props.paneWidth != null || props.paneHeight != null - const rows = props.paneHeight ?? termRows - const cols = props.paneWidth ?? termCols - const topPad = props.topPad ?? (hosted ? 1 : TOP_PAD) + // The app always owns the terminal now (there is no pane host left), so the + // resting view is SCROLLBACK: the settled transcript is flushed ONCE into the + // terminal's own scrollback () and only the live tail + composer + // repaint. Wheel/trackpad scrolling, select/copy and clickable links are then + // the terminal's own, which is the whole point. + const rows = termRows + const cols = termCols const focused = props.focused ?? true - const bottomSlack = hosted ? 0 : 1 + // A row left for the shell cursor and the caller's sign-off line. + const bottomSlack = 1 // Terminal outcomes: reported to the host when there is one (the app stays // mounted; the host decides what to show), otherwise exit the Ink render. @@ -252,6 +266,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const [elapsed, setElapsed] = useState(0) const [notice, setNotice] = useState(props.initialNotice ?? null) + // The slash-command menu's highlighted index. The menu itself is derived from + // what is typed (see `menu` below) rather than stored — it is open exactly when + // the line starts with `/` and something still matches — so this is the only + // state it needs, and it resets to the top whenever the matches change. + const [menuIndex, setMenuIndex] = useState(0) // 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 @@ -279,13 +298,36 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // 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) - // Whether the terminal's mouse reporting is armed (wheel/trackpad scrolls - // the transcript). Capturing the mouse steals native text selection and - // clickable links, so it starts off — the terminal keeps the mouse for - // clicking links and copy/paste, and ctrl+s arms it for wheel scrolling - // (the terminal's own bypass, shift-drag or option-drag in iTerm2, works - // either way). - const [mouseCapture, setMouseCapture] = useState(false) + // 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 @@ -603,13 +645,35 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const text = raw.trim() setComposer({ text: '', cursor: 0 }) if (!text) return - if (text === '/exit' || text === '/quit') { - exit() - return + // A leading slash claims the line for the CLI. An unknown one is REFUSED, + // not forwarded: a typo'd command sent on as prose is a message you did + // not mean to send, and the agent cannot tell it from one you did. + if (isCommandInput(text)) { + const command = resolveCommand(text) + if (!command) { + setNotice(`✗ no such command: ${text.split(/\s/)[0]} · type / to see them`) + return + } + if (command.id === 'exit') { + 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') + return + } } + // /stop is the one command that talks to the server, so it rides the async + // path below with the sends. + const stopping = resolveCommand(text)?.id === 'stop' void (async () => { try { - if (text === '/stop') { + if (stopping) { const { session: s } = await api.sessions.stop(sessionId) setNotice(null) setChatNotes((prev) => [ @@ -649,30 +713,55 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } })() }, - [api, exit, pump, sessionId], + [api, exit, pump, sessionId, props.onFocusNav], ) - // The composer renders whenever sending is possible. The keyboard handler - // is gated on pane focus (the host keeps exactly one handler active) but - // NOT on sendability: a hosted watch-only chat (closed conversation, - // single-shot session) still navigates, scrolls, and hands focus back with - // esc — its composer-editing keys are ignored below. The solo watch-only - // follow keeps today's no-input behavior. - const composerVisible = canSend && isRawModeSupported - const inputActive = (composerVisible || (hosted && isRawModeSupported)) && focused - - // Mouse reporting (SGR), so wheel/trackpad scroll reaches the app as input - // instead of scrolling the terminal's (empty) scrollback. Capturing the - // mouse steals native text selection, so ctrl+s toggles the capture off - // for normal select/copy (the terminal's shift/option-drag bypass works - // while armed, too). Turned off again on unmount. + // 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 + + // 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 + // while the line starts with `/` and something matches, and typing a character + // that matches nothing closes it (the line is then just prose, and submit will + // say so). + const menu = useMemo( + () => (composerVisible ? matchCommands(composer.text) : []), + [composerVisible, composer.text], + ) + const menuOpen = menu.length > 0 + // Clamped rather than stored-and-corrected: the matches shrink as you type, and + // an index left pointing past the end would highlight nothing. + const menuAt = Math.min(menuIndex, Math.max(0, menu.length - 1)) + // Back to the top whenever the match set changes, so the highlight is on the + // best match for what you have typed rather than wherever it was left. useEffect(() => { - if (!inputActive || !mouseCapture || !stdout) return + setMenuIndex(0) + }, [menu.length]) + const complete = useCallback((command: SlashCommand): void => { + const text = completedText(command) + 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') } - }, [inputActive, mouseCapture, stdout]) + }, [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 @@ -726,20 +815,25 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // 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 || hosted || !hasHost), + isRawModeSupported && focused && (composerVisible || windowed || !hasHost), () => { if (working && canSend) submit('/stop') }, ) // The notice bar doubles as the ctrl+c prompt: armed, it says what a second // press does, so the quit is never a surprise. - const shownNotice = ctrlCArmed ? CTRL_C_QUIT_HINT : notice - const { viewBudget, padRows, composerRows, noticeRows } = useMemo(() => { + // 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 : ctrlCArmed ? CTRL_C_QUIT_HINT : notice + const { viewBudget, padRows, 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 // row means the footer quietly outgrows the space reserved for it. - const fixed = (props.hideMetaLine ? 0 : 1) + 1 /* footer margin */ + const fixed = 1 /* meta line */ + 1 /* footer margin */ // What the wrapping parts share. Each takes what it needs and yields the // rest, in priority order: the chat window always keeps a row, then the // composer, and the notice gives up its extra rows first (it truncates — @@ -751,26 +845,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { ? Math.max(0, Math.min(fitLines(`· ${shownNotice}`, cols).length, free - 2)) : 0 free -= noticeRows - // The composer panel: its interior grows with the input, plus the 1-cell - // pad above and below. No rules to account for — the tint is the frame. In - // a pane too short for all of it the pad goes, then the interior shrinks - // toward a single row. + // The command menu, one row per match, budgeted like everything else: it + // renders INSIDE the frame, so rows it takes are rows the chat yields. In a + // terminal with no room for it the list is cut short rather than pushing the + // composer off the bottom. + const menuRows = Math.max(0, Math.min(menu.length, free - 3)) + free -= menuRows + // The composer: its interior grows with the input, plus the 1-cell pad above + // and below. In a terminal too short for all of it the pad goes, then the + // interior shrinks toward a single row. 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) - // At least one row of chat: the top padding yields first. - const pad = Math.max(0, Math.min(topPad, forContent - 1)) - return { viewBudget: forContent - pad, padRows: pad, composerRows, noticeRows } + // 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, - topPad, + windowed, composerVisible, composer.text, shownNotice, - props.hideMetaLine, + menu.length, ]) // The live tail: the in-progress response and the one activity line under @@ -906,7 +1007,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, panel: true })) + out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true })) } if (liveTail.text) { out.push(...pendingMessageRows('live', liveTail.text, cols, { gutter: '' })) @@ -934,7 +1035,6 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { dim: true, right: 'queued', pulse: true, - panel: true, }), ) } @@ -949,13 +1049,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { dim: true, right: q.state === 'sending' ? 'sending' : q.state === 'queued' ? 'queued' : 'cancelled', pulse: waiting, - panel: true, }), ) } - // Every lifted block gets its blank tinted row above and below, here so a - // message and the tool run attached under it share one pad. - return padPanelBlocks(out) + return out }, [ infraActivity, sandbox, @@ -989,6 +1086,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } 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. @@ -997,6 +1095,63 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { 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 + // live region, repainted each frame. See settledItemKeys for what "final" + // means and why it is decided per message rather than per turn. + // + // The startup block is final once it has settled (it collapses to its bare + // headline 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]) + // 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. + // * 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 + // was flushed is history: it is kept verbatim, and a resize re-wraps only + // the live region below. (Claude Code's scrollback has the same artifact — + // printed output does not reflow.) + // + // So an entry is flushed ONCE, keyed by entryKey, and its rows are kept as they + // were built. Seeded with the session break when this chat replaces another + // one's, so the rule prints above the first row of history, not after it. + const flushedRows = useRef( + 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) { + flushedRows.current = [...flushedRows.current, ...fresh] + for (const row of fresh) flushedEntries.current.add(row.entryKey) + } + } + const staticRows = flushedRows.current + // The live region: everything not yet flushed, capped to the rows the frame + // has for it. Capped from the FRONT (keep the newest) because an over-tall + // live frame scrolls ink's render region and smears stale rows up the + // terminal — and nothing is lost, since these rows flush as they settle. + const liveRows = useMemo(() => { + const rest = allRows.filter((r) => !flushedEntries.current.has(r.entryKey)) + return rest.length > viewBudget ? rest.slice(rest.length - viewBudget) : rest + // flushedEntries is a ref mutated above during this same render, so the + // 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 @@ -1086,18 +1241,56 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { if (m[1] === '64') delta -= WHEEL_ROWS else if (m[1] === '65') delta += WHEEL_ROWS } - if (delta !== 0) scrollByRows(delta) + 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. + // 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) { - scrollByRows(key.pageUp ? -view.capacity : view.capacity) + 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 + // (typing, editing, ctrl+*) falls through to the composer below, which is + // what keeps the menu a suggestion rather than a mode you get stuck in. + if (menuOpen) { + if (key.upArrow || key.downArrow) { + const next = key.upArrow ? menuAt - 1 : menuAt + 1 + // Wraps, because the list is short enough that walking off one end + // meaning "go to the other" is faster than reversing direction. + setMenuIndex((next + menu.length) % menu.length) + return + } + if (key.tab) { + complete(menu[menuAt]) + return + } + // Enter COMPLETES rather than submits while the line is still a partial + // command ("/tr"), and submits once it names one exactly ("/transcript"): + // otherwise enter on a highlighted suggestion would refuse the very + // command the menu is pointing at. + if (key.return && !resolveCommand(composer.text)) { + complete(menu[menuAt]) + return + } + if (key.escape) { + setComposer({ text: '', cursor: 0 }) + return + } + } if (key.escape) { - // Modal-first: transcript navigation drops back to the composer, then - // esc leaves the pane. + // 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) @@ -1109,21 +1302,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { 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') { - setExpanded((v) => !v) + if (windowed) setExpanded((v) => !v) + else setWindowed(true) return } - // ctrl+s releases/re-arms the mouse capture: released, the terminal - // gets the mouse back for normal select/copy; armed, wheel/trackpad - // scrolls the transcript. - if (key.ctrl && ch === 's') { - const next = !mouseCapture - setMouseCapture(next) - setNotice( - next - ? 'wheel scrolling on · ctrl+s to release mouse for links/select' - : 'mouse released for links/select · ctrl+s for wheel scrolling', - ) + // 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) { @@ -1205,11 +1399,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } 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. if (!composerVisible) { - if (key.upArrow && navKeys.length > 0) { + if (key.upArrow && windowed && navKeys.length > 0) { setNavKey(navKeys[navKeys.length - 1]) setScrollAnchor(null) } else if (key.downArrow && props.onFocusNav) { @@ -1230,10 +1433,12 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } 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. + // 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. const up = cursorLineUp(composer.text, composer.cursor) if (up !== null) setComposer((c) => ({ ...c, cursor: up })) - else if (navKeys.length > 0) { + else if (windowed && navKeys.length > 0) { setNavKey(navKeys[navKeys.length - 1]) setScrollAnchor(null) } @@ -1305,18 +1510,31 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // the squeeze resolves inside (the overflow-hidden viewport absorbs it). - {/* Top padding — see the termRows comment: absorbs terminal row- - accounting quirks and the post-exit sign-off so the first content - line never scrolls out of the window. */} + {/* 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 one-line opener is the whole banner — the rest of the session - identity (dashboard link, model, version) lives in the footer meta - line, so nothing is printed to scrollback before the app. */} {/* 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 @@ -1328,17 +1546,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { rowViewport already emits exactly the rows that fit. */} - {view.showAbove && ( + {windowed && view.showAbove && ( {` ↑ ${view.hiddenAbove} more line${view.hiddenAbove === 1 ? '' : 's'} above`} )} - {allRows.slice(view.start, view.end).map((row) => ( + {(windowed ? allRows.slice(view.start, view.end) : liveRows).map((row) => ( ))} - {view.showBelow && ( + {windowed && view.showBelow && ( {` ↓ ${view.hiddenBelow} more line${view.hiddenBelow === 1 ? '' : 's'} below`} @@ -1373,28 +1594,41 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { · {shownNotice} )} - {/* The composer: the input area on the elevated surface — one step - lighter (the active surface) while it's where you are (focused, no - transcript highlight), matching the transcript's selection - treatment. The tint is the whole frame — no rules — so the panel - reads as lifted off the canvas rather than fenced in by lines. A - uniform 1-cell pad keeps the text off all four edges, and because - it's inside the tinted Box the gutter carries the panel color too. - The panel is pinned to the rows budgeted for it, and clips: a pane - too short for the whole input scrolls it (below) rather than - painting the overflow across the meta line. */} + {/* The slash-command menu, directly above the input it completes: one + row per match, the highlighted one wearing the same cyan ▶ as every + other selection in the app. It sits inside the budgeted rows + (menuRows), so a long list is cut rather than pushing the composer + off the bottom of the frame. */} + {menuRows > 0 && ( + + {menu.slice(0, menuRows).map((command, i) => ( + + + {i === menuAt ? SELECTION_GLYPH : ' '}{' '} + {`/${command.name}`} + {` ${command.detail}`} + + + ))} + + )} + {/* The composer. It is pinned to the rows budgeted for it and clips, so a + terminal too short for the whole input scrolls it rather than painting + the overflow across the meta line. */} {composerVisible && ( = COMPOSER_INTERIOR_ROWS + 2 ? 1 : 0} paddingX={COMPOSER_PAD_X} > @@ -1411,11 +1645,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { {/* 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. */} - {/* The explicit colour on the parent is what the bare text - children below inherit — ink would otherwise leave the typed - text on the terminal's default foreground, unreadable against - the panel on a light theme — and it gives the inverse caret a - known pair of colours to swap. */} + {/* 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. */} )} - {!props.hideMetaLine && {metaLine}} + {metaLine} ) @@ -1477,6 +1709,23 @@ export function cursorLineDown(text: string, cursor: number): number | null { return nextStart + Math.min(col, nextLen) } +// The rule printed into scrollback above a session that replaced another in the +// same terminal: two conversations otherwise run together, and the second's +// opening line reads as the first's next turn. Names the session it opens, since +// that is the thing the reader needs to know about the text below it. +function sessionBreakRow(sessionId: string, cols: number): TranscriptRow { + const label = ` ${sessionId} ` + const rule = '─'.repeat(Math.max(0, contentWidth(cols) - visibleWidth(label))) + return { + id: `break:${sessionId}`, + entryKey: `break:${sessionId}`, + spans: [ + { text: label, dim: true }, + { text: rule, dim: true }, + ], + } +} + // One or more SGR mouse reports (\x1b[ + {row.indent ? : null} diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 915de6d..c87e886 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -43,25 +43,30 @@ import { } from '../lib/sessions' import type { ResolvedSessionBar } from '../lib/config' import { randomFact } from '../lib/facts' -import { SURFACE_ACTIVE, SURFACE_ELEVATED, theme } from '../lib/theme' +import { inputSurface, theme } from '../lib/theme' +import { useAltScreen } from './altScreen' import { ConnectApp } from './ConnectApp' -// The multi-session UI, a vertical stack of four bands: -// 1. the header — " ellipsis.dev" top-left; top-right the focused -// session's meta (id · model · cost · tokens), or the who-tag when -// no session is focused -// 2. the chat window (the hosted ConnectApp, full width) -// 3. the text input (the ConnectApp's composer) -// 4. the session nav — a vertical list: "+ New session" then your sessions, -// banded by status (live conversations first) and newest-born first -// inside a band -// This is what a bare `agent`, `agent "prompt"`, and `agent session -// connect ` all open. +// The multi-session UI — what a bare `agent`, `agent "prompt"`, and `agent +// session connect ` all open. It is a set of SCREENS, not a stack of bands: // -// Focus is modal and esc steps outward: inside the chat esc closes panels, -// then transcript navigation, then lands on the nav list. ↓ at the composer's -// last line reaches the nav too; enter (or esc, or ↑ off the top row) hands it -// back. Exactly one useInput handler is active at a time. +// * the chat (the ConnectApp) owns the terminal outright. Its settled +// transcript is printed into the terminal's real scrollback, so the wheel, +// 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 +// 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. // // 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 @@ -77,8 +82,8 @@ const NAV_NEW_LABEL = '+ New session' const HEADER_TITLE = 'ellipsis.dev' const TITLE_WIDTH = HEADER_TITLE.length -// Blank canvas cells between the frame and the terminal edge, on all four -// sides. Everything inside lays out against the inset width/height. +// Blank cells between the frame and the terminal edge, on all four sides. +// Everything inside lays out against the inset width/height. const APP_INSET = 1 // Extra indent for the session-nav rows, on top of the app inset — the list @@ -112,10 +117,9 @@ export interface SessionsAppProps { // caveat to show in its chat (watch-only reasons ride connectability). initialConfigName?: string initialNotice?: string - // How the session nav (band 4) is scoped: how many rows it shows and which - // sessions reach it. `hidden` drops the band entirely, giving its rows to - // the chat — focus then never leaves the chat, so esc and ↓ at the bottom - // edge do nothing. Set under "sessionBar" in the config file. + // 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. + // Set under "sessionBar" in the config file. sessionBar: ResolvedSessionBar // Builds the start request for a composer-spawned session (the entry point // owns repository detection and defaults). @@ -155,9 +159,9 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { } }, [stdout]) const height = Math.max(8, termRows - 1) - // The app inset: one blank cell of canvas on all four sides of the whole - // frame, so nothing sits flush against the terminal edge. The bands lay out - // inside it, so every width/height below is the INNER box, not the terminal. + // The app inset: one blank cell on all four sides of the whole frame, so + // nothing sits flush against the terminal edge. Everything lays out inside it, + // so every width/height below is the INNER box, not the terminal. const contentCols = Math.max(20, termCols - APP_INSET * 2) const contentRows = Math.max(6, height - APP_INSET * 2) @@ -238,8 +242,18 @@ 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 bar at the bottom owns the keyboard. + // '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. + // + // It moved there because the chat gave up its viewport: the transcript is + // printed into the terminal's real scrollback now, and scrollback is + // all-or-nothing — a band pinned below the chat would be overwritten by the + // rows scrolling past it. A screen of its own also gives the list the whole + // terminal rather than a fixed handful of rows. const [focus, setFocus] = useState<'nav' | 'chat'>('chat') + const navOpen = focus === 'nav' && !hideNav + useAltScreen(navOpen) const [mainPane, setMainPane] = useState( props.initialSessionId ? { type: 'chat', sessionId: props.initialSessionId } : { type: 'new' }, ) @@ -250,6 +264,10 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------ chat entries ------------------------------ const [entries, setEntries] = useState>(new Map()) + // Sessions whose chat has already printed into this terminal's scrollback. + // The NEXT one to open prints a rule naming itself first, so two conversations + // in one scrollback don't run together (see ConnectApp's scrollbackBreak). + const shownChats = useRef>(new Set()) const [loadError, setLoadError] = useState(null) const loading = useRef(new Set()) @@ -393,7 +411,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { useInput( (ch, key) => { - // The nav is a vertical list: ↑/↓ move the highlight, enter opens the + // The picker is a vertical list: ↑/↓ move the highlight, enter opens the // highlighted session, esc (or ↑ off the top row) returns to the chat. if (key.upArrow || key.downArrow) { const idx = selectable.indexOf(selected) @@ -447,36 +465,43 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // ------------------------------- rendering -------------------------------- - // Band heights: header = blank + title line + rule (3); nav = rule + the - // new-session row + `sessionBar.rows` session rows + hint, or nothing when - // hidden. The chat band gets the rest, and a terminal too short for both - // drops session rows rather than growing the frame past the screen. + // The session picker, open, owns the whole screen: the header, the + // "+ New session" row and the hint line, with every remaining row going to + // sessions. Nothing caps the list any more — the old row cap existed to stop a + // pinned band from eating the chat, and a screen of its own has no such + // conflict. `sessionBar` still scopes WHICH sessions are listed. const headerRows = 3 - const navSessionRows = Math.max(1, Math.min(sessionBar.rows, contentRows - 10)) - const navRows = hideNav ? 0 : 3 + navSessionRows - const chatRows = Math.max(4, contentRows - headerRows - navRows) + const navSessionRows = Math.max(1, contentRows - headerRows - 3) + // The pane height the new-session composer and the loading placeholder get: + // everything under the header. (A live chat is not sized here at all — it owns + // the terminal and prints into its scrollback.) + const paneRows = Math.max(4, contentRows - headerRows) - // ---- band 1: the header ---- + // ---- the header ---- // "ellipsis.dev" pins the left edge as always; the right edge carries the // focused session's live meta (id · model · cost · tokens), derived from // its transcript store so it ticks like the old footer did. With no // session focused the right edge falls back to the who-tag. + // + // It is NOT pinned above the chat any more. A live chat prints its transcript + // into the terminal's scrollback, and a band above a scrolling region is + // simply overwritten by it — the two cannot coexist. So the chat runs + // headerless (its own footer meta line carries the same identity, which is + // why hideMetaLine is dropped below) and the header renders on the screens + // that DO own their frame: the new-session pane and the session picker. const focusedEntry = mainPane.type === 'chat' ? entries.get(mainPane.sessionId) : undefined const metaText = useHeaderMeta(focusedEntry, mainPane, appBase, customerLogin, contentCols - 4) const whoText = props.ghLogin ? `@${props.ghLogin} in ${customerLogin}` : customerLogin const header = ( - // The top bar is a lifted surface, like the composer: the panel tint (not - // a rule) is what separates it from the transcript below. Its own 2-cell - // pad sits inside the tint, so the bar reads as a band with the title - // floating in it rather than type pinned to an edge. + // Unpainted, like every other surface: its own blank rows above and below + // are what set the title apart, not a tint. ) - // ---- bands 2+3: the chat window + its composer (one hosted component) ---- + // ---- the main screen: the chat, or the new-session composer ---- let main: React.ReactElement if (mainPane.type === 'new') { main = ( // Full width, no side padding: the prompt input's rules span the // terminal exactly like the header's and the nav's do. - + ) } else { + // Anything already printed below means this chat is arriving under another + // conversation, so it opens with a rule naming itself. Recorded before the + // render so the flag is stable for this mount: the ref is what makes the + // FIRST chat of the process print no break. + const needsBreak = shownChats.current.size > 0 && !shownChats.current.has(mainPane.sessionId) + shownChats.current.add(mainPane.sessionId) main = ( - // The chat window + composer, full width (bands 2 and 3 live inside - // the hosted ConnectApp: transcript above, input box at the bottom). - // overflow=hidden clips a mis-estimated transcript slice instead of - // letting the frame outgrow the terminal (which scrolls Ink's render - // region and smears stale rows on every hop). The meta line is - // hidden here — the header renders it. - - - + // 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. + ) } } - // ---- band 4: the session nav ---- - // A vertical list: the pinned new-session row, then `sessionBar.rows` session - // rows (status dot + description + a dim age tag) in sortSidebarSessions - // order — status band, newest-born first — windowed so the highlight parks - // on the second-to-last row and the list scrolls under it. The band's height - // is fixed, so a short list leaves blank rows rather than moving the chat - // above it. + // ---- the session picker ---- + // A vertical list on a screen of its own (the alternate buffer): the pinned + // new-session row, then the session rows (status dot + description + a dim age + // tag) in sortSidebarSessions order — status band, newest-born first — + // windowed so the highlight parks near the bottom and the list scrolls under + // it. It gets the whole terminal now, so the window is only reached by lists + // longer than the screen. const selectedRowIdx = Math.max(0, rows.findIndex((s) => s.id === selected)) const win = navSlice(rows.length, navSessionRows, selectedRowIdx) const navFocused = focus === 'nav' @@ -602,16 +624,16 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { // pad is on the band, so every row (and the hint) shares one left edge. - {/* The highlighted row — this one and the session rows below — takes - the active surface across its full width (the same lighter panel - the focused composer sits on), never the inverse bar. */} - + {/* The highlighted row — this one and the session rows below — is marked + by the cyan ▶ in its gutter and nothing else. No bar: a fill would be + one more surface to keep in step with the terminal's own. */} + {selected === 'new' && navFocused ? ( @@ -638,10 +660,7 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const meta = `${rowMeta(s)}${attention.has(s.id) ? ' · needs you' : ''}` const descW = Math.max(8, contentCols - NAV_GUTTER * 2 - meta.length - 6) return ( - // The highlighted row's background spans the whole width — the - // meta tag included — on the same active surface as everywhere - // else, replacing the old inverse (bone-white) description bar. - + @@ -680,31 +699,39 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { ) : ( - {navFocused - ? `↑↓ move · enter open · n new · esc chat · q quit${ - win.end < rows.length ? ` · ${rows.length - win.end} more below` : '' - }` - : '↓/esc: sessions'} + {`↑↓ move · enter open · n new · esc back to the chat · q quit${ + win.end < rows.length ? ` · ${rows.length - win.end} more below` : '' + }`} )} ) + // The picker, open, IS the screen: it took over the alternate buffer, so it + // renders alone (header + list) and the chat is left untouched on the primary + // buffer, waiting behind it. + if (navOpen) { + return ( + + {header} + {nav} + + ) + } + + // A LIVE CHAT owns the terminal outright: no box around it, no header, no + // inset. All three would be frame furniture around a region that prints into + // the terminal's scrollback, and the rows scrolling past would run straight + // through them. + const chatOwnsScreen = mainPane.type === 'chat' && entries.has(mainPane.sessionId) + if (chatOwnsScreen) return main + + // The remaining screens (the new-session composer, a chat still loading) do + // own their frame, so they keep the inset and the header. return ( - // The brand canvas, painted edge to edge: every band sits on it, and the - // composer's panel is the one surface lifted above it. Painting the root - // (rather than letting the terminal's own background show through) is what - // makes the charcoal→panel step read as intentional depth on ANY terminal - // theme instead of only on a dark one. - + {header} {main} - {!hideNav && nav} ) } @@ -795,10 +822,9 @@ const PICKER_ROWS: readonly PickerRow[] = [ // The new-session pane, mirroring the dashboard's home composer // (app.ellipsis.dev/[login]): a centered "What are we shipping today?" // heading floating in the empty space, and the input panel docked at the -// bottom — the Repository / Agent / Model rows inside the tinted box with -// the ❯ prompt line beneath them (the dashboard card's controls-inside-the- -// composer shape). The ❯ glyph marks whichever row is selected; the whole -// panel steps to the lighter active surface while any row has focus. ↑ from +// bottom — the Repository / Agent / Model rows above the ❯ prompt line (the +// dashboard card's controls-inside-the-composer shape). The ❯ glyph marks +// whichever row is selected. ↑ from // the prompt climbs into the option rows, ↑/↓ walk them (↓ off the last // returns to the prompt), →/enter unfolds a row's option list in place, // inside the panel ([x] marks the pick). Inside an open list ↑/↓ walk, → (or @@ -1074,6 +1100,11 @@ function NewSessionPane({ // minus the heading, notices, and the panel's other rows (~12); the panel // grows upward into the spacer above, so the prompt never moves. const dropdownCapacity = Math.max(3, height - 12) + // Whether the input has room for its 1-row perimeter: the three picker rows, + // the blank one above the prompt, the prompt's own two, and a pad row top AND + // bottom. All or nothing, deliberately — a pad on top with none underneath + // reads as a box that forgot to close, which is worse than no pad at all. + const inputPad = height - (PICKER_ROWS.length + 1 + 2) >= 2 ? 1 : 0 // The wrap width inside the input box: the pane minus the box's own left // and right padding (the "▶ " prompt is part of the wrapped text). const inputWidth = Math.max(8, width - COMPOSER_PAD_X * 2) @@ -1093,44 +1124,53 @@ function NewSessionPane({ // pushing the input around. // No pane padding: the input's rules span the full terminal width like // the header's and the nav's (the rows inside carry their own indents). + // The heading and the fact are DECORATION, and they are what yields when the + // terminal is short: both sit in an overflow-hidden box that shrinks to + // nothing, so the input below keeps every row it asked for — including the + // blank one under the prompt. Squeezing the input instead would eat that row + // and leave the box looking open at the bottom. - - - - What are we shipping today? - - - {/* The fact box is sized to the text (capped so long facts wrap at a - readable measure) so short facts sit centered, not left-aligned - inside a fixed column. */} - - - - {fact} + + + + + What are we shipping today? + {/* The fact box is sized to the text (capped so long facts wrap at a + readable measure) so short facts sit centered, not left-aligned + inside a fixed column. */} + + + + {fact} + + + + - {error && ✗ {error}} {starting && ✻ Starting session…} - {/* The input panel — the SAME surface as the chat composer, stepping - onto the lighter active surface while ANY of its four rows is where - you are (a picker row, its open option list, or the prompt), no - rules, with a uniform 1-cell pad inside the tint. The run controls - live INSIDE it, one row each ABOVE the prompt (the dashboard card's - controls-inside-the-composer shape): Repository, Agent, Model, a - blank spacer row, then the prompt line. The ❯ selection glyph marks - whichever row is selected. →/enter opens a picker row IN PLACE — - its value collapses to a bare label and the option list unfolds - indented beneath it, inside the panel (the panel grows upward into - the spacer above; the prompt never moves). Repositories + {/* The input, laid out like the chat composer: the run controls one row + each ABOVE the prompt (the dashboard card's controls-inside-the- + composer shape) — Repository, Agent, Model, a blank row, then the + prompt line. The ❯ selection glyph marks whichever row is selected. + →/enter opens a picker row IN PLACE: its value collapses to a bare + label and the option list unfolds indented beneath it, growing upward + into the space above so the prompt never moves. Repositories multi-select ([x] toggles), the others pick one. */} {PICKER_ROWS.map((r, i) => { const active = focused && openPicker === null && row === i diff --git a/src/ui/altScreen.ts b/src/ui/altScreen.ts new file mode 100644 index 0000000..5101343 --- /dev/null +++ b/src/ui/altScreen.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react' +import { useApp, useStdout } from 'ink' + +// The alternate screen buffer (DECSET 1049), hand-rolled because ink 7 takes +// `alternateScreen` only as a render()-time option and this app has to hop +// buffers at runtime under ONE live ink instance: the default view lives in the +// primary buffer with its settled transcript flushed to real scrollback, and +// the transcript browser takes over the alt screen and hands the primary buffer +// back untouched on exit. +// +// The switch goes through ink's suspendTerminal, which is the only way to keep +// ink's picture of the screen honest across it: suspend ERASES the current +// frame, and resume forces a full redraw with the frame bookkeeping reset +// (lastOutput/lastOutputHeight). Writing 1049h/l behind ink's back instead +// leaves ink diffing the next frame against one that is no longer on screen, +// which paints the new frame over the wrong rows. Input is paused for the +// duration and restored by resume, so the keyboard survives the hop. +const ENTER = '[?1049h' +const LEAVE = '[?1049l' + +export function useAltScreen(active: boolean): void { + const { suspendTerminal } = useApp() + const { stdout } = useStdout() + // Transitions are serialized: suspendTerminal throws if the terminal is + // already suspended, and two fast toggles (ctrl+r then esc) would otherwise + // overlap. Errors are swallowed — failing to hop buffers must not take the + // session down. + const queue = useRef>(Promise.resolve()) + const hop = (write: string): void => { + if (!stdout?.isTTY) return + queue.current = queue.current + .then(() => suspendTerminal(() => void stdout.write(write))) + .catch(() => {}) + } + useEffect(() => { + if (!active) return + hop(ENTER) + // Also the unmount path: quitting from inside the browser must give the + // primary buffer back rather than leave the shell on the alt screen. + return () => hop(LEAVE) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, stdout, suspendTerminal]) +} diff --git a/src/ui/commands.ts b/src/ui/commands.ts new file mode 100644 index 0000000..13af94d --- /dev/null +++ b/src/ui/commands.ts @@ -0,0 +1,72 @@ +// The composer's slash commands, and the autocomplete menu over them. +// +// A slash command is an instruction to the CLI, not a message to the agent, so +// the two are kept apart by a rule rather than by guesswork: a line whose FIRST +// character is `/` is a command, and an unknown one is refused instead of being +// 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 SlashCommand = { + id: CommandId + // What you type, without the leading slash. + name: string + // Extra spellings that select the same command. Not shown in the menu. + aliases?: readonly string[] + // The one-line description in the menu, lowercase and imperative. + detail: string +} + +// Every command, in menu order: the two that act on the session first, then the +// screens, 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: 'exit', name: 'exit', aliases: ['quit'], detail: 'leave the CLI; the session keeps running' }, +] + +// Whether this input is addressed to the CLI rather than the agent. Deliberately +// strict about position: a `/` anywhere else is ordinary prose ("check /tmp"), +// and only a leading one claims the line. Pure, for tests. +export function isCommandInput(text: string): boolean { + return text.startsWith('/') +} + +// The commands a partially typed `/word` should offer, in menu order. +// +// PREFIX matching, not fuzzy or substring: the list is short and the names are +// short, so a prefix is enough to get you there in two or three keystrokes, and +// it never surprises you with a command whose name you did not start typing. +// Aliases match too (so `/qu` finds exit) but the canonical name is what shows. +// A bare `/` offers everything. Pure, for tests. +export function matchCommands(text: string): SlashCommand[] { + if (!isCommandInput(text)) return [] + const typed = text.slice(1).toLowerCase() + // Only the first word is the command; once you have typed a space you have + // committed to one, and the menu has nothing left to offer. + if (/\s/.test(typed)) return [] + if (typed === '') return [...SLASH_COMMANDS] + return SLASH_COMMANDS.filter((c) => + [c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(typed)), + ) +} + +// The command an entered line names, or null when it names none — which the +// caller reports as an error rather than sending on. Exact match on the name or +// an alias, case-insensitively; a prefix is NOT enough here, because running the +// wrong command is worse than being told to finish typing. Pure, for tests. +export function resolveCommand(text: string): SlashCommand | null { + if (!isCommandInput(text)) return null + const typed = text.slice(1).trim().toLowerCase() + return ( + SLASH_COMMANDS.find((c) => [c.name, ...(c.aliases ?? [])].includes(typed)) ?? null + ) +} + +// The text the composer holds after tab/enter completes `highlighted`: the whole +// command, with a trailing space so the line reads as finished. Pure, for tests. +export function completedText(command: SlashCommand): string { + return `/${command.name} ` +} diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 33f7599..dffa953 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -53,11 +53,10 @@ export async function runSessionsUi(options: SessionsUiOptions): Promise { const openSocket = makeOpenSocket(token, resolveWsBase(resolveApiBase())) const me = await client.me() - // Start at the top of a fresh window: scroll whatever is on screen into - // scrollback, then home the cursor (same dance as the solo connect). - if (process.stdout.isTTY) { - process.stdout.write('\n'.repeat(process.stdout.rows ?? 24) + '\x1b[H') - } + // No screen-clearing dance: the chat prints its settled transcript into THIS + // terminal's scrollback (see ConnectApp), so the conversation grows down the + // terminal from wherever the shell prompt left off, like ordinary command + // output. Homing the cursor would throw away the scrollback it relies on. const app = render( React.createElement(SessionsApp, { api: client, diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index 2cecaef..08bb031 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -25,11 +25,8 @@ import { theme } from '../lib/theme' export const GUTTER_COLS = 2 // 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. +// pane's edge, like the composer's interior, so your messages and the agent's +// 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) @@ -53,11 +50,10 @@ export type RowSpan = { // there is no "unstyled" span and no `dimColor` — because neither of the two // things a bare span would fall back on belongs to us: // -// * NO COLOUR MEANS THE TERMINAL'S COLOUR. Ink leaves an uncoloured `` -// on the terminal's default foreground, which under a LIGHT theme is the -// same near-black as the canvas we paint beneath it. Most spans carry no -// colour of their own (the assistant's prose included, via styleFor), so -// the bulk of a transcript came out dark on dark. See theme.ts, rule 1. +// * NO COLOUR MEANS THE TERMINAL'S COLOUR, which is a colour we do not know: +// most spans carry none of their own (the assistant's prose included, via +// styleFor), so leaving them bare hands the bulk of the transcript to +// whatever the user's theme happens to be. See theme.ts. // * DIM IS OPTIONAL, as far as terminals are concerned: \x1b[2m is dropped // outright by a fair number of them once a 24-bit foreground is also set. // That is the same reason a pulsing mark SWAPS its colour on the off beat @@ -101,11 +97,7 @@ export type TranscriptRow = { // 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. Only a message YOU sent does. - panel?: boolean - // 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. + // 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 @@ -165,35 +157,6 @@ export function navKeyOf(row: TranscriptRow): string { return row.navKey ?? row.entryKey } -// One blank tinted row above and below every maximal run of consecutive panel -// rows — the vertical pad around a message YOU sent, matching the composer's -// interior pad. Applied to the ASSEMBLED list rather than inside itemRows so a -// 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 - // below a message's nested tool line belongs to that message, and leaving - // navKey off would make the tool line a ↑/↓ stop of its own again. - const pad = (edge: TranscriptRow, side: string): TranscriptRow => ({ - id: `${edge.id}:${side}`, - entryKey: edge.entryKey, - navKey: edge.navKey, - spans: [], - panel: true, - spacer: true, - }) - for (const row of rows) { - const prev = out[out.length - 1] - if (row.panel && !prev?.panel) out.push(pad(row, 'padT')) - if (!row.panel && prev?.panel) out.push(pad(prev, 'padB')) - out.push(row) - } - const last = out[out.length - 1] - if (last?.panel) out.push(pad(last, 'padB')) - return out -} - // One transcript item as its screen rows: the separator above it, its body // pre-wrapped to the column it occupies, and the "+N lines" marker when a long // body is clamped. @@ -202,11 +165,6 @@ export function itemRows( cols: number, opts: { indent?: number; clamp: boolean; nested?: boolean; attach?: boolean }, ): TranscriptRow[] { - // 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 // 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 @@ -240,7 +198,6 @@ export function itemRows( indent, textPad, spans, - panel, ...extra, }) } @@ -276,8 +233,8 @@ export function itemRows( // 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): -// 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. +// 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. // @@ -368,15 +325,14 @@ export function isToolActivity(item: TranscriptItem): boolean { // 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. +// does not: a branch under your own message 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 -// part of the tool burst it belongs to. The duration is a `tick` marker rather +// ticking readout in the right-hand metadata column. `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( @@ -410,11 +366,10 @@ export function activityRows( } // 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. +// the committed record it becomes, so nothing shifts when that 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. export function pendingMessageRows( key: string, text: string, @@ -425,10 +380,8 @@ export function pendingMessageRows( bold?: boolean right?: string pulse?: boolean - panel?: boolean }, ): TranscriptRow[] { - const panel = opts.panel ?? false const width = contentWidth(cols) const rows: TranscriptRow[] = [spacerRow(key, `${key}:sp`)] const lines = fitLines(text, width) @@ -442,7 +395,6 @@ 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, pulse: i === 0 ? opts.pulse : undefined, }) } @@ -530,6 +482,54 @@ export function rowViewport( } } +// ---------------------------------------------------------------- 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. +// +// 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 +// keeps the live frame about one message tall during a long turn. Pure, for +// tests. +export function settledItemKeys( + items: readonly TranscriptItem[], + turnLive: boolean, +): Set { + if (!turnLive) return new Set(items.map((i) => i.key)) + let lastSpeech = -1 + for (const [i, item] of items.entries()) if (!isToolActivity(item)) lastSpeech = i + // A transcript that is nothing but an open tool run has no settled prefix. + if (lastSpeech < 0) return new Set() + return new Set(items.slice(0, lastSpeech).map((i) => i.key)) +} + +// How many rows off the FRONT of the list are final — the count handed to +// . A prefix, deliberately: rows are flushed in screen order, so the +// first row that can still change stops the scan even if later rows are +// settled. Pure, for tests. +export function settledRowCount( + rows: readonly TranscriptRow[], + settledKeys: ReadonlySet, +): number { + let n = 0 + for (const row of rows) { + if (!settledKeys.has(row.entryKey)) break + n++ + } + 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. diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..189cbc9 --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + completedText, + isCommandInput, + matchCommands, + resolveCommand, + SLASH_COMMANDS, +} from '../src/ui/commands' + +describe('isCommandInput', () => { + it('claims a line only when the slash LEADS it', () => { + expect(isCommandInput('/stop')).toBe(true) + expect(isCommandInput('/')).toBe(true) + // Ordinary prose that happens to contain a slash still reaches the agent. + expect(isCommandInput('check /tmp for the log')).toBe(false) + expect(isCommandInput('what does a/b mean')).toBe(false) + expect(isCommandInput('')).toBe(false) + }) +}) + +describe('matchCommands', () => { + const names = (text: string): string[] => matchCommands(text).map((c) => c.name) + + it('offers everything for a bare slash', () => { + expect(names('/')).toEqual(SLASH_COMMANDS.map((c) => c.name)) + }) + + it('narrows by prefix', () => { + expect(names('/st')).toEqual(['stop']) + expect(names('/s')).toEqual(['stop', 'sessions']) + expect(names('/tr')).toEqual(['transcript']) + }) + + it('matches aliases but shows the canonical name', () => { + expect(names('/qu')).toEqual(['exit']) + expect(names('/quit')).toEqual(['exit']) + }) + + it('is case-insensitive', () => { + expect(names('/ST')).toEqual(['stop']) + }) + + it('offers nothing once a space commits to a command', () => { + // The first word is the command; past it there is nothing left to complete. + expect(names('/stop ')).toEqual([]) + expect(names('/stop now')).toEqual([]) + }) + + it('offers nothing for prose or an unknown prefix', () => { + expect(names('hello')).toEqual([]) + expect(names('/zzz')).toEqual([]) + }) +}) + +describe('resolveCommand', () => { + it('needs the WHOLE name, so a prefix never runs the wrong command', () => { + expect(resolveCommand('/stop')?.id).toBe('stop') + // '/s' matches two commands in the menu; running either would be a guess. + expect(resolveCommand('/s')).toBeNull() + expect(resolveCommand('/st')).toBeNull() + }) + + it('accepts aliases and surrounding whitespace', () => { + expect(resolveCommand('/quit')?.id).toBe('exit') + expect(resolveCommand('/exit')?.id).toBe('exit') + expect(resolveCommand('/stop ')?.id).toBe('stop') + expect(resolveCommand('/STOP')?.id).toBe('stop') + }) + + it('returns null for prose and for an unknown command', () => { + expect(resolveCommand('hello')).toBeNull() + expect(resolveCommand('/stpo')).toBeNull() + }) +}) + +describe('completedText', () => { + it('completes to the canonical name with a trailing space', () => { + expect(completedText(SLASH_COMMANDS[0])).toBe('/stop ') + // An alias completes to the name it stands for, not to the alias. + const exit = SLASH_COMMANDS.find((c) => c.id === 'exit') + expect(completedText(exit!)).toBe('/exit ') + }) + + it('produces text that resolves back to the same command', () => { + for (const command of SLASH_COMMANDS) { + expect(resolveCommand(completedText(command))?.id).toBe(command.id) + } + }) +}) + +describe('the command list itself', () => { + it('has no duplicate names or aliases, so a spelling means one thing', () => { + const spellings = SLASH_COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]) + expect(new Set(spellings).size).toBe(spellings.length) + }) + + it('describes every command, since the menu shows the detail', () => { + for (const command of SLASH_COMMANDS) { + expect(command.detail, command.name).toMatch(/^[a-z]/) + expect(command.detail.length, command.name).toBeGreaterThan(8) + } + }) +}) diff --git a/test/config.test.ts b/test/config.test.ts index 7e0fcdd..9a81c87 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -232,7 +232,6 @@ describe('sessionBar', () => { hosts: {}, sessionBar: { hidden: true, - rows: 8, days: 30, repo: 'any', statuses: 'unfinished', @@ -241,7 +240,6 @@ describe('sessionBar', () => { }) expect(sessionBar()).toEqual({ hidden: true, - rows: 8, days: 30, repo: 'any', statuses: 'unfinished', @@ -250,8 +248,8 @@ describe('sessionBar', () => { }) it('fills in the fields the file leaves out', () => { - writeConfig({ version: 2, hosts: {}, sessionBar: { rows: 3 } }) - expect(sessionBar()).toEqual({ ...SESSION_BAR_DEFAULTS, rows: 3 }) + writeConfig({ version: 2, hosts: {}, sessionBar: { days: 3 } }) + expect(sessionBar()).toEqual({ ...SESSION_BAR_DEFAULTS, days: 3 }) }) // A typo in a preference should not stop the UI from opening. @@ -259,7 +257,7 @@ describe('sessionBar', () => { writeConfig({ version: 2, hosts: {}, - sessionBar: { hidden: 'yes', rows: 0, days: -3, repo: 'origin', statuses: 'live' }, + sessionBar: { hidden: 'yes', days: -3, repo: 'origin', statuses: 'live' }, }) expect(sessionBar()).toEqual(SESSION_BAR_DEFAULTS) }) diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index d09e4ef..c6e6845 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -20,6 +20,8 @@ import { itemRows, layOutItems, rowViewport, + settledItemKeys, + settledRowCount, snapAnchorForEntry, snapToEntry, spanColor, @@ -733,6 +735,57 @@ describe('cursorLineDown', () => { }) }) +describe('settledItemKeys', () => { + const item = (key: string, kind: TranscriptItem['kind'], text = 'x'): TranscriptItem => ({ + key, + kind, + text, + }) + + it('settles everything when no turn is in flight', () => { + const items = [item('a', 'user'), item('b', 'assistant'), item('c', 'tool')] + expect([...settledItemKeys(items, false)]).toEqual(['a', 'b', 'c']) + }) + + it('holds back the last message and its tool run while a turn is live', () => { + // 'b' is the message the open run hangs off, so → can still unfold it and + // its fold row can still grow: neither may be flushed yet. + const items = [item('a', 'user'), item('b', 'assistant'), item('c', 'tool')] + expect([...settledItemKeys(items, true)]).toEqual(['a']) + }) + + it('flushes a finished message once the agent moves on to the next', () => { + const items = [ + item('a', 'user'), + item('b', 'assistant'), + item('c', 'tool'), + item('d', 'assistant'), + ] + expect([...settledItemKeys(items, true)]).toEqual(['a', 'b', 'c']) + }) + + it('settles nothing when the transcript is only an open tool run', () => { + expect(settledItemKeys([item('c', 'tool')], true).size).toBe(0) + }) +}) + +describe('settledRowCount', () => { + const row = (id: string, entryKey: string): TranscriptRow => ({ id, entryKey, spans: [] }) + + it('counts the settled PREFIX, stopping at the first live row', () => { + const rows = [row('1', 'a'), row('2', 'a'), row('3', 'b'), row('4', 'c')] + // 'c' is settled too, but it sits behind live 'b' — rows flush in screen + // order, so the scan stops there. + expect(settledRowCount(rows, new Set(['a', 'c']))).toBe(2) + }) + + it('is 0 when the first row is live and everything when all are settled', () => { + const rows = [row('1', 'a'), row('2', 'b')] + expect(settledRowCount(rows, new Set(['b']))).toBe(0) + expect(settledRowCount(rows, new Set(['a', 'b']))).toBe(2) + }) +}) + describe('rowViewport', () => { it('follows the bottom by default, filling the window', () => { expect(rowViewport(10, 4, null)).toMatchObject({ start: 7, end: 10, hiddenBelow: 0 }) @@ -812,15 +865,19 @@ describe('itemRows', () => { expect(rows).toHaveLength(1) }) - it('panels what you said and leaves everything the agent said on the canvas', () => { - const panelOf = (kind: TranscriptItem['kind'], nested = false): boolean | undefined => + // Every row is unpainted: a row prints into the terminal's scrollback and is + // never repainted, so a fill on it would outlive the frame that drew it. The + // 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] - .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) + .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 })) { + expect(row).not.toHaveProperty('panel') + } }) it('emits one row per line of a multi-line body', () => { diff --git a/test/connect-render.test.ts b/test/connect-render.test.ts new file mode 100644 index 0000000..d0c9237 --- /dev/null +++ b/test/connect-render.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import React from 'react' +import { render } from 'ink' +import { PassThrough } from 'node:stream' +import stripAnsi from 'strip-ansi' +import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' +import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' +import { ConnectApp } from '../src/ui/ConnectApp' + +// End-to-end render of the real chat against a fake TTY: the scrollback view is +// a claim about what reaches the TERMINAL (settled rows printed once, above a +// repainting live frame), and only an actual render can check it. The stream and +// API are stubbed — nothing here talks to a network. + +const h = React.createElement + +function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { + const stream = new PassThrough() as unknown as NodeJS.WriteStream + let out = '' + stream.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + const tty = stream as unknown as { isTTY: boolean; columns: number; rows: number } + tty.isTTY = true + tty.columns = 80 + tty.rows = 24 + return { stream, output: () => out } +} + +// A stdin the app's keyboard handlers will accept: without raw-mode support ink +// throws out of useInput and the app never gets to render its own frame. +function fakeStdin(): NodeJS.ReadStream { + const stdin = new PassThrough() as unknown as NodeJS.ReadStream + const tty = stdin as unknown as { + isTTY: boolean + setRawMode: () => unknown + ref: () => void + unref: () => void + } + tty.isTTY = true + tty.setRawMode = () => stdin + tty.ref = () => {} + tty.unref = () => {} + return stdin +} + +// How many times a string was written to the terminal. The unit of the whole +// scrollback claim: a FLUSHED row is written once and then belongs to the +// terminal, while a live row is rewritten by every repaint. Comparing the two +// counts in one render is what distinguishes them — and it is why each test +// forces a repaint, so "once" means "survived repaints", not "never repainted". +function writes(raw: string, needle: string): number { + return stripAnsi(raw).split(needle).length - 1 +} + +// A cost tick, which changes the footer's total and so forces a real repaint of +// the live region without touching the transcript. (An IDENTICAL frame is +// skipped by ink, so the value has to actually differ.) +function costTick(store: SessionTranscriptStore, cents: number): void { + store.ingest({ + type: 'session', + session: { + id: 'session_render', + status: 'waiting', + cost_tokens: cents, + cost_sandbox_cpu: 0, + cost_sandbox_memory: 0, + cost_fee: 0, + tokens_total: cents, + tokens_model: 'claude-fable-5', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 40)) + +// The render options every test here uses. `interactive: true` is not optional: +// ink treats CI as non-interactive (is-in-ci), and a non-interactive render +// buffers everything and emits ONE final frame — no erases, no repaints, no +// flush as it happens. Every claim in this file is about the difference +// between a flushed row and a repainted one, so the whole file measures nothing +// under CI without it. The real app pins the same flag for the same reason (see +// runConnect). +const OPTIONS = { patchConsole: false, interactive: true } as const + +let seq = 0 +// A claude_code assistant-message record — the shape recordToItems turns into a +// ● prose row. +function say(text: string): Record { + return { + feed_seq: ++seq, + source: 'claude_code', + record_type: 'event', + payload: { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text }] }, + }, + } +} + +function lifecycle(recordType: string, payload: Record = {}) { + return { feed_seq: ++seq, source: 'lifecycle', record_type: recordType, payload } +} + +function seededStore(records: Record[], status: string) { + const store = new SessionTranscriptStore() + const session = { + id: 'session_render', + status, + cost_tokens: 0, + cost_sandbox_cpu: 0, + cost_sandbox_memory: 0, + cost_fee: 0, + tokens_total: 0, + tokens_model: 'claude-fable-5', + } + store.ingest({ + type: 'snapshot', + protocol: SESSION_STREAM_PROTOCOL_VERSION, + earliest_feed_seq: null, + session, + messages: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + store.ingest({ type: 'records_append', records } as any) + return store +} + +// The app with its network edges stubbed: the socket factory never connects, so +// the render is driven entirely by the seeded store. +function chat(store: SessionTranscriptStore, extra: Record = {}) { + return h(ConnectApp, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + api: {} as any, + sessionId: 'session_render', + store, + // Never resolves: no stream, no frames, no timers of its own. + openSocket: () => new Promise(() => {}), + canSend: true, + minRenderFeedSeq: 0, + sessionUrl: 'https://app.ellipsis.dev/acme?session=session_render', + model: 'claude-fable-5', + ...extra, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +describe('ConnectApp — the scrollback view', () => { + it('prints a settled transcript once, and keeps printing it once as the frame repaints', async () => { + // 'waiting' = no turn in flight, so every message is settled and flushes. + const store = seededStore( + [lifecycle('sandbox_ready', {}), say('first message'), say('second message')], + 'waiting', + ) + const { stream, output } = fakeTty() + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) + await settle() + // Two more repaints of the live region, so "written once" below is a real + // claim about the flush rather than an artifact of a single frame. + // Big enough to move the dollar figure in the footer: $0.00 -> $0.50 -> $1.50. + costTick(store, 50_000) + await settle() + costTick(store, 150_000) + await settle() + app.unmount() + const raw = output() + // The live region demonstrably repainted... + expect(writes(raw, 'total')).toBeGreaterThan(1) + // ...and the settled rows were written once anyway: they are the terminal's + // now, which is what makes the wheel scroll them. + expect(writes(raw, 'first message')).toBe(1) + expect(writes(raw, 'second message')).toBe(1) + }) + + it('holds the last message in the live frame while a turn is in flight', async () => { + // A live turn means the newest message can still grow a tool run under it, so + // it must NOT be flushed — it stays in the repainting region, and the older + // message flushes without it. + const store = seededStore( + [ + lifecycle('sandbox_ready', {}), + say('older message'), + say('newest message'), + lifecycle('turn_started', { turn_id: 't1' }), + ], + 'working', + ) + const { stream, output } = fakeTty() + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) + await settle() + costTick(store, 50_000) + await settle() + app.unmount() + const raw = output() + // The older message settled and flushed: written once despite the repaint. + expect(writes(raw, 'older message')).toBe(1) + // The newest one is still live — a tool run can still land under it — so it + // is rewritten by each repaint instead. + expect(writes(raw, 'newest message')).toBeGreaterThan(1) + }) + + it('opens with a rule naming the session when it follows another chat', async () => { + const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') + const { stream, output } = fakeTty() + const app = render(chat(store, { scrollbackBreak: true }), { + stdout: stream, + stdin: fakeStdin(), + ...OPTIONS, + }) + await settle() + app.unmount() + const text = stripAnsi(output()) + expect(text).toContain('session_render') + expect(text).toContain('─') + }) + + it('paints no background on a FLUSHED row, so scrollback carries no stale fill', async () => { + // A row printed into scrollback is never repainted, so a fill on it outlives + // the frame that drew it: stale bands survive a resize or a shorter frame + // with nothing able to clean them up. The composer is the one painted + // surface, and it lives in the live frame — so the assertion is about the + // flushed rows specifically, not about the byte stream as a whole. + const store = seededStore( + [lifecycle('sandbox_ready', {}), say('hello'), say('and again')], + 'waiting', + ) + const { stream, output } = fakeTty() + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) + await settle() + app.unmount() + // The flushed rows are everything written before the live frame's first + // cursor-hide, which is where ink starts painting the region it owns. + const raw = output() + const flushed = raw.slice(0, raw.indexOf('\u001B[?25l')) + expect(flushed).toContain('hello') + expect(flushed).not.toMatch(/\u001B\[[0-9;]*4[0-7]m/) + expect(flushed).not.toMatch(/\u001B\[[0-9;]*10[0-7]m/) + expect(flushed).not.toContain('48;2;') + expect(flushed).not.toContain('48;5;') + }) + + it('opens the slash-command menu as you type, and completes with tab', async () => { + const store = seededStore([lifecycle('sandbox_ready', {}), say('hi')], 'waiting') + const { stream, output } = fakeTty() + const stdin = fakeStdin() + const app = render(chat(store), { stdout: stream, stdin, ...OPTIONS }) + await settle() + const before = output().length + + // A bare slash offers every command, with its description. + stdin.write('/') + await settle() + let frame = stripAnsi(output().slice(before)) + expect(frame).toContain('/stop') + expect(frame).toContain('/transcript') + 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') + await settle() + frame = stripAnsi(output().slice(beforeNarrow)) + expect(frame).toContain('/transcript') + 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') + app.unmount() + }) + + it('does not capture the mouse in the chat, so the terminal keeps the wheel', async () => { + // The point of the whole exercise: no SGR mouse reporting (1000h/1006h) is + // armed while the chat is the view, which is what leaves wheel scrolling and + // select/copy to the terminal. + const store = seededStore([lifecycle('sandbox_ready', {}), say('hello')], 'waiting') + const { stream, output } = fakeTty() + const app = render(chat(store), { stdout: stream, stdin: fakeStdin(), ...OPTIONS }) + await settle() + app.unmount() + expect(output()).not.toContain('[?1000h') + expect(output()).not.toContain('[?1049h') + }) +}) diff --git a/test/scrollback.test.ts b/test/scrollback.test.ts new file mode 100644 index 0000000..2c55197 --- /dev/null +++ b/test/scrollback.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import React, { useEffect, useState } from 'react' +import { Box, Static, Text, render, useApp } from 'ink' +import { PassThrough } from 'node:stream' +import stripAnsi from 'strip-ansi' +import { useAltScreen } from '../src/ui/altScreen' + +// Offline render harness for the two terminal mechanisms the scrollback view +// rests on: ink's flush and the alternate-screen hop. Neither is +// observable in the React tree — the difference is in the BYTES written — so +// both are driven against a fake TTY stream and asserted on its output. +// createElement rather than JSX: the suite is .ts by convention. +const h = React.createElement + +// A stdout ink will treat as an interactive terminal, recording what is written. +function fakeTty(): { stream: NodeJS.WriteStream; output: () => string } { + const stream = new PassThrough() as unknown as NodeJS.WriteStream + let out = '' + stream.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + const tty = stream as unknown as { isTTY: boolean; columns: number; rows: number } + tty.isTTY = true + tty.columns = 80 + tty.rows = 24 + return { stream, output: () => out } +} + +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 25)) + +// `interactive: true` is not optional: ink treats CI as non-interactive +// (is-in-ci), and a non-interactive render buffers everything into ONE final +// frame — no erases, no repaints, no flush as it happens. These tests +// are about exactly that difference, so they measure nothing under CI without it. +const OPTIONS = { patchConsole: false, interactive: true } as const + +describe(' flush — the scrollback view', () => { + it('prints each settled row ONCE and never reprints it', async () => { + // The invariant the whole scrollback view rests on: a flushed row is printed + // and then belongs to the terminal. Reprinting shows up as a duplicated + // transcript, which is what makes this worth pinning down. + const { stream, output } = fakeTty() + function App(): React.ReactElement { + const [rows, setRows] = useState(['alpha']) + useEffect(() => { + // A second row settles, exactly as a second message would. + const t = setTimeout(() => setRows(['alpha', 'bravo']), 5) + return () => clearTimeout(t) + }, []) + return h( + Box, + { flexDirection: 'column' }, + h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)), + h(Text, null, 'live'), + ) + } + const app = render(h(App), { stdout: stream, ...OPTIONS }) + await settle() + app.unmount() + const text = stripAnsi(output()) + expect(text.match(/alpha/g)?.length).toBe(1) + expect(text.match(/bravo/g)?.length).toBe(1) + }) + + it('reprints from the start when the item list SHRINKS', async () => { + // Why ConnectApp holds flushed rows in an append-only ref instead of + // re-deriving them each frame: re-syncs its printed count from + // items.length, so a shorter list (what withholding the flush for the alt + // screen would produce) makes the next full list reprint what was already on + // screen. + const { stream, output } = fakeTty() + function App(): React.ReactElement { + const [rows, setRows] = useState(['alpha']) + useEffect(() => { + const shrink = setTimeout(() => setRows([]), 5) + const grow = setTimeout(() => setRows(['alpha']), 15) + return () => { + clearTimeout(shrink) + clearTimeout(grow) + } + }, []) + return h(Static, { items: rows }, (row: string) => h(Text, { key: row }, row)) + } + const app = render(h(App), { stdout: stream, ...OPTIONS }) + await settle() + app.unmount() + expect(stripAnsi(output()).match(/alpha/g)?.length).toBe(2) + }) +}) + +describe('useAltScreen', () => { + it('enters the alt buffer on open and restores the primary one on close', async () => { + const { stream, output } = fakeTty() + function App({ open }: { open: boolean }): React.ReactElement { + useAltScreen(open) + return h(Text, null, 'frame') + } + const app = render(h(App, { open: false }), { stdout: stream, ...OPTIONS }) + await settle() + expect(output()).not.toContain('[?1049h') + + app.rerender(h(App, { open: true })) + await settle() + expect(output()).toContain('[?1049h') + + app.rerender(h(App, { open: false })) + await settle() + expect(output()).toContain('[?1049l') + app.unmount() + }) + + it('writes nothing to a non-TTY stdout — the headless --no-input follow', async () => { + const stream = new PassThrough() as unknown as NodeJS.WriteStream + let out = '' + stream.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + function App(): React.ReactElement { + useAltScreen(true) + return h(Text, null, 'frame') + } + const app = render(h(App), { stdout: stream, ...OPTIONS }) + await settle() + app.unmount() + expect(out).not.toContain('[?1049') + }) + + it('restores the primary buffer when the app exits while open', async () => { + // Quitting from inside the browser must not strand the shell on the alt + // screen. + const { stream, output } = fakeTty() + function App(): React.ReactElement { + useAltScreen(true) + const { exit } = useApp() + useEffect(() => { + const t = setTimeout(() => exit(), 5) + return () => clearTimeout(t) + }, [exit]) + return h(Text, null, 'frame') + } + const app = render(h(App), { stdout: stream, ...OPTIONS }) + await settle() + app.unmount() + await settle() + expect(output()).toContain('[?1049l') + }) +}) diff --git a/test/sessions.test.ts b/test/sessions.test.ts index 6f7a09f..e47d788 100644 --- a/test/sessions.test.ts +++ b/test/sessions.test.ts @@ -199,7 +199,6 @@ describe('sessionSource', () => { describe('sessionBarQuery', () => { const bar = { - rows: 5, days: 7, repo: 'cwd' as const, statuses: 'all' as const, @@ -250,8 +249,8 @@ describe('sessionBarQuery', () => { ).toBeUndefined() }) - it('fetches at least as many rows as the bar displays', () => { - expect(sessionBarQuery({ ...bar, rows: 200 }, context).limit).toBe(200) + it('fetches a page deep enough to band and scroll', () => { + expect(sessionBarQuery(bar, context).limit).toBe(SESSION_BAR_FETCH) }) }) diff --git a/test/theme.test.ts b/test/theme.test.ts index f0fa567..f621011 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -1,52 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Chalk } from 'chalk' -import { surfaceFor, theme } from '../src/lib/theme' - -// The 256-color index chalk resolves a hex to, which is what ink paints with -// on a terminal that does 256 colors but not truecolor. -const ansi256 = (hex: string): number => { - const match = new Chalk({ level: 2 }).bgHex(hex)('x').match(/48;5;(\d+)/) - return Number(match?.[1]) -} - -describe('surfaceFor', () => { - it('leaves the authored warmth alone on a truecolor terminal', () => { - expect(surfaceFor('#1c1b1a', 3)).toBe('#1c1b1a') - expect(surfaceFor('#262523', 3)).toBe('#262523') - }) - - it('neutralizes the channels below truecolor', () => { - expect(surfaceFor('#1c1b1a', 2)).toBe('#1b1b1b') - expect(surfaceFor('#262523', 2)).toBe('#252525') - expect(surfaceFor('#343330', 2)).toBe('#323232') - expect(surfaceFor('#1c1b1a', 1)).toBe('#1b1b1b') - }) - - it('passes a malformed hex through rather than painting garbage', () => { - expect(surfaceFor('nonsense', 2)).toBe('nonsense') - }) - - // The bug this exists for: chalk sends any hex whose channels differ to the - // 6x6x6 color cube, whose darkest step above black is rgb(95,95,95). All - // three warm brand surfaces landed on that ONE index, so the near-black - // canvas painted mid grey and every surface step (and with it every "you are - // here" highlight) disappeared on a 256-color terminal. - it('keeps the three surfaces three distinct steps on a 256-color terminal', () => { - const authored = [ansi256('#1c1b1a'), ansi256('#262523'), ansi256('#343330')] - expect(new Set(authored).size).toBe(1) - expect(authored[0]).toBe(59) - - const painted = [ - ansi256(surfaceFor('#1c1b1a', 2)), - ansi256(surfaceFor('#262523', 2)), - ansi256(surfaceFor('#343330', 2)), - ] - expect(new Set(painted).size).toBe(3) - // On the greyscale ramp (232-255), where a near-black stays near-black. - expect(painted.every((index) => index >= 232)).toBe(true) - expect(painted).toEqual([...painted].sort((a, b) => a - b)) - }) -}) +import { theme } from '../src/lib/theme' describe('theme', () => { it('carries a color for every token, so no call site has to fall back', () => { @@ -54,4 +7,14 @@ describe('theme', () => { expect(value, name).toMatch(/^#[0-9a-f]{6}$/) } }) + + // The palette paints FOREGROUNDS only. Backgrounds are the terminal's, because + // transcript rows print into its scrollback and are never repainted — a fill + // there outlives the frame that drew it. No surface tokens means no call site + // can reintroduce one by reaching for a plausible name. + it('holds no surface colors', () => { + for (const name of Object.keys(theme)) { + expect(name).not.toMatch(/canvas|panel|surface|background/i) + } + }) })