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
90 changes: 90 additions & 0 deletions ui-tui/src/__tests__/episodeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { Episode, EpisodeTool, Msg } from '../types.js'
import { $directChat, viewKeyOf } from '../app/directChatStore.js'
import { resetFolds, toggleFold } from '../app/foldStore.js'
import { EpisodeMessage, EpisodeView, turnFoldScope } from '../components/episodeView.js'
import { MESSAGE_FOLD_KEEP_OPEN_CHARS } from '../config/limits.js'
import { foldDirectTurns } from '../domain/directEpisodes.js'
import { TOOL_PREVIEW_ROWS } from '../domain/episodeSummary.js'
import { TRANSCRIPT_GUTTER_INSET, transcriptGutterWidth } from '../lib/inputMetrics.js'
Expand Down Expand Up @@ -756,3 +757,92 @@ describe('the reasoning row once the model has stopped', () => {
expect(settled({ reasoningMs: 12_000 })).not.toContain('weighing the two shapes')
})
})

/* Three defaults the transcript's reader asked for: an agent message of two or
more rendered lines keeps the work it introduced open, a delegation stays
visible while the turn is still running, and a trace box does not print a
line of the model's reasoning unasked. */
describe('what the transcript folds by default', () => {
const LONG = 'x'.repeat(MESSAGE_FOLD_KEEP_OPEN_CHARS)

it('keeps the work open when the message that introduced it runs to two lines', () => {
const episodes: Episode[] = [
{ index: 0, narration: LONG, reasoning: '', tools: [] },
{ index: 1, narration: '', reasoning: '', tools: [call('a', 'exec', 'ls'), call('b', 'exec', 'pwd')] }
]

const f = view(episodes)

// Both calls on their own rows under the summary, rather than the summary
// alone standing in for them.
expect(f).toContain('ls')
expect(f).toContain('pwd')
})

it('still folds the work under a message shorter than that', () => {
const episodes: Episode[] = [
{ index: 0, narration: 'x'.repeat(MESSAGE_FOLD_KEEP_OPEN_CHARS - 1), reasoning: '', tools: [] },
{ index: 1, narration: '', reasoning: '', tools: [call('a', 'exec', 'ls'), call('b', 'exec', 'pwd')] }
]

const f = view(episodes)

expect(f).toContain('ran 2 commands')
})

it('lets the reader fold the work under a long message by hand', () => {
/* The message rule picks the untouched default; a reader's close is
`foldStore`'s and outranks it. Driven through `toggleFold` with the same
default the row's own click passes, not through a seeded key. */
resetFolds()
toggleFold('', 'seg:a', true)
const episodes: Episode[] = [
{ index: 0, narration: LONG, reasoning: '', tools: [] },
{ index: 1, narration: '', reasoning: '', tools: [call('a', 'exec', 'ls'), call('b', 'exec', 'pwd')] }
]

const f = view(episodes)

expect(f).toContain('ran 2 commands')
expect(f).not.toContain('pwd')
resetFolds()
})

it('keeps every delegation in a running stretch, not only the latest call', () => {
// `read_file` after the two graphs leaves both dag calls behind the call in
// hand. The folded live view used to render a panel for the latest tool
// only, so the earlier delegations disappeared while the turn ran.
const second = { ...DAG_RUN, runId: 'dag-2' }
const episodes: Episode[] = [
{
index: 0,
narration: '',
reasoning: '',
tools: [
call('s1', 'run_subagent_dag', 'first graph', { dag: DAG_RUN }),
call('s2', 'run_subagent_dag', 'second graph', { dag: second }),
call('t1', 'read_file', 'notes.md', { done: false })
]
}
]

const f = view(episodes, { live: true })

expect(f).toContain('dag-1')
expect(f).toContain('dag-2')
})

it('does not print a line of reasoning in a trace box unless it is opened', () => {
const THOUGHT = 'the model weighs the two options and picks the safer one'
const episodes: Episode[] = [{ index: 0, narration: '', reasoning: THOUGHT, tools: [] }]

// Dense is the trace-box rendering. Folded, the row names the thought and
// nothing else; the text is behind the row's own toggle.
const folded = view(episodes, { dense: true })
expect(folded).toContain('thought')
expect(folded).not.toContain(THOUGHT)

const open = view(episodes, { dense: true, openKeys: ['rsn:0'] })
expect(open).toContain(THOUGHT)
})
})
44 changes: 43 additions & 1 deletion ui-tui/src/__tests__/virtualHeights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { $directChat, viewKeyOf } from '../app/directChatStore.js'
import { $folds, callFolds, resetFolds, toggleFold } from '../app/foldStore.js'
import { turnFoldScope } from '../components/episodeView.js'
import { MessageLine } from '../components/messageLine.js'
import { DAG_TRACE_BOX_ROWS } from '../config/limits.js'
import { DAG_TRACE_BOX_ROWS, MESSAGE_FOLD_KEEP_OPEN_CHARS } from '../config/limits.js'
import { layoutDagGraph } from '../lib/dagGraphLayout.js'
import { dagNodeKey } from '../lib/dagOpenNodes.js'
import { estimatedMsgHeight, messageHeightKey, wrappedLines } from '../lib/virtualHeights.js'
Expand Down Expand Up @@ -376,3 +376,45 @@ describe('estimatedMsgHeight covers an open tool card', () => {
)
})
})

// A message of two or more rendered lines keeps its stretch open by default,
// and the reader can still shut it. Both states have to measure the same rows
// the renderer draws, or the virtualizer reserves the wrong height.
describe('estimatedMsgHeight follows the message rule', () => {
const scope = turnFoldScope(viewKeyOf($directChat.get().active), 't1')
const msg = (narration: string): Msg => ({
episodes: [
{ index: 0, narration, reasoning: '', tools: [] },
{
index: 1,
narration: '',
reasoning: '',
tools: [
{ done: true, id: 'a', name: 'exec', ok: true, summary: 'ls' },
{ done: true, id: 'b', name: 'exec', ok: true, summary: 'pwd' }
]
}
],
foldId: 't1',
kind: 'episodes',
role: 'assistant',
text: ''
})
const estimate = (m: Msg) =>
estimatedMsgHeight(m, 92, { ...BASE, cardFolds: callFolds($folds.get(), scope) })

beforeEach(resetFolds)

it('reserves the open rows, then the folded row once the reader shuts it', async () => {
const m = msg('x'.repeat(MESSAGE_FOLD_KEEP_OPEN_CHARS))

const open = estimate(m)
expect(open).toBeGreaterThanOrEqual(await renderedRows(m, 92))

toggleFold(scope, 'seg:a', true)

const closed = estimate(m)
expect(closed).toBeGreaterThanOrEqual(await renderedRows(m, 92))
expect(closed).toBeLessThan(open)
})
})
61 changes: 44 additions & 17 deletions ui-tui/src/components/episodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
cardDefaultOpen,
detailBlockShape,
failureNote,
keepsWorkOpen,
segmentTurn,
toolArgument,
toolParts,
Expand Down Expand Up @@ -47,6 +48,10 @@ const INDENT = transcriptGutterWidth('assistant', '')
const STEP = 2
const MIN_TAIL = 12

/* A call whose result is a panel. Its title box is the only row that names it,
so every rule that keeps a delegation visible reads this one. */
const isPanelTool = (tool: EpisodeTool) => Boolean(tool.dag || tool.spawn)

// The part of a click a fold control needs. Declared structurally rather than
// imported: the fork does not export its ClickEvent, and appLayout.tsx already
// names its own slice of the same event this way.
Expand Down Expand Up @@ -533,7 +538,7 @@ const WorkSegment = memo(function WorkSegment({
// open: a call with no argument and no output renders no block, and a blank row
// above nothing is a one-line band spending two rows.
const callRow = (tool: EpisodeTool, depth: number, onToggle: () => void, opened = false) => {
if (tool.dag || tool.spawn) {
if (isPanelTool(tool)) {
return null
}

Expand Down Expand Up @@ -622,14 +627,16 @@ const WorkSegment = memo(function WorkSegment({
)
}

// ── Running: the summary so far, plus only the call in hand ──
// ── Running: the summary so far, the call in hand, and every delegation ──
//
// Quiet by default and openable all the same. What a reader wants from a call
// is most urgent while it runs -- the command a two-minute `exec` is actually
// running is a question that cannot wait for it to finish -- so the rows here
// carry the same toggles the settled ones do. Only the default differs: the
// stretch stays compressed to the summary and the call in hand until the
// reader opens it (see `detailDefaultOpen`, which does not fire in flight).
// stretch stays compressed to the summary, the call in hand and a panel per
// delegation until the reader opens it (see `detailDefaultOpen`, which does
// not fire in flight). A delegation gets its panel at this depth because the
// panel is the only row that names it.
if (inFlight) {
const latest = [...tools].reverse().find(tool => !tool.done) ?? tools[tools.length - 1]!
const done = totalDurationMs(tools.filter(tool => tool.done))
Expand Down Expand Up @@ -693,8 +700,16 @@ const WorkSegment = memo(function WorkSegment({
width={width}
/>
)}
{dagFor(latest, INDENT + STEP)}
{spawnFor(latest, INDENT + STEP)}
{/* Every delegation in the stretch, not only the one in hand: a
spawn or dag panel is the only place the call is named, so a
later plain call must not fold an earlier delegation out of
sight while the turn runs. */}
{tools.filter(isPanelTool).map(tool => (
<Box flexDirection="column" key={tool.id}>
{dagFor(tool, INDENT + STEP)}
{spawnFor(tool, INDENT + STEP)}
</Box>
))}
{cardOpen(latest) ? detailFor(latest, INDENT + STEP, () => toggleCall(latest.id)) : null}
</>
)}
Expand Down Expand Up @@ -774,9 +789,10 @@ export const EpisodeView = memo(function EpisodeView({
closedKeys?: readonly string[]
cols?: number
compact?: boolean
/** Trace-box rendering: no breathing rows between segments, and a static
* reasoning row carries a tail of its text instead of standing bare. The
* height estimator's own `dense` must agree (see `estimatedMsgHeight`). */
/** Trace-box rendering: no breathing rows between segments. A folded
* reasoning row stays a bare label here -- the reader opened the box to
* watch the run, not to read the scratch work. The height estimator's own
* `dense` must agree (see `estimatedMsgHeight`). */
dense?: boolean
episodes: Episode[]
live?: boolean
Expand Down Expand Up @@ -877,9 +893,13 @@ export const EpisodeView = memo(function EpisodeView({
onToggle={() => toggle(`rsn:${ep.index}`)}
running={thinking}
t={t}
// In a trace box the row is static, so without the tail it reads
// as a bare label -- the one word "reasoning" says nothing.
tail={(thinking || dense) && !rsnOpen ? reasoning : ''}
// Only the live row shows a tail. A trace box renders a settled
// run, where the reader asked to watch the run, not to read the
// model's scratch work: the row names the thought and how long it
// took, and its text is one click away. Printing a line of it
// there is the reasoning expanded by default, which is exactly
// what this view must not do.
tail={thinking && !rsnOpen ? reasoning : ''}
time={durationLabel(reasoningMs, Boolean(thinking))}
width={width}
/>
Expand Down Expand Up @@ -909,11 +929,11 @@ export const EpisodeView = memo(function EpisodeView({
)
}

const renderWork = (seg: Extract<Segment, { kind: 'work' }>) => {
const renderWork = (seg: Extract<Segment, { kind: 'work' }>, keepOpen: boolean) => {
// The tools whose result is a panel: a summary line cannot say which node
// failed or what a delegated run is doing, so a stretch holding one opens
// itself, and folding it by hand still leaves the panel.
const hasPanel = seg.tools.some(tool => tool.dag || tool.spawn)
const hasPanel = seg.tools.some(isPanelTool)
// A solo stretch has no "expanded" depth of its own: its fold state gates the
// detail block directly (WorkSegment's solo branch above), so for one call
// this state IS the card's own fold, and the card's predicate is what answers
Expand All @@ -933,16 +953,23 @@ export const EpisodeView = memo(function EpisodeView({
// unfolds itself as the calls land is the churn the compressed live view
// exists to avoid. A reader's own open still stands -- `foldOpen` reads it
// first.
//
// `keepOpen` is the message rule: a multi-call stretch introduced by an
// agent message of two or more rendered lines opens on its own, so the rows
// the message led to are readable beside it (see `keepsWorkOpen`). A solo
// call is already one visible row, so the rule leaves its detail block to
// the card's own predicate (`detailDefaultOpen`): a call with content opens
// its detail, one without a block opens nothing.
const detailDefaultOpen =
seg.tools.length === 1
? !hasPanel && Boolean(seg.tools[0]!.done) && cardDefaultOpen(seg.tools[0]!)
: hasPanel && !seg.live
: (hasPanel || keepOpen) && !seg.live

return (
<WorkSegment
closedCalls={closedCalls}
compact={compact}
defaultOpen={hasPanel}
defaultOpen={hasPanel || keepOpen}
isOpen={foldOpen(`seg:${seg.key}`, detailDefaultOpen)}
key={`w:${seg.key}`}
live={seg.live}
Expand Down Expand Up @@ -976,7 +1003,7 @@ export const EpisodeView = memo(function EpisodeView({
key={seg.kind === 'talk' ? `t:${seg.episode.index}` : `w:${seg.key}`}
marginTop={i > 0 && !dense ? 1 : 0}
>
{seg.kind === 'talk' ? renderTalk(seg.episode) : renderWork(seg)}
{seg.kind === 'talk' ? renderTalk(seg.episode) : renderWork(seg, keepsWorkOpen(segments, i))}
</Box>
))}

Expand Down
8 changes: 8 additions & 0 deletions ui-tui/src/config/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export const LONG_MSG = 300
export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160

// An agent message this long keeps the work it introduced open rather than
// folding into a one-line summary. A character count, not measured rows: the
// fold decision is made outside a layout pass and the renderer only has the
// string. 100 is about two rows in an ordinary 100-column terminal body
// (~50 double-width glyphs per row), which is the reading measure a message of
// "two lines" is judged by here.
export const MESSAGE_FOLD_KEEP_OPEN_CHARS = 100

// Rows per wheel event (pre-accel). 1 keeps Ink's DECSTBM fast path live
// (each scroll < viewport-1) and produces smooth motion. wheelAccel.ts
// ramps this on sustained scrolls.
Expand Down
18 changes: 18 additions & 0 deletions ui-tui/src/domain/episodeSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import type { Episode, EpisodeTool } from '../types.js'

import { MESSAGE_FOLD_KEEP_OPEN_CHARS } from '../config/limits.js'
import { hasMeaningfulReasoning } from '../lib/reasoning.js'
import { claudeRule } from './claudeCodeTools.js'
import { codexRule } from './codexTools.js'
Expand Down Expand Up @@ -609,3 +610,20 @@ export const segmentTurn = (episodes: Episode[], liveIndex?: number): Segment[]

return segments
}

/**
* Whether the work segment at `index` follows an agent message long enough
* that the work should not be folded away from it.
*
* The transcript folds "every call between two things the model said" into one
* summary row. That is a compression the reader can undo, except when the
* message introducing the stretch runs to two or more rendered lines: the
* message is the part worth reading, and the calls it led to are what makes it
* legible. Judged by character count rather than measured rows -- see
* `MESSAGE_FOLD_KEEP_OPEN_CHARS`.
*/
export const keepsWorkOpen = (segments: Segment[], index: number): boolean => {
const prev = segments[index - 1]

return prev?.kind === 'talk' && (prev.episode.narration ?? '').trim().length >= MESSAGE_FOLD_KEEP_OPEN_CHARS
}
12 changes: 8 additions & 4 deletions ui-tui/src/lib/virtualHeights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { stringWidth } from '@hermes/ink'
import type { EpisodeTool, Msg } from '../types.js'

import { EMPTY_CALL_FOLDS, type CallFolds } from '../app/foldStore.js'
import { cardDefaultOpen, detailBlockShape, foldedPreviewRows, segmentTurn } from '../domain/episodeSummary.js'
import { cardDefaultOpen, detailBlockShape, foldedPreviewRows, keepsWorkOpen, segmentTurn } from '../domain/episodeSummary.js'
import { layoutDagGraph } from './dagGraphLayout.js'
import { dagNodeToggleKey } from './dagOpenNodes.js'
import { dagDetailRoom, dagRowDetail, dagTraceBoxRows, dagTraceRows, hasDagRowDetail } from './dagStatus.js'
Expand Down Expand Up @@ -255,7 +255,9 @@ export const estimatedMsgHeight = (
// symptom. A stretch of work is no longer one row by construction: a settled
// card opens on its own predicate, so its block is measured here the same way
// it is drawn, and a talk segment is its reasoning row plus its wrapped prose.
for (const [i, seg] of segmentTurn(msg.episodes ?? []).entries()) {
const segments = segmentTurn(msg.episodes ?? [])

for (const [i, seg] of segments.entries()) {
h += i > 0 && !dense ? 1 : 0

if (seg.kind === 'work') {
Expand Down Expand Up @@ -301,8 +303,10 @@ export const estimatedMsgHeight = (
h += 1

// Folded, a multi-call stretch is its summary row; the panels inside it
// still draw (WorkSegment's own defaultOpen branch).
if (!segIsOpen(seg.key, hasPanel && !seg.live)) {
// still draw (WorkSegment's own defaultOpen branch). The message rule
// (see `keepsWorkOpen`) opens the stretch by default, and the estimate
// has to agree or the virtualizer reserves the folded height.
if (!segIsOpen(seg.key, (hasPanel || keepsWorkOpen(segments, i)) && !seg.live)) {
for (const tool of panelTools) {
h += panelRows(tool, Math.max(28, panelWidth - (INDENT + STEP)))
}
Expand Down
Loading
Loading