Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import type { AgentSession, SupportedModel } from './types'
// Pure session-model helpers shared by the connect command and the
// multi-session UI (SessionsApp). No I/O here — everything is testable.

// THE selection marker, everywhere: the one bone-bright character that says
// "you are here" — it replaces a sidebar row's status dot, a transcript
// line's gutter icon, and the focused composer's prompt. One char, always the
// accent, so the eye finds the cursor instantly anywhere in the console. The
// thick right-arrow is reserved for selection alone; statuses are colored dots.
// THE selection marker, everywhere: the one character that says "you are here"
// — it replaces a sidebar row's status dot, a transcript line's gutter icon,
// and the focused composer's prompt. Always painted theme.cursor, the cyan that
// means nothing else, so the eye finds it instantly anywhere in the console.
// The thick right-arrow is reserved for selection alone; statuses are dots.
export const SELECTION_GLYPH = '▶'

// Whether the composer can send to this session, and — when it can't — why.
Expand Down
12 changes: 10 additions & 2 deletions src/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import chalk from 'chalk'
//
// One rule carried over from the web app (landing globals.css `.dark`): the
// accent in dark mode is BONE, not brand blue. Brand ink #175173 scores
// 1.79:1 on the panel — unreadable as terminal text. So "you are here" is
// carried by brightness (bone against stone), not by hue.
// 1.79:1 on the panel — unreadable as terminal text. So emphasis is carried by
// brightness (bone against stone), not by hue. The ▶ cursor is the one
// exception, and takes `cursor` below.
//
// Because the CLI paints its own canvas, the palette only holds if it is used
// for EVERY cell of the frame. Two rules keep it whole on a terminal whose own
Expand Down Expand Up @@ -86,6 +87,13 @@ export const theme = {
foreground: '#f0efe9',
muted: '#a8a59c',

// The ▶ cursor, and nothing else. Bone-on-stone was too quiet a step to find
// at a glance on a busy frame, so the cursor carries HUE as well as
// brightness: cyan is the one hue not already spoken for (green = done,
// amber = working, red = failed), so it never reads as a status. 9.7:1 on
// the canvas and 7.2:1 on the active surface, so it holds up highlighted.
cursor: '#5fd3e0',

// Status. Tuned for the charcoal canvas, not the light one.
success: '#4ebc7b',
error: '#e5544b',
Expand Down
143 changes: 78 additions & 65 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
contentWidth,
entryRange,
GUTTER_COLS,
isAgentSpeech,
isCollapsible,
isToolActivity,
itemRows,
Expand Down Expand Up @@ -671,9 +672,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {

// The rendered transcript lines, in order: collapsed (the default) folds
// consecutive tool activity into "Ran N …" notices, except the runs under a
// MESSAGE opened in place with → (openedKeys), which render their tool calls
// right below the fold lineindented one level (2 columns), so the
// expansion reads as the fold's children — and ← closes them again. The
// MESSAGE opened in place with → (openedKeys), where the fold is REPLACED by
// the calls it stood for"Ran 2 shell commands" above the two commands is
// just a stale count of what you can already see — and ← folds them back. The
// message is what opens, not the fold: a run of tool calls is work that
// message did, so it is reached by opening the message (see layOutItems).
// Expanded (ctrl+r) shows everything, flat.
Expand All @@ -687,14 +688,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// The message a fold hangs off: opening THAT is what reveals the run.
let parent: string | null = null
for (const item of folded) {
out.push(item)
if (!isToolActivity(item)) {
out.push(item)
parent = item.key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visible still treats any non-tool item as a fold's parent while layOutItems restricts parenthood to assistant messages, so opening a long user message silently unfolds the tool run below it.

A user turn over COLLAPSE_LINES is collapsible, so → adds its key to openedKeys (ConnectApp.tsx:1142) just to un-clamp the text. Here parent is that user key, so the following grp: fold is replaced by foldRun(...) — the "Ran N shell commands" line vanishes and the raw ●/⎿ lines appear at indent 0 (layOutItems keeps them flat and un-nested, since its own parent is null). Confirmed by replaying this memo over [user(long), tool, tool_result] with openedKeys={'u'}.

Suggested change
parent = item.key
parent = item.kind === 'assistant' ? item.key : null

continue
}
if (item.key.startsWith('grp:') && parent !== null && openedKeys.has(parent)) {
out.push(...foldRun(item.key, base))
}
const open = item.key.startsWith('grp:') && parent !== null && openedKeys.has(parent)
if (open) out.push(...foldRun(item.key, base))
else out.push(item)
}
return out
}, [items, expanded, pendingTools, openedKeys])
Expand Down Expand Up @@ -791,7 +792,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
text: liveText,
label: 'Generating…',
tick: 'elapsed' as const,
suffix: liveTokens != null ? `· ↓ ${formatTokens(liveTokens)} tokens` : '',
suffix: liveTokens != null ? `${formatTokens(liveTokens)} tokens` : '',
// The ⏺ line sits directly under the prose it describes.
hug: liveText !== '',
nested: false,
Expand All @@ -802,9 +803,19 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
pendingTools.length === 1
? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…`
: `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…`
// A running tool call nests under the message that made it, in the
// same place its ⎿ result will land.
return { text: '', label, tick: 'tool' as const, suffix: '', hug, nested: true }
// A running tool call nests under the message that made it, in the same
// place its ⎿ result will land — so it takes the SAME predicate
// layOutItems uses for that result. Any disagreement here shows up as the
// live line sitting flat and then jumping a level when the result lands.
const said = visible.filter((i) => !isToolActivity(i)).pop()
return {
text: '',
label,
tick: 'tool' as const,
suffix: '',
hug,
nested: said != null && isAgentSpeech(said),
}
}
if (working && !infraActivity && (awaitingAgent !== null || sendPending)) {
return {
Expand Down Expand Up @@ -878,7 +889,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
// full-colour ◆ rows ABOVE the live activity — the running turn is the
// response to THIS message, so its stream belongs below it.
for (const q of inFlightSends.filter((q) => q.state === 'accepted')) {
out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true }))
out.push(...pendingMessageRows(q.key, q.text, cols, { gutter: '◆', bold: true, panel: true }))
}
if (liveTail.text) {
out.push(...pendingMessageRows('live', liveTail.text, cols, { gutter: '' }))
Expand Down Expand Up @@ -906,6 +917,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
dim: true,
right: 'queued',
pulse: true,
panel: true,
}),
)
}
Expand All @@ -920,6 +932,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
dim: true,
right: q.state === 'sending' ? 'sending' : q.state === 'queued' ? 'queued' : 'cancelled',
pulse: waiting,
panel: true,
}),
)
}
Expand Down Expand Up @@ -967,14 +980,21 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
return rowViewport(allRows.length, viewBudget, anchor)
}, [allRows, viewBudget, scrollAnchor])

// The one row that wears the ▶ marker: the highlighted block's FIRST row with
// a gutter glyph. The whole block tints, but the marker points at a single
// line — a block with nested tool activity has a glyph on the call and on its
// ⎿ result, and marking both reads as two separate selections.
// The one row that wears the ▶ marker: the selected block's FIRST row with a
// gutter glyph, since the marker replaces that glyph in place. Only one row
// takes it — a block with nested tool activity has a glyph on the call and on
// its ⎿ result, and marking both reads as two separate selections.
//
// Restricted to rows ON SCREEN, because the marker is now the ONLY thing that
// says "you are here" (there is no highlight bar any more). A block taller
// than the window is bottom-aligned by the ↑ snap, which puts its first row
// above the frame — so the marker falls to the topmost visible row of the
// block, and the selection stays legible instead of vanishing.
const markerRowId = useMemo(() => {
if (navKey === null) return null
return allRows.find((r) => navKeyOf(r) === navKey && r.gutter)?.id ?? null
}, [allRows, navKey])
const onScreen = allRows.slice(view.start, view.end).filter((r) => navKeyOf(r) === navKey)
return (onScreen.find((r) => r.gutter) ?? onScreen.find((r) => !r.spacer))?.id ?? null
}, [allRows, navKey, view.start, view.end])

// Move the window by `delta` ROWS. Reaching the last row re-pins it to the
// bottom, so streamed content follows again.
Expand Down Expand Up @@ -1297,13 +1317,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
key={row.id}
row={row}
cols={cols}
// The whole block lifts, tool rows included — the highlight is what
// says "this message and the work it did". A panel's pad rows
// (spacer + panel) lift with it; canvas spacers between blocks
// never highlight.
selected={
navKey !== null && navKeyOf(row) === navKey && (!row.spacer || row.panel === true)
}
// Every row of the selected BLOCK, tool rows included: the ▶ marks
// one of them, and the rest read "→ to expand" on their clamp hint.
selected={navKey !== null && navKeyOf(row) === navKey}
marker={row.id === markerRowId}
// Both ticking values are passed as constants to rows that don't
// use them, so React.memo skips those rows entirely: the
Expand Down Expand Up @@ -1378,7 +1394,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
key={`${composer.text}:${composer.cursor}:${focused && navKey === null}`}
color={theme.foreground}
>
<Text color={focused && navKey === null ? theme.foreground : theme.muted}>
<Text color={focused && navKey === null ? theme.cursor : theme.muted}>
{SELECTION_GLYPH}{' '}
</Text>
{composer.text.slice(0, composer.cursor)}
Expand Down Expand Up @@ -1462,12 +1478,12 @@ function sandboxRows(o: {
const { sandbox, infraActivity, settled, cols } = o
const key = 'sandbox'
const rows: TranscriptRow[] = []
const width = contentWidth(cols, { panel: true })
// Every row of the block sits on the panel and reserves the standard gutter,
// so the ▶ marker lands in the headline's mark slot when the block is
// highlighted — the same treatment every other entry gets.
const width = contentWidth(cols)
// Every row of the block reserves the standard gutter, so the ▶ marker lands
// in the headline's mark slot when the block is highlighted — the same
// treatment every other entry gets.
const line = (spans: RowSpan[], extra: Partial<TranscriptRow> = {}): void => {
rows.push({ id: `${key}:r${rows.length}`, entryKey: key, panel: true, spans, ...extra })
rows.push({ id: `${key}:r${rows.length}`, entryKey: key, spans, ...extra })
}
// The conversation's opening line: where it lives. Plain text — an OSC 8
// hyperlink here gets broken by ink's wrapping and swallows the label; the
Expand Down Expand Up @@ -1840,8 +1856,8 @@ export function deliveredUnechoedSends(
// scales down with size: under 1s reads as milliseconds ("428ms"), under 5s
// keeps one decimal ("1.2s", trimming a trailing .0), and everything longer
// reads as whole h/m/s components with zero parts dropped ("10s", "1m 2s",
// "2m", "1h 3m 30s"). The one duration format everywhere in the app, always
// shown parenthesized: "(10s)". Pure, for tests.
// "2m", "1h 3m 30s"). The one duration format everywhere in the app, and it
// reads bare — a readout, not a parenthetical aside. Pure, for tests.
export function humanDuration(seconds: number): string {
const clamped = Math.max(0, seconds)
if (clamped === 0) return '0s'
Expand Down Expand Up @@ -1986,14 +2002,13 @@ export function deriveSandboxState(
if (p.status === 'completed' || p.status === 'failed') {
const detail =
p.detail && typeof p.detail === 'object' ? (p.detail as Record<string, unknown>) : {}
// "full build (2s)", "(42s)", or a bare tier — the duration always
// parenthesized (the app-wide duration format).
// "Preparing image, full build, 2s" — the label then its readout,
// comma-separated like every other metadata line in the app.
const tier = cacheTierLabel(detail.cache_tier)
const dur = msLabel(p.duration_ms)
const note = [...(tier ? [tier] : []), ...(dur ? [`(${dur})`] : [])].join(' ')
const failed = p.status === 'failed'
const base = failed ? `${label} failed` : label
const text = note ? (note.startsWith('(') ? `${base} ${note}` : `${base} · ${note}`) : base
const text = [base, ...(tier ? [tier] : []), ...(dur ? [dur] : [])].join(', ')
const line = open.get(key)
if (line) {
// Close the line this phase opened, in place: one line per phase,
Expand Down Expand Up @@ -2031,8 +2046,11 @@ export function deriveSandboxState(
push(
record,
'done',
['Sandbox ready', ...(tier ? [tier] : [])].join(' · ') +
(totalSeconds > 0 ? ` (${humanDuration(totalSeconds)})` : ''),
[
'Sandbox ready',
...(tier ? [tier] : []),
...(totalSeconds > 0 ? [humanDuration(totalSeconds)] : []),
].join(', '),
)
sandboxDone = true
// The box coming up is the session-level outcome too.
Expand Down Expand Up @@ -2064,10 +2082,10 @@ export function deriveSandboxState(
// belt-and-braces guarantee — a row that wrapped would push every row below it
// down and slide the window out of sync with the scroll position.
//
// The selected row steps onto the lighter active surface, the app-wide "you are
// here" treatment (the focused composer, sidebar rows, dropdown options all
// match). Never inverse: a bone-white bar is far too loud on the charcoal
// canvas.
// Selection is carried by the cyan ▶ in the gutter and NOTHING else: no tint,
// no recolored text. A highlight bar across a multi-row block is a lot of paint
// for "you are here", and it fought with the one panel tint that still means
// something (a message you sent). The marker is one glyph and unmistakable.
const RowLine = React.memo(function RowLine({
row,
cols,
Expand All @@ -2078,10 +2096,12 @@ const RowLine = React.memo(function RowLine({
}: {
row: TranscriptRow
cols: number
// This row belongs to the selected BLOCK. It changes nothing visually — only
// which key the "+N lines" hint names (→ vs ctrl+r).
selected: boolean
// Whether THIS row carries the ▶ selection marker in its gutter. Every row of
// the highlighted block is `selected` (they all tint), but only one is the
// marker row — see markerRowId.
// Whether THIS row carries the ▶ marker in its gutter. Every row of the
// selected block is `selected`, but only one is the marker row — see
// markerRowId.
marker: boolean
// The row's ticking duration, resolved here so the once-a-second tick
// repaints this line instead of rebuilding the transcript's rows.
Expand All @@ -2090,11 +2110,7 @@ const RowLine = React.memo(function RowLine({
// the blink repaints the live lines and leaves the rest of the window alone.
pulseOn: boolean
}): React.ReactElement {
const background = selected || row.activeRow
? SURFACE_ACTIVE
: row.panel
? SURFACE_ELEVATED
: undefined
const background = row.panel ? SURFACE_ELEVATED : undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the tint gone, a block taller than the chat window shows no cursor at all: the only row that can carry ▶ is scrolled off-frame.

markerRowId is the block's FIRST row with a gutter, and ↑ onto a too-tall entry calls ensureVisible(key, -1) → snapToEntry bottom-aligns it, so that first row sits above view.start. Assistant prose isn't collapsible, so there is no "+N lines (→ to expand)" hint on screen either — nothing on the frame indicates which block is selected until several more ↑ presses scroll its top back into view.

// The "+N lines" marker's hint names the key that actually opens it: → when
// the line is highlighted, ctrl+r otherwise.
const spans: RowSpan[] = row.clampedLines
Expand All @@ -2110,9 +2126,10 @@ const RowLine = React.memo(function RowLine({
// span with no colour of its own onto real hexes, so nothing on the row is
// left to the terminal's own palette. See its comment in transcriptRows.
const markColor = (span: RowSpan): string => spanColor(span, pulseOn)
// Durations always render parenthesized, in the right-hand metadata column.
// The right-hand metadata column reads as plain prose — "23s, 4 tokens", no
// parentheses and no interpuncts. It is a readout, not an aside.
const right = row.tick
? { text: `(${[humanDuration(seconds), row.right?.text].filter(Boolean).join(' ')})`, dim: true }
? { text: [humanDuration(seconds), row.right?.text].filter(Boolean).join(', '), dim: true }
: row.right
// height=1 is load-bearing: a blank row (a spacer, or a message panel's pad)
// has no text, and ink collapses an empty Box to zero height — the row would
Expand All @@ -2121,7 +2138,7 @@ const RowLine = React.memo(function RowLine({
// width, so the panel reads as a block, not a ragged strip behind the text.
return (
<Box width={cols} height={1} flexShrink={0} backgroundColor={background}>
{row.panel && <Box width={MESSAGE_PAD} flexShrink={0} />}
<Box width={MESSAGE_PAD} flexShrink={0} />
{row.indent ? <Box width={row.indent} flexShrink={0} /> : null}
<Box width={GUTTER_COLS} flexShrink={0}>
{/* The gutter glyph, or the selection marker in its place on the one
Expand All @@ -2131,36 +2148,32 @@ const RowLine = React.memo(function RowLine({
itself never changes, so the column holds still and the eye reads
a heartbeat rather than a character swapping in and out. */}
<Text
color={selected || !row.gutter ? theme.foreground : markColor(row.gutter)}
color={marker ? theme.cursor : row.gutter ? markColor(row.gutter) : theme.foreground}
wrap="truncate"
>
{marker ? SELECTION_GLYPH : (row.gutter?.text ?? '')}
</Text>
</Box>
{/* A ⎿ item's extra breathing room, on every row of it so a wrapped
result stays aligned under its first line. */}
{row.textPad ? <Box width={row.textPad} flexShrink={0} /> : null}
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<Text wrap="truncate">
{spans.map((span, i) => (
<Text
key={i}
color={selected ? theme.foreground : markColor(span)}
bold={span.bold}
>
<Text key={i} color={markColor(span)} bold={span.bold}>
{span.text}
</Text>
))}
</Text>
</Box>
{right && (
<Box flexShrink={0} paddingLeft={1}>
<Text
color={selected ? theme.foreground : markColor(right)}
wrap="truncate"
>
<Text color={markColor(right)} wrap="truncate">
{right.text}
</Text>
</Box>
)}
{row.panel && <Box width={MESSAGE_PAD} flexShrink={0} />}
<Box width={MESSAGE_PAD} flexShrink={0} />
</Box>
)
})
Loading