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.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- 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 }
|