dsh-live-trace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,1044 @@
1
+ /**
2
+ * The dashboard renderer: view state in, terminal rows out.
3
+ *
4
+ * `renderFrame` is pure — it takes the view model plus a size and returns one
5
+ * ANSI string per terminal row. It never writes, never reads the clock beyond
6
+ * the `now` it is given, and never inspects the terminal, which means the whole
7
+ * layout (including the 80-column contract) is testable without a TTY.
8
+ *
9
+ * Four panels share the same chrome: the chronological trace, the session
10
+ * switcher, the file-change diff, and the command list. The trace renders model
11
+ * prose as Markdown and each tool call as one block with its command, output,
12
+ * and status.
13
+ *
14
+ * @module dsh-live-trace/renderer
15
+ */
16
+
17
+ import { formatClock, formatDuration, formatTokens, shortPath, shortSessionId } from './format.js'
18
+ import { highlightLine } from './highlight.js'
19
+ import { renderMarkdown } from './markdown.js'
20
+ import { statusGlyph, statusStyle } from './theme.js'
21
+ import { DEFAULT_TRANSLATOR, statusText } from './i18n.js'
22
+ import { commandsView, editsView, sessionsView, toolDetailRows } from './views.js'
23
+ import { displayWidth, padRow, padTo, renderRow, rowWidth, sanitize, truncate, truncateSegments, wrapSegments } from './width.js'
24
+ import { commandsOf, usageTotal, VIEWS } from './view-state.js'
25
+
26
+ /** Below this many columns the board cannot say anything useful. */
27
+ export const MIN_COLS = 24
28
+ /** Below this many rows the board cannot show a header, body, and footer. */
29
+ export const MIN_ROWS = 8
30
+
31
+ const HEADER_ROWS = 3
32
+ const FOOTER_ROWS = 3
33
+ const TITLE = 'dsh-live-trace'
34
+ /** Rows one trace entry may occupy before it is cut with a marker. */
35
+ const DEFAULT_MAX_ENTRY_ROWS = 40
36
+ /** Rows one entry may occupy while its thinking block is expanded. */
37
+ const EXPANDED_ENTRY_ROWS = 400
38
+ /** Output lines shown inline in a trace tool block. */
39
+ const TRACE_OUTPUT_LINES = 12
40
+
41
+ /**
42
+ * Whether a tool entry has settled.
43
+ *
44
+ * The merged-block protocol marks this with `phase`. Against an older plugin
45
+ * (whose entries carry no phase) the original protocol is recovered from the
46
+ * tag: `result` was the settled half and `tool` the running one. Without this
47
+ * a stale plugin would render every running call as if it had finished.
48
+ */
49
+ function isSettledEntry(entry) {
50
+ if (entry.phase === 'result') return true
51
+ if (entry.phase === 'call') return false
52
+ return entry.tag === 'result'
53
+ }
54
+
55
+ /** Collapse a multi-line command to one log line. */
56
+ function oneLine(value) {
57
+ return sanitize(value).replace(/\s*\n\s*/g, ' ').trim()
58
+ }
59
+
60
+ /**
61
+ * Render a full frame.
62
+ *
63
+ * @param {import('./view-state.js').ViewState} state
64
+ * @param {object} options
65
+ * @param {number} options.cols
66
+ * @param {number} options.rows
67
+ * @param {import('./theme.js').Theme} options.theme
68
+ * @param {(key: string, params?: object) => string} [options.t] translator
69
+ * @param {number} [options.now]
70
+ * @param {number} [options.frame] spinner frame index
71
+ * @param {number} [options.scrollOffset] trace offset, counted from the bottom
72
+ * @param {number} [options.panelOffset] panel offset, counted from the top
73
+ * @param {'trace' | 'sessions' | 'edits' | 'commands'} [options.view]
74
+ * @param {boolean} [options.sessionsOverlay]
75
+ * @param {boolean} [options.paused]
76
+ * @param {boolean} [options.help]
77
+ * @param {boolean} [options.rawText] disable Markdown rendering
78
+ * @param {boolean} [options.expandThinking] show thinking in full
79
+ * @param {number} [options.maxEntryRows]
80
+ * @param {{ start?: number, bodyRows?: number, headerRows?: number, rowItems?: number[] | null, view?: string }} [out]
81
+ * optional sink for the painted window, so input handling can map a screen
82
+ * row back to the list item it shows
83
+ * @returns {string[]} exactly `rows` ANSI strings
84
+ */
85
+ export function renderFrame(state, options, out) {
86
+ const { cols, rows, theme } = options
87
+ const t = options.t ?? DEFAULT_TRANSLATOR
88
+ const now = options.now ?? Date.now()
89
+ const frame = options.frame ?? 0
90
+
91
+ if (cols < MIN_COLS || rows < MIN_ROWS) {
92
+ return renderTooSmall(cols, rows, theme)
93
+ }
94
+
95
+ const bodyRows = rows - HEADER_ROWS - FOOTER_ROWS
96
+ const contentWidth = cols - 4
97
+ const view = options.sessionsOverlay === true ? 'sessions' : (options.view ?? 'trace')
98
+
99
+
100
+ const traceOffset = view === 'trace' && options.help !== true ? Math.max(0, options.scrollOffset ?? 0) : 0
101
+ // Render only what the window can show, plus the rows scrolled past.
102
+ const budget = bodyRows + traceOffset + 2
103
+
104
+ const panel =
105
+ options.help === true
106
+ ? { rows: helpLines(contentWidth, theme, t), cursorRow: -1, older: false }
107
+ : buildPanel(state, { contentWidth, theme, now, frame, view, options, budget, t })
108
+
109
+ // The trace follows the newest entry from the bottom; the panels are lists
110
+ // that scroll from the top and keep their cursor row on screen.
111
+ const total = panel.rows.length
112
+ const maxTop = Math.max(0, total - bodyRows)
113
+ let start
114
+ if (view === 'trace' && options.help !== true) {
115
+ start = Math.max(0, maxTop - traceOffset)
116
+ } else {
117
+ start = Math.max(0, Math.min(options.panelOffset ?? 0, maxTop))
118
+ }
119
+ if (panel.cursorRow >= 0) {
120
+ if (panel.cursorRow < start) start = panel.cursorRow
121
+ else if (panel.cursorRow >= start + bodyRows) start = panel.cursorRow - bodyRows + 1
122
+ }
123
+ start = Math.max(0, Math.min(start, maxTop))
124
+
125
+ const visible = panel.rows.slice(start, start + bodyRows)
126
+ const hiddenAbove = start
127
+ const hiddenBelow = Math.max(0, total - (start + visible.length))
128
+ // A windowed trace knows rows exist above it without knowing how many.
129
+ const hasOlder = panel.older === true || start > 0
130
+
131
+ const lines = []
132
+ lines.push(renderRow(borderTop(cols, theme, view, t)))
133
+ lines.push(renderRow(frameRow(headerContent(state, contentWidth, theme, now, t), cols, theme)))
134
+ lines.push(renderRow(borderMid(cols, theme)))
135
+ for (let index = 0; index < bodyRows; index += 1) {
136
+ lines.push(renderRow(frameRow(visible[index] ?? [], cols, theme)))
137
+ }
138
+ lines.push(renderRow(borderMid(cols, theme)))
139
+ lines.push(
140
+ renderRow(
141
+ frameRow(
142
+ footerContent(state, contentWidth, theme, now, t, {
143
+ hiddenAbove,
144
+ hiddenBelow,
145
+ hasOlder,
146
+ offset: view === 'trace' ? Math.max(0, options.scrollOffset ?? 0) : start,
147
+ paused: options.paused === true,
148
+ view,
149
+ overlay: options.sessionsOverlay === true
150
+ }),
151
+ cols,
152
+ theme
153
+ )
154
+ )
155
+ )
156
+ lines.push(renderRow(borderBottom(cols, theme)))
157
+ if (out !== undefined) {
158
+ out.start = start
159
+ out.bodyRows = bodyRows
160
+ out.headerRows = HEADER_ROWS
161
+ out.rowItems = panel.rowItems ?? null
162
+ out.view = view
163
+ out.total = total
164
+ }
165
+ return lines
166
+ }
167
+
168
+ /**
169
+ * Line-oriented output for `--plain`, one line per entry.
170
+ * @param {import('./view-state.js').ViewState} state
171
+ * @param {{ cols?: number, t?: Function }} [options]
172
+ * @returns {string[]}
173
+ */
174
+ export function renderPlainLines(state, options = {}) {
175
+ const cols = options.cols ?? 120
176
+ return state.entries.map((entry) => renderPlainEntry(entry, cols, options.t))
177
+ }
178
+
179
+ /**
180
+ * One plain line for a single entry, used by streaming plain mode.
181
+ * @param {object} entry
182
+ * @param {number} [cols]
183
+ * @param {(key: string, params?: object) => string} [t]
184
+ * @returns {string}
185
+ */
186
+ export function renderPlainEntry(entry, cols = 200, t = DEFAULT_TRANSLATOR) {
187
+ const time = formatClock(entry.at ?? Date.now())
188
+ const label = `[${entry.label ?? entry.tag}]`
189
+ const isToolish = entry.tag === 'tool' || entry.tag === 'result'
190
+ const settled = isToolish && isSettledEntry(entry)
191
+ // A running call's text already leads with the tool name; only a settled
192
+ // block needs it prepended.
193
+ const name = settled && entry.tool !== undefined ? `${sanitize(entry.tool)} ` : ''
194
+ const mark = settled ? (entry.ok === false ? '✗ ' : '✓ ') : ''
195
+ const status = settled
196
+ ? entry.shellStatus?.signal !== undefined
197
+ ? ` ${t('commands.signal', { x: sanitize(entry.shellStatus.signal) })}`
198
+ : entry.shell !== undefined
199
+ ? ` ${t('commands.exit', { n: entry.shellStatus?.exitCode ?? 0 })}`
200
+ : ''
201
+ : isToolish
202
+ ? ` ${t('trace.running')}`
203
+ : ''
204
+ const duration = typeof entry.durationMs === 'number' ? ` ${formatDuration(entry.durationMs)}` : ''
205
+ // The command belongs in the log line; the body alone would lose what ran.
206
+ const command = settled && entry.shell !== undefined ? ` $ ${oneLine(entry.shell.command)}` : ''
207
+ const body = sanitize(entry.text ?? '')
208
+ return truncate(`${time} ${label} ${mark}${name}${body}${status}${duration}${command}`, cols, '…')
209
+ }
210
+
211
+ /* ------------------------------------------------------------------ *
212
+ * Chrome
213
+ * ------------------------------------------------------------------ */
214
+
215
+ function borderTop(cols, theme, view, t = DEFAULT_TRANSLATOR) {
216
+ const inner = cols - 2
217
+ const label = view === undefined ? TITLE : `${TITLE} · ${t(`view.${view}`)}`
218
+ const head = `─ ${label} `
219
+ // Measured in cells: a translated panel name may contain wide characters.
220
+ const headWidth = displayWidth(head)
221
+ const fill = headWidth < inner ? head + '─'.repeat(inner - headWidth) : truncate(head, inner)
222
+ return [{ text: '┌', sgr: theme.ui.border }, { text: fill, sgr: theme.ui.border }, { text: '┐', sgr: theme.ui.border }]
223
+ }
224
+
225
+ function borderMid(cols, theme) {
226
+ return [
227
+ { text: '├', sgr: theme.ui.border },
228
+ { text: '─'.repeat(Math.max(0, cols - 2)), sgr: theme.ui.border },
229
+ { text: '┤', sgr: theme.ui.border }
230
+ ]
231
+ }
232
+
233
+ function borderBottom(cols, theme) {
234
+ return [
235
+ { text: '└', sgr: theme.ui.border },
236
+ { text: '─'.repeat(Math.max(0, cols - 2)), sgr: theme.ui.border },
237
+ { text: '┘', sgr: theme.ui.border }
238
+ ]
239
+ }
240
+
241
+ /** One bordered row: `│ content │`, content exactly `cols - 4` cells wide. */
242
+ function frameRow(content, cols, theme) {
243
+ const inner = Math.max(0, cols - 4)
244
+ const fitted = truncateSegments(content, inner)
245
+ const padded = padRow(fitted, inner)
246
+ return [{ text: '│ ', sgr: theme.ui.border }, ...padded, { text: ' │', sgr: theme.ui.border }]
247
+ }
248
+
249
+ /**
250
+ * Place `left` and `right` at the two ends of one content row.
251
+ *
252
+ * Right-hand segments may carry a `priority`; when the row is too narrow the
253
+ * lowest-priority segment is removed first, repeatedly, before either side is
254
+ * truncated. That keeps the figures a user must see at 80 columns (tokens, the
255
+ * scroll position) while dropping decoration (the usage breakdown, the key
256
+ * hints). Segments without a priority are never removed.
257
+ */
258
+ export function composeLR(left, right, width, options = {}) {
259
+ const minLeft = Math.max(0, options.minLeft ?? 0)
260
+ let head = [...(left ?? [])]
261
+ let tail = [...(right ?? [])]
262
+
263
+ // Anything marked `priority` may be dropped. Both sides are fair game — a
264
+ // long title or a long error message must give way before the session does —
265
+ // but nothing at or above `ESSENTIAL` is ever removed.
266
+ const ESSENTIAL = 3
267
+ const dropLowest = (list) => {
268
+ let victim = -1
269
+ let worst = Number.POSITIVE_INFINITY
270
+ for (let index = 0; index < list.length; index += 1) {
271
+ const priority = list[index]?.priority
272
+ // At or above `ESSENTIAL` nothing may be dropped, however tight it gets.
273
+ if (typeof priority !== 'number' || priority >= ESSENTIAL) continue
274
+ if (priority < worst) {
275
+ worst = priority
276
+ victim = index
277
+ }
278
+ }
279
+ return victim === -1 ? null : [...list.slice(0, victim), ...list.slice(victim + 1)]
280
+ }
281
+ while (rowWidth(head) + rowWidth(tail) + 2 > width) {
282
+ const nextTail = dropLowest(tail)
283
+ if (nextTail !== null) {
284
+ tail = nextTail
285
+ continue
286
+ }
287
+ const nextHead = dropLowest(head)
288
+ if (nextHead !== null) {
289
+ head = nextHead
290
+ continue
291
+ }
292
+ break
293
+ }
294
+
295
+ // Still too wide: shorten the text on the right rather than the session id,
296
+ // which is the whole point of the header. Only the detail is cut.
297
+ let leftWidth = rowWidth(head)
298
+ let rightWidth = rowWidth(tail)
299
+ if (leftWidth + rightWidth + 2 > width) {
300
+ const reserved = Math.min(leftWidth, Math.max(minLeft, leftWidth))
301
+ const room = Math.max(0, width - 2 - reserved)
302
+ if (room > 0) {
303
+ tail = truncateSegments(tail, room)
304
+ rightWidth = rowWidth(tail)
305
+ }
306
+ }
307
+ if (leftWidth + rightWidth + 2 <= width) {
308
+ return [...head, { text: ' '.repeat(width - leftWidth - rightWidth) }, ...tail]
309
+ }
310
+ // Only now does the left give way.
311
+ if (rightWidth + 10 <= width) {
312
+ return [...truncateSegments(head, width - rightWidth - 2), { text: ' ' }, ...tail]
313
+ }
314
+ return truncateSegments(head, width)
315
+ }
316
+
317
+ /* ------------------------------------------------------------------ *
318
+ * Header
319
+ * ------------------------------------------------------------------ */
320
+
321
+ function headerContent(state, width, theme, now, t = DEFAULT_TRANSLATOR) {
322
+ const session = state.sessionId
323
+ const summary = state.sessions.find((item) => item.id === session)
324
+
325
+ // The session id is the identifier; the title and the path are decoration.
326
+ // Marking them droppable keeps the id on screen when the header is tight.
327
+ const left = [{ text: `${t('header.session')} `, sgr: theme.ui.label, priority: 3 }]
328
+ if (session === null || session === undefined) {
329
+ left.push({ text: t('header.noSession'), sgr: theme.ui.statusIdle, priority: 3 })
330
+ } else {
331
+ left.push({ text: shortSessionId(session, 28), sgr: theme.ui.value, priority: 3 })
332
+ if (summary?.title !== undefined && summary.title.length > 0) {
333
+ left.push({ text: ` · ${sanitize(summary.title)}`, sgr: theme.level.info, priority: 1 })
334
+ }
335
+ if (summary?.cwd !== undefined && width > 100) {
336
+ left.push({ text: ` ${shortPath(sanitize(summary.cwd), 34)}`, sgr: theme.ui.dim, priority: 1 })
337
+ }
338
+ }
339
+
340
+ const status = state.status.status ?? 'idle'
341
+ const right = [
342
+ { text: `${t('header.status')} `, sgr: theme.ui.label, priority: 3 },
343
+ { text: `${statusGlyph(status, Math.floor(now / 250))} `, sgr: statusStyle(theme, status), priority: 3 },
344
+ {
345
+ text: state.connected ? statusText(t, status) : t('header.disconnected'),
346
+ sgr: statusStyle(theme, state.connected ? status : 'error'),
347
+ priority: 2
348
+ }
349
+ ]
350
+ // The error text is the longest and least essential thing here, so it is the
351
+ // first to go.
352
+ if (state.status.error !== null && state.status.error !== undefined) {
353
+ right.push({ text: ` — ${truncate(sanitize(state.status.error), 40)}`, sgr: theme.ui.fail, priority: 0 })
354
+ }
355
+
356
+ // Never squeeze the session out: give the left at least the label and the id.
357
+ const minLeft = rowWidth(left.slice(0, session === null || session === undefined ? 2 : 2))
358
+ return composeLR(left, right, width, { minLeft })
359
+ }
360
+
361
+ /* ------------------------------------------------------------------ *
362
+ * Panels
363
+ * ------------------------------------------------------------------ */
364
+
365
+ /**
366
+ * Build the active panel's rows and its cursor row.
367
+ *
368
+ * @returns {{ rows: import('./width.js').Segment[][], cursorRow: number }}
369
+ */
370
+ export function buildPanel(state, context) {
371
+ const { contentWidth, theme, now, view, options, budget, t } = context
372
+ if (view === 'sessions') {
373
+ const panel = sessionsView(state, { width: contentWidth, theme, now, t, overlay: options.sessionsOverlay === true })
374
+ return { ...panel, older: false }
375
+ }
376
+ if (view === 'edits') {
377
+ const panel = editsView(state, { width: contentWidth, theme, t, cursor: state.cursor.edits })
378
+ return { ...panel, older: false }
379
+ }
380
+ if (view === 'commands') {
381
+ const commands = commandsOf(state)
382
+ const panel = commandsView(state, { width: contentWidth, theme, t, commands, selected: commands.length - 1 })
383
+ return { ...panel, older: false }
384
+ }
385
+ if (state.entries.length === 0) {
386
+ return { rows: buildBodyLines(state, context), cursorRow: -1, older: false }
387
+ }
388
+ const trace = buildTraceRows(state, context, budget ?? Number.POSITIVE_INFINITY)
389
+ return { rows: trace.rows, cursorRow: -1, older: trace.older }
390
+ }
391
+
392
+ /**
393
+ * Memoized entry rendering.
394
+ *
395
+ * A frame repaints every ~120ms, but an entry's rows only change when its
396
+ * content, the width, or a rendering toggle changes. Caching per entry object
397
+ * makes a steady-state repaint almost free; entries are replaced rather than
398
+ * mutated, so identity is a safe cache key.
399
+ *
400
+ * @type {WeakMap<object, Map<string, import('./width.js').Segment[][]>>}
401
+ */
402
+ const entryRowCache = new WeakMap()
403
+
404
+ function cachedEntryLines(entry, context) {
405
+ let byContext = entryRowCache.get(entry)
406
+ if (byContext === undefined) {
407
+ byContext = new Map()
408
+ entryRowCache.set(entry, byContext)
409
+ }
410
+ const key = [
411
+ context.contentWidth,
412
+ context.showTime === true ? 't' : '-',
413
+ context.labelWidth,
414
+ context.repeat ?? 1,
415
+ context.maxEntryRows,
416
+ context.options?.rawText === true ? 'raw' : 'md',
417
+ context.options?.expandThinking === false ? '-' : 'x',
418
+ context.options?.thinkingLines ?? 3
419
+ ].join('|')
420
+ const hit = byContext.get(key)
421
+ if (hit !== undefined) return hit
422
+ const rows = entryLines(entry, context)
423
+ byContext.set(key, rows)
424
+ return rows
425
+ }
426
+
427
+ /** Whether two entries would collapse into one repeated row. */
428
+ function sameEntry(a, b) {
429
+ return a.tag === b.tag && a.label === b.label && a.text === b.text && a.ok === b.ok
430
+ }
431
+
432
+ /** The live streaming indicator, which sits below every committed entry. */
433
+ function streamIndicatorRows(state, context) {
434
+ if (state.stream === null) return []
435
+ const { contentWidth, theme, options } = context
436
+ const t = context.t ?? DEFAULT_TRANSLATOR
437
+ const glyph = statusGlyph(state.status.status === 'idle' ? 'running' : state.status.status, context.frame)
438
+ const out = []
439
+
440
+ // Reasoning is the point while the model is thinking, so it is wrapped and
441
+ // shown as a block. It used to be one line holding the last few characters,
442
+ // which is unreadable exactly when you most want to read it.
443
+ const reasoning = sanitize(state.stream.reasoning ?? '').trim()
444
+ if (reasoning.length > 0) {
445
+ const label = `${t('trace.thinking')} `
446
+ const labelWidth = displayWidth(label)
447
+ const gutterWidth = 2 + labelWidth + 2
448
+ // Through the same renderer the committed block uses, so Markdown in the
449
+ // reasoning is formatted rather than shown as its own syntax.
450
+ const rows = renderBody(reasoning, {
451
+ width: Math.max(8, contentWidth - gutterWidth),
452
+ theme,
453
+ raw: options?.rawText === true,
454
+ thinking: true
455
+ })
456
+ // The tail is what is being thought right now, so the block is anchored to
457
+ // its end rather than its start.
458
+ const limit = streamThinkingLines(options)
459
+ for (const [index, row] of rows.slice(-limit).entries()) {
460
+ out.push([
461
+ { text: index === 0 ? `${glyph} ` : ' ', sgr: theme.ui.statusRunning },
462
+ { text: index === 0 ? label : ' '.repeat(labelWidth), sgr: theme.ui.label },
463
+ { text: '┊ ', sgr: theme.ui.border },
464
+ ...row
465
+ ])
466
+ }
467
+ }
468
+
469
+ const writing = sanitize(state.stream.text ?? '').trim()
470
+ if (writing.length > 0) {
471
+ const label = `${t('trace.writing')} `
472
+ const labelWidth = displayWidth(label)
473
+ const rows = renderBody(writing, {
474
+ width: Math.max(8, contentWidth - (2 + labelWidth + 2)),
475
+ theme,
476
+ raw: options?.rawText === true
477
+ })
478
+ for (const [index, row] of rows.slice(-streamThinkingLines(options)).entries()) {
479
+ out.push([
480
+ { text: index === 0 ? `${glyph} ` : ' ', sgr: theme.ui.statusRunning },
481
+ { text: index === 0 ? label : ' '.repeat(labelWidth), sgr: theme.ui.label },
482
+ { text: '┊ ', sgr: theme.ui.border },
483
+ ...row
484
+ ])
485
+ }
486
+ }
487
+
488
+ return out
489
+ }
490
+
491
+ /**
492
+ * How many rows of live reasoning to show.
493
+ *
494
+ * `e` expands; the collapsed count is `--thinking-lines`. Expanded means
495
+ * "show me the thinking", so it is several times that rather than a token two
496
+ * lines — the complaint that started this.
497
+ */
498
+ function streamThinkingLines(options) {
499
+ const base = Math.max(1, options?.thinkingLines ?? 3)
500
+ if (options?.expandThinking === false) return base
501
+ return Math.max(4, base * 4)
502
+ }
503
+
504
+ /**
505
+ * Render the newest trace rows, stopping once `budget` rows are covered.
506
+ *
507
+ * The trace is bottom-anchored, so a frame only ever shows the tail. The
508
+ * original implementation rendered the *entire* log and then sliced it, which
509
+ * cost seconds per frame on a long session and is what made scrolling feel
510
+ * laggy.
511
+ *
512
+ * @param {import('./view-state.js').ViewState} state
513
+ * @param {object} context
514
+ * @param {number} budget rows the caller needs
515
+ * @returns {{ rows: import('./width.js').Segment[][], older: boolean }}
516
+ */
517
+ export function buildTraceRows(state, context, budget = Number.POSITIVE_INFINITY) {
518
+ const { contentWidth, theme } = context
519
+
520
+ const blocks = []
521
+ let rows = 0
522
+
523
+ const indicator = streamIndicatorRows(state, context)
524
+ if (indicator.length > 0) {
525
+ blocks.push(indicator)
526
+ rows += indicator.length
527
+ }
528
+
529
+ const showTime = contentWidth >= 60
530
+ const labelWidth = computeLabelWidth(state.entries)
531
+ const maxEntryRows = context.options?.maxEntryRows ?? DEFAULT_MAX_ENTRY_ROWS
532
+ let index = state.entries.length - 1
533
+
534
+ while (index >= 0 && rows < budget) {
535
+ const entry = state.entries[index]
536
+ // Collapse a run of identical entries into one counted row, walking
537
+ // backwards so grouping costs nothing extra.
538
+ let first = index
539
+ while (first - 1 >= 0 && sameEntry(state.entries[first - 1], entry)) first -= 1
540
+ const rendered = cachedEntryLines(entry, {
541
+ ...context,
542
+ showTime,
543
+ labelWidth,
544
+ repeat: index - first + 1,
545
+ maxEntryRows
546
+ })
547
+ blocks.push(rendered)
548
+ rows += rendered.length
549
+ index = first - 1
550
+ }
551
+
552
+ const out = []
553
+ for (let block = blocks.length - 1; block >= 0; block -= 1) out.push(...blocks[block])
554
+ return { rows: out, older: index >= 0 }
555
+ }
556
+
557
+ /**
558
+ * Build every wrapped trace line for the current state.
559
+ *
560
+ * Kept as the unbounded form for tests and one-off rendering; the frame path
561
+ * uses {@link buildTraceRows} with a row budget.
562
+ *
563
+ * @returns {import('./width.js').Segment[][]}
564
+ */
565
+ export function buildBodyLines(state, context) {
566
+ const { theme } = context
567
+ if (state.entries.length === 0) {
568
+ const out = []
569
+ const t = context.t ?? DEFAULT_TRANSLATOR
570
+ if (!state.connected) {
571
+ out.push([{ text: t('trace.connecting'), sgr: theme.ui.statusIdle }])
572
+ } else if (state.sessions.length === 0) {
573
+ out.push([{ text: t('trace.noSession'), sgr: theme.ui.statusIdle }])
574
+ } else {
575
+ out.push([{ text: t('trace.waiting'), sgr: theme.ui.statusIdle }])
576
+ }
577
+ if (state.serverError !== null && state.serverError !== undefined) {
578
+ out.push([{ text: sanitize(state.serverError), sgr: theme.ui.fail }])
579
+ }
580
+ out.push(...streamIndicatorRows(state, context))
581
+ return out
582
+ }
583
+ return buildTraceRows(state, context, Number.POSITIVE_INFINITY).rows
584
+ }
585
+
586
+ /**
587
+ * Collapse runs of identical consecutive entries into one line.
588
+ *
589
+ * Chatty plugins can append the same bookkeeping record once per step; showing
590
+ * one line with a `×N` count keeps the trace readable without hiding that the
591
+ * event happened N times. The newest occurrence supplies the timestamp.
592
+ *
593
+ * @param {object[]} entries
594
+ * @returns {Array<{ entry: object, count: number }>}
595
+ */
596
+ export function groupRepeats(entries) {
597
+ const groups = []
598
+ for (const entry of entries) {
599
+ const last = groups[groups.length - 1]
600
+ const same =
601
+ last !== undefined &&
602
+ last.entry.tag === entry.tag &&
603
+ last.entry.label === entry.label &&
604
+ last.entry.text === entry.text &&
605
+ last.entry.ok === entry.ok
606
+ if (same) {
607
+ last.count += 1
608
+ // Keep the first occurrence's ordering but report the latest time.
609
+ last.entry = { ...entry, key: last.entry.key }
610
+ } else {
611
+ groups.push({ entry, count: 1 })
612
+ }
613
+ }
614
+ return groups
615
+ }
616
+
617
+ function computeLabelWidth(entries) {
618
+ let widest = 8
619
+ for (const entry of entries.slice(-40)) {
620
+ const label = `[${entry.label ?? entry.tag ?? '?'}]`
621
+ widest = Math.max(widest, displayWidth(label))
622
+ }
623
+ return Math.max(9, Math.min(22, widest)) + 1
624
+ }
625
+
626
+ /** The `HH:MM:SS [LABEL] ` prefix every trace row starts with. */
627
+ function entryPrefix(entry, context) {
628
+ const { theme, labelWidth, showTime } = context
629
+ const tagStyle = theme.tag[entry.tag] ?? theme.ui.value
630
+ const indented = entry.tag === 'tool' || entry.tag === 'approval' || entry.tag === 'result'
631
+ const prefix = []
632
+ if (showTime) prefix.push({ text: `${formatClock(entry.at)} `, sgr: theme.ui.dim })
633
+ if (indented) prefix.push({ text: ' ', sgr: '' })
634
+ const label = `[${truncate(entry.label ?? entry.tag ?? '?', labelWidth - 3)}]`
635
+ prefix.push({ text: label, sgr: tagStyle })
636
+ prefix.push({ text: ' '.repeat(Math.max(1, labelWidth - displayWidth(label))), sgr: '' })
637
+ return prefix
638
+ }
639
+
640
+ /** Indent a set of rows under an entry's prefix. */
641
+ function indentRows(rows, indent) {
642
+ return rows.map((row) => [{ text: ' '.repeat(indent), sgr: '' }, ...row])
643
+ }
644
+
645
+ function entryLines(entry, context) {
646
+ const { contentWidth, theme } = context
647
+ const prefix = entryPrefix(entry, context)
648
+ const prefixWidth = rowWidth(prefix)
649
+
650
+ // A turn opening gets a full-width rule, matching the board's reading order.
651
+ if (entry.event === 'turn/start') {
652
+ return [[...prefix, { text: '─'.repeat(Math.max(0, contentWidth - prefixWidth)), sgr: theme.ui.border }]]
653
+ }
654
+
655
+ // Expanding thinking is an explicit request for the whole block, so it lifts
656
+ // the per-entry cap; leaving it in place made `e` look broken on long
657
+ // reasoning. It stays bounded so one entry can never flood the frame.
658
+ const budget =
659
+ context.options?.expandThinking !== false
660
+ ? Math.max(context.maxEntryRows ?? DEFAULT_MAX_ENTRY_ROWS, EXPANDED_ENTRY_ROWS)
661
+ : (context.maxEntryRows ?? DEFAULT_MAX_ENTRY_ROWS)
662
+ const isTool = entry.tag === 'tool' || entry.tag === 'result'
663
+ const rows = isTool ? toolBlockRows(entry, context, prefix, prefixWidth) : proseRows(entry, context, prefix, prefixWidth)
664
+ if (rows.length <= budget) return rows
665
+ const kept = rows.slice(0, budget - 1)
666
+ const capT = context.options?.t ?? DEFAULT_TRANSLATOR
667
+ kept.push([
668
+ { text: ' '.repeat(prefixWidth), sgr: '' },
669
+ { text: capT('trace.moreLines', { n: rows.length - budget + 1 }), sgr: theme.ui.dim }
670
+ ])
671
+ return kept
672
+ }
673
+
674
+ /** An assistant/user entry: Markdown body plus a thinking section. */
675
+ function proseRows(entry, context, prefix, prefixWidth) {
676
+ const { contentWidth, theme } = context
677
+ const bodyWidth = Math.max(8, contentWidth - prefixWidth)
678
+ const raw = context.options?.rawText === true
679
+ const lines = []
680
+
681
+ const source = entry.rawText !== undefined && entry.rawText.length > 0 ? entry.rawText : (entry.text ?? '')
682
+ const rendered = renderBody(source, { width: bodyWidth, theme, raw, level: entry.level })
683
+ const body = rendered.length > 0 ? rendered : [{ text: '(empty)', sgr: theme.level.muted }]
684
+ const repeat = typeof context.repeat === 'number' && context.repeat > 1
685
+ ? [{ text: `×${context.repeat} `, sgr: theme.ui.scrollHint }]
686
+ : []
687
+ if (repeat.length > 0) {
688
+ // The count is re-wrapped with the body so the row still fits its budget.
689
+ const first = wrapSegments([...repeat, ...body[0]], bodyWidth)
690
+ lines.push([...prefix, ...first[0]])
691
+ for (const row of first.slice(1)) lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...row])
692
+ for (const row of body.slice(1)) lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...row])
693
+ lines.push(...thinkingRows(entry, context, prefixWidth, bodyWidth))
694
+ if (typeof entry.detail === 'string' && entry.detail.length > 0) {
695
+ const detailWidth = Math.max(8, bodyWidth - 2)
696
+ for (const line of wrapSegments([{ text: `↳ ${sanitize(entry.detail)}`, sgr: theme.level.muted }], detailWidth)) {
697
+ lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...line])
698
+ }
699
+ }
700
+ return lines
701
+ }
702
+ lines.push([...prefix, ...body[0]])
703
+ for (const row of body.slice(1)) lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...row])
704
+
705
+ lines.push(...thinkingRows(entry, context, prefixWidth, bodyWidth))
706
+
707
+ if (typeof entry.detail === 'string' && entry.detail.length > 0) {
708
+ const detailWidth = Math.max(8, bodyWidth - 2)
709
+ for (const line of wrapSegments([{ text: `↳ ${sanitize(entry.detail)}`, sgr: theme.level.muted }], detailWidth)) {
710
+ lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...line])
711
+ }
712
+ }
713
+ return lines
714
+ }
715
+
716
+ /**
717
+ * The thinking block under an assistant entry.
718
+ *
719
+ * Collapsed by default: reasoning is usually long and rarely what you are
720
+ * watching for, so it shows a few lines and a marker telling you how much more
721
+ * there is and which key opens it. `e` expands every block at once.
722
+ *
723
+ * @returns {import('./width.js').Segment[][]}
724
+ */
725
+ function thinkingRows(entry, context, prefixWidth, bodyWidth) {
726
+ const { theme, options } = context
727
+ const reasoning = entry.reasoning
728
+ if (typeof reasoning !== 'string' || reasoning.length === 0) return []
729
+ const gutter = [
730
+ { text: ' '.repeat(prefixWidth), sgr: '' },
731
+ { text: '┊ ', sgr: theme.ui.border }
732
+ ]
733
+ const rows = renderBody(reasoning, {
734
+ width: Math.max(8, bodyWidth - 2),
735
+ theme,
736
+ raw: options?.rawText === true,
737
+ thinking: true
738
+ })
739
+ if (rows.length === 0) return []
740
+
741
+ // Visible unless the user collapsed it: thinking is the point while the
742
+ // model reasons, so the default must not hide it.
743
+ const expanded = options?.expandThinking !== false
744
+ const collapsedLines = Math.max(1, options?.thinkingLines ?? 3)
745
+ const shown = expanded ? rows.length : Math.min(rows.length, collapsedLines)
746
+ const out = rows.slice(0, shown).map((row) => [...gutter, ...row])
747
+
748
+ if (!expanded && rows.length > shown) {
749
+ const t = context.options?.t ?? DEFAULT_TRANSLATOR
750
+ const hint = t('trace.expand')
751
+ const key = hint.slice(0, hint.indexOf(' '))
752
+ out.push([
753
+ ...gutter,
754
+ { text: `${t('trace.moreLines', { n: rows.length - shown })} `, sgr: theme.ui.dim },
755
+ { text: key, sgr: theme.ui.cursor },
756
+ { text: hint.slice(key.length), sgr: theme.ui.dim }
757
+ ])
758
+ } else if (expanded && rows.length > collapsedLines) {
759
+ const t = context.options?.t ?? DEFAULT_TRANSLATOR
760
+ const hint = t('trace.collapse')
761
+ const key = hint.slice(0, hint.indexOf(' '))
762
+ out.push([...gutter, { text: key, sgr: theme.ui.cursor }, { text: hint.slice(key.length), sgr: theme.ui.dim }])
763
+ }
764
+ return out
765
+ }
766
+
767
+ /** Render a text body as Markdown, or as plain wrapped text in raw mode. */
768
+ function renderBody(text, context) {
769
+ const source = sanitize(text)
770
+ if (source.trim().length === 0) return []
771
+ if (context.raw === true) {
772
+ const sgr = context.thinking === true ? context.theme.ui.stream : (context.theme.level[context.level] ?? context.theme.level.info)
773
+ return wrapSegments([{ text: source, sgr }], context.width)
774
+ }
775
+ const rows = renderMarkdown(source, { width: context.width, md: context.theme.md, syn: context.theme.syn })
776
+ if (context.thinking === true) {
777
+ // Thinking keeps its own dim tone over whatever Markdown styled.
778
+ return rows.map((row) => row.map((segment) => (segment.sgr === undefined || segment.sgr === '' ? { ...segment, sgr: context.theme.ui.stream } : segment)))
779
+ }
780
+ return rows
781
+ }
782
+
783
+ /** A tool entry: header + the command it ran + its output + its status. */
784
+ function toolBlockRows(entry, context, prefix, prefixWidth) {
785
+ const { contentWidth, theme } = context
786
+ const t = context.options?.t ?? DEFAULT_TRANSLATOR
787
+ const bodyWidth = Math.max(8, contentWidth - prefixWidth)
788
+ const lines = []
789
+ const settled = isSettledEntry(entry)
790
+
791
+ const head = []
792
+ if (typeof context.repeat === 'number' && context.repeat > 1) {
793
+ head.push({ text: `×${context.repeat} `, sgr: theme.ui.scrollHint })
794
+ }
795
+ const summary =
796
+ entry.shell?.description !== undefined && entry.shell.description.length > 0
797
+ ? sanitize(entry.shell.description)
798
+ : !settled && entry.phase === 'call'
799
+ ? sanitize(entry.text ?? '')
800
+ : settled && entry.shell !== undefined
801
+ ? ''
802
+ : sanitize(entry.text ?? '')
803
+ // The name leads the header unless the text already leads with it, which is
804
+ // how a running call's own summary reads in both protocols.
805
+ const named = entry.tool !== undefined ? sanitize(entry.tool) : ''
806
+ const name = named.length > 0 && !summary.startsWith(named) ? `${named} ` : ''
807
+ head.push({ text: name, sgr: theme.level.info })
808
+ head.push({ text: summary, sgr: theme.level[entry.level ?? 'info'] ?? theme.level.info })
809
+
810
+ // Status pill: the settlement of a finished call, or a running marker.
811
+ const pill = []
812
+ if (settled) {
813
+ const ok = entry.ok !== false
814
+ pill.push({ text: ` ${ok ? '✓' : '✗'}`, sgr: ok ? theme.ui.ok : theme.ui.fail })
815
+ if (entry.shellStatus?.signal !== undefined) pill.push({ text: ` signal ${sanitize(entry.shellStatus.signal)}`, sgr: theme.ui.fail })
816
+ else if (entry.shell !== undefined) pill.push({ text: ` exit ${entry.shellStatus?.exitCode ?? 0}`, sgr: ok ? theme.ui.exitOk : theme.ui.exitFail })
817
+ if (typeof entry.durationMs === 'number') pill.push({ text: ` ${formatDuration(entry.durationMs)}`, sgr: theme.ui.dim })
818
+ } else {
819
+ pill.push({ text: ` ${statusGlyph('tool', 0)} ${t('trace.running')}`, sgr: theme.ui.statusTool })
820
+ }
821
+ // The path is often already named in the summary; showing it twice is noise.
822
+ if (entry.filePath !== undefined && !summary.includes(entry.filePath)) {
823
+ pill.push({ text: ` ${shortPath(sanitize(entry.filePath), 40)}`, sgr: theme.ui.dim })
824
+ }
825
+
826
+ // The pill trails the end of the wrapped header, never the middle of it.
827
+ const available = Math.max(8, bodyWidth - rowWidth(pill))
828
+ const wrapped = wrapSegments(head, available)
829
+ wrapped.forEach((row, index) => {
830
+ const tail = index === wrapped.length - 1 ? pill : []
831
+ if (index === 0) lines.push([...prefix, ...row, ...tail])
832
+ else lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...row, ...tail])
833
+ })
834
+
835
+ if (entry.shell !== undefined) {
836
+ for (const row of commandRows(entry.shell.command, bodyWidth, theme)) {
837
+ lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...row])
838
+ }
839
+ }
840
+
841
+ if (settled) {
842
+ const output = entry.output?.text ?? entry.rawText ?? ''
843
+ if (entry.shell === undefined && entry.diffs === undefined) {
844
+ for (const row of renderBody(output, { width: bodyWidth, theme, raw: context.options?.rawText === true })) {
845
+ lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...row])
846
+ }
847
+ } else if (entry.output !== undefined && output.length > 0) {
848
+ const outputLines = sanitize(output).split('\n')
849
+ for (const line of outputLines.slice(0, TRACE_OUTPUT_LINES)) {
850
+ for (const wrappedLine of wrapSegments([{ text: line, sgr: theme.ui.output }], bodyWidth)) {
851
+ lines.push([{ text: ' '.repeat(prefixWidth), sgr: '' }, ...wrappedLine])
852
+ }
853
+ }
854
+ const omitted = (entry.output.omitted ?? 0) + Math.max(0, outputLines.length - TRACE_OUTPUT_LINES)
855
+ if (omitted > 0) {
856
+ lines.push([
857
+ { text: ' '.repeat(prefixWidth), sgr: '' },
858
+ { text: `${t('trace.moreLines', { n: omitted })} ${t('trace.gotoCommands')}`, sgr: theme.ui.dim }
859
+ ])
860
+ }
861
+ }
862
+ if (entry.diffs !== undefined) {
863
+ const counts = entry.diffs.reduce(
864
+ (total, hunk) => ({
865
+ added: total.added + (hunk.newText.length > 0 ? hunk.newText.split('\n').length : 0),
866
+ removed: total.removed + (typeof hunk.oldText === 'string' && hunk.oldText.length > 0 ? hunk.oldText.split('\n').length : 0)
867
+ }),
868
+ { added: 0, removed: 0 }
869
+ )
870
+ lines.push([
871
+ { text: ' '.repeat(prefixWidth), sgr: '' },
872
+ {
873
+ text: `${t(entry.diffs.length === 1 ? 'trace.hunk' : 'trace.hunks', { n: entry.diffs.length })} `,
874
+ sgr: theme.level.muted
875
+ },
876
+ { text: `+${counts.added}`, sgr: theme.ui.added },
877
+ { text: ' ', sgr: '' },
878
+ { text: `−${counts.removed}`, sgr: theme.ui.removed },
879
+ { text: ` ${t('trace.gotoEdits')}`, sgr: theme.ui.dim }
880
+ ])
881
+ }
882
+ }
883
+ return lines
884
+ }
885
+
886
+ /**
887
+ * Render a shell command the way a terminal shows it: a prompt marker, the
888
+ * command syntax highlighted, and a two-space hanging indent for continuations.
889
+ *
890
+ * Highlighting is per logical line rather than per block so wrapping can happen
891
+ * inside it without losing the colouring.
892
+ */
893
+ function commandRows(command, width, theme) {
894
+ const out = []
895
+ const lines = sanitize(command).split('\n')
896
+ lines.forEach((line, index) => {
897
+ const prompt = index === 0 ? { text: '$ ', sgr: theme.ui.hint } : { text: ' ', sgr: '' }
898
+ const room = Math.max(4, width - rowWidth([prompt]))
899
+ const wrapped = wrapSegments(highlightLine(line, 'bash', theme.syn), room)
900
+ wrapped.forEach((row, rowIndex) => {
901
+ const lead = rowIndex === 0 ? prompt : { text: ' '.repeat(rowWidth([prompt])), sgr: '' }
902
+ out.push([lead, ...row])
903
+ })
904
+ })
905
+ return out
906
+ }
907
+
908
+ /* ------------------------------------------------------------------ *
909
+ * Help
910
+ * ------------------------------------------------------------------ */
911
+
912
+ function helpLines(width, theme, t = DEFAULT_TRANSLATOR) {
913
+ const sections = [
914
+ [t('help.views'), [
915
+ ['1 / t', t('help.tracePanel')],
916
+ ['2 / s', t('help.sessionsPanel')],
917
+ ['3 / d', t('help.editsPanel')],
918
+ ['4 / c', t('help.commandsPanel')],
919
+ ['tab', t('help.cycle')]
920
+ ]],
921
+ [t('help.trace'), [
922
+ ['↑ / ↓', t('help.scrollLine')],
923
+ ['PgUp / PgDn', t('help.page')],
924
+ ['Home / End', t('help.jump')],
925
+ ['e', t('help.thinking')],
926
+ ['m', t('help.markdown')],
927
+ ['l', t('help.language')],
928
+ ['p', t('help.pause')],
929
+ ['r', t('help.replay')]
930
+ ]],
931
+ [t('help.lists'), [
932
+ ['↑ / ↓', t('help.select')],
933
+ ['wheel', t('help.mouse')],
934
+ ['click', t('help.click')],
935
+ ['enter', t('help.bind')],
936
+ ['esc', t('help.leave')]
937
+ ]],
938
+ ['', [
939
+ ['?', t('help.helpKey')],
940
+ ['q / Ctrl-C', t('help.quit')]
941
+ ]]
942
+ ]
943
+ const out = [[{ text: t('help.title'), sgr: theme.ui.title }], [{ text: '', sgr: '' }]]
944
+ for (const [heading, entries] of sections) {
945
+ if (heading.length > 0) out.push([{ text: heading, sgr: theme.ui.title }])
946
+ for (const [keys, description] of entries) {
947
+ out.push([
948
+ { text: ' ', sgr: '' },
949
+ { text: padTo(keys, 24), sgr: theme.level.info },
950
+ { text: description, sgr: theme.level.muted }
951
+ ])
952
+ }
953
+ out.push([{ text: '', sgr: '' }])
954
+ }
955
+ out.push([{ text: t('help.readonly'), sgr: theme.level.muted }])
956
+ out.push([{ text: t('help.width', { n: width }), sgr: theme.level.muted }])
957
+ return out
958
+ }
959
+
960
+ /* ------------------------------------------------------------------ *
961
+ * Footer
962
+ * ------------------------------------------------------------------ */
963
+
964
+ function footerContent(state, width, theme, now, t, scroll) {
965
+ const view = scroll.view ?? 'trace'
966
+ const status = state.status.status ?? 'idle'
967
+ const left = [{ text: `${statusGlyph(status, Math.floor(now / 250))} `, sgr: statusStyle(theme, status) }]
968
+ left.push({
969
+ text: scroll.paused === true ? t('footer.paused') : statusText(t, status),
970
+ sgr: scroll.paused === true ? theme.ui.statusTool : statusStyle(theme, status)
971
+ })
972
+
973
+ if (scroll.overlay === true) left.push({ text: ' sessions', sgr: theme.ui.panelTitle })
974
+
975
+ const turn = state.status.turn ?? 0
976
+ const step = state.status.step ?? 0
977
+ const maxStep = state.status.maxStep ?? 0
978
+ if (turn > 0) left.push({ text: ` T${turn}`, sgr: theme.ui.value })
979
+ if (step > 0) left.push({ text: ` · S${step}${maxStep > step ? `/${maxStep}` : ''}`, sgr: theme.ui.value })
980
+
981
+ // "Elapsed" means the current turn while one is open, otherwise how long the
982
+ // session has sat in its current state. Session age itself would be
983
+ // misleading (a resumed session can be days old).
984
+ const startedAt = state.turnStartedAt ?? state.status.since
985
+ if (typeof startedAt === 'number' && startedAt > 0 && startedAt <= now) {
986
+ left.push({ text: ` ${formatDuration(now - startedAt)}`, sgr: theme.ui.dim })
987
+ }
988
+ if (view === 'sessions' && state.sessions.length > 0) {
989
+ left.push({ text: ` ${t('footer.sessions', { n: state.sessions.length })}`, sgr: theme.ui.dim })
990
+ }
991
+ if (view === 'edits' && state.edits.length > 0) {
992
+ left.push({ text: ` ${t('footer.files', { n: state.edits.length })}`, sgr: theme.ui.dim })
993
+ }
994
+
995
+ const total = usageTotal(state.usage)
996
+ const right = [
997
+ { text: `${t('footer.tokens')} `, sgr: theme.ui.label },
998
+ {
999
+ text:
1000
+ state.contextWindow !== null && state.contextWindow !== undefined
1001
+ ? `${formatTokens(total)}/${formatTokens(state.contextWindow)}`
1002
+ : formatTokens(total),
1003
+ sgr: theme.ui.value
1004
+ }
1005
+ ]
1006
+ if (typeof state.usage?.inputTokens === 'number' || typeof state.usage?.outputTokens === 'number') {
1007
+ right.push({
1008
+ text: ` (↑${formatTokens(state.usage.inputTokens ?? 0)} ↓${formatTokens(state.usage.outputTokens ?? 0)})`,
1009
+ sgr: theme.ui.dim,
1010
+ priority: 1
1011
+ })
1012
+ }
1013
+ if (scroll.view === 'trace' && scroll.offset > 0) {
1014
+ right.push({ text: ` ${t('footer.above', { n: scroll.offset })}`, sgr: theme.ui.scrollHint, priority: 4 })
1015
+ } else if (scroll.hasOlder === true) {
1016
+ right.push({ text: ` ${t('footer.older')}`, sgr: theme.ui.scrollHint, priority: 3 })
1017
+ }
1018
+ if (scroll.hiddenBelow > 0) {
1019
+ right.push({ text: ` ${t('footer.below', { n: scroll.hiddenBelow })}`, sgr: theme.ui.scrollHint, priority: 3 })
1020
+ }
1021
+ right.push({ text: ` ${t(`keys.${view}`)}`, sgr: theme.ui.hint, priority: 2 })
1022
+
1023
+ return composeLR(left, right, width)
1024
+ }
1025
+
1026
+ /* ------------------------------------------------------------------ *
1027
+ * Degenerate sizes
1028
+ * ------------------------------------------------------------------ */
1029
+
1030
+ function renderTooSmall(cols, rows, theme) {
1031
+ const message = [
1032
+ `Terminal too small (${cols}×${rows}).`,
1033
+ `Need at least ${MIN_COLS}×${MIN_ROWS}.`,
1034
+ 'Resize the window or press q.'
1035
+ ]
1036
+ const lines = []
1037
+ for (let index = 0; index < rows; index += 1) {
1038
+ const text = message[index] ?? ''
1039
+ lines.push(renderRow(padRow([{ text: truncate(text, Math.max(0, cols)), sgr: theme.ui.fail }], cols)))
1040
+ }
1041
+ return lines
1042
+ }
1043
+
1044
+ export { VIEWS, toolDetailRows }