dsh-oc-tui 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/lib/ui.js ADDED
@@ -0,0 +1,1646 @@
1
+ // The TUI view model and renderer: opencode-inspired layout with a dark
2
+ // theme, session sidebar, chat transcript, input row, and status bar.
3
+ import { Screen, makeStyle, mergeStyle, hexToAnsi } from './term.js'
4
+ import { renderMarkdown } from './markdown.js'
5
+ import { displayWidth, truncateWidth, timeString, toolSummary, roughTokens, formatTokens } from './util.js'
6
+
7
+ // DeepSeek brand palette: deep blue accents on a blue-tinted dark canvas.
8
+ export const THEME = {
9
+ primary: '4d6bfe', // DeepSeek blue
10
+ secondary: '6c9cff', // light blue
11
+ accent: '7c9cff', // light blue accent
12
+ error: 'e06c75',
13
+ warning: 'e8c468', // soft gold (no orange)
14
+ warningDim: '806c39', // dimmed gold: resting cells of the flowing composer frame
15
+ success: '7fd88f',
16
+ info: '56b6c2',
17
+ text: 'f0f4ff', // blue-white text
18
+ textMuted: '8a93a8',
19
+ background: '0a0e18', // blue-tinted dark background
20
+ backgroundPanel: '111a2c',
21
+ backgroundElement: '1b2740',
22
+ border: '3d4d73',
23
+ borderSubtle: '2b3a5c',
24
+ markdownHeading: '7c9cff',
25
+ markdownLinkText: '6c9cff',
26
+ markdownCode: '7fd88f',
27
+ markdownCodeBlock: 'f0f4ff',
28
+ markdownBlockQuote: '9fb0d8',
29
+ markdownListItem: '4d6bfe',
30
+ markdownHorizontalRule: '46547a',
31
+ codeBg: '1b2740',
32
+ thinking: '9aa6c2',
33
+ reminder: 'c5bdf7', // system-reminder box text (pale violet)
34
+ reminderBg: '3a2f66', // system-reminder box background (violet)
35
+ imageChipBg: 'd97706', // orange emphasis for pasted-image markers
36
+ imageChipText: '1a0d00', // text on the orange image chip
37
+ compaction: '95d8c0', // compaction box text (pale mint)
38
+ compactionBg: '1f5241', // compaction box background (green)
39
+ // Context-meter segments (web ContextMeter port): heuristic composition
40
+ // shares get distinct hues so the breakdown bar reads at a glance.
41
+ contextSystem: '7fd88f',
42
+ contextTools: 'e8c468',
43
+ contextMessages: '56b6c2',
44
+ // Mouse text-selection highlight (left-drag select, right-click copy).
45
+ selection: '3153b8',
46
+ }
47
+
48
+ // Flowing activity indicator frames (clockwise Braille flow).
49
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
50
+
51
+ // Cached styles for the composer's flowing (marching-ants) frame: a bright
52
+ // gold head, a warning-gold body, and the dimmed resting tone. Built once
53
+ // because the whole border re-resolves its tone on every animation frame.
54
+ let flowFramePalette = null
55
+
56
+ const COMMAND_HINTS = [
57
+ ['/new', 'new session'], ['/resume', 'resume session'],
58
+ ['/model', 'select model'], ['/provider', 'select provider'], ['/compact', 'compact context'],
59
+ ['/goal', 'manage goal'], ['/settings', 'open settings'],
60
+ ['/clear', 'clear view'], ['/quit', 'exit'],
61
+ ]
62
+
63
+ export function inputRows(text, cursor, width) {
64
+ const rows = ['']
65
+ let row = 0
66
+ let col = 0
67
+ let cursorRow = 0
68
+ let cursorCol = 0
69
+ let offset = 0
70
+ for (const ch of text) {
71
+ if (offset === cursor) {
72
+ cursorRow = row
73
+ cursorCol = col
74
+ }
75
+ if (ch === '\n') {
76
+ rows.push('')
77
+ row++
78
+ col = 0
79
+ offset += ch.length
80
+ continue
81
+ }
82
+ const rune = displayWidth(ch)
83
+ if (col > 0 && col + rune > width) {
84
+ rows.push('')
85
+ row++
86
+ col = 0
87
+ }
88
+ rows[row] += ch
89
+ col += rune
90
+ offset += ch.length
91
+ }
92
+ if (cursor >= offset) {
93
+ cursorRow = row
94
+ cursorCol = col
95
+ }
96
+ return { rows, cursorRow, cursorCol }
97
+ }
98
+
99
+ export function cursorAtVisual(text, width, targetRow, targetCol) {
100
+ let row = 0
101
+ let col = 0
102
+ let offset = 0
103
+ let lastOnRow = 0
104
+ for (const ch of text) {
105
+ if (ch === '\n') {
106
+ if (row === targetRow) return targetCol >= col ? offset : lastOnRow
107
+ row++
108
+ col = 0
109
+ offset += ch.length
110
+ lastOnRow = offset
111
+ continue
112
+ }
113
+ const rune = displayWidth(ch)
114
+ if (col > 0 && col + rune > width) {
115
+ if (row === targetRow) return offset
116
+ row++
117
+ col = 0
118
+ lastOnRow = offset
119
+ }
120
+ if (row === targetRow && targetCol <= col) return offset
121
+ col += rune
122
+ offset += ch.length
123
+ if (row === targetRow) lastOnRow = offset
124
+ }
125
+ return row < targetRow ? text.length : lastOnRow
126
+ }
127
+
128
+ function formatDuration(ms) {
129
+ return ms < 1000 ? Math.round(ms) + 'ms' : (ms / 1000).toFixed(ms < 10_000 ? 1 : 0) + 's'
130
+ }
131
+
132
+ function formatMetric(value) {
133
+ return value >= 100 ? Math.round(value).toString() : value.toFixed(1)
134
+ }
135
+
136
+ // Pad a line's segments to the full transcript width with a background fill
137
+ // so a "boxed" region (e.g. thinking) keeps its emphasized background even in
138
+ // cells that carry no text.
139
+ function boxPad(segs, width, bg) {
140
+ let w = 0
141
+ for (const s of segs) w += displayWidth(s.text)
142
+ if (w >= width) return segs
143
+ return [...segs, { text: ' '.repeat(width - w), style: makeStyle({ bg }) }]
144
+ }
145
+
146
+ // Interpolate between two hex colors; t in [0, 1].
147
+ function mixColor(a, b, t) {
148
+ const ca = hexToAnsi(a)
149
+ const cb = hexToAnsi(b)
150
+ const ch = [0, 1, 2].map((i) => {
151
+ const v = [ca.r, ca.g, ca.b][i] + ([cb.r, cb.g, cb.b][i] - [ca.r, ca.g, ca.b][i]) * t
152
+ return Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
153
+ })
154
+ return '#' + ch.join('')
155
+ }
156
+
157
+ // Draw text with a per-character color gradient from `from` to `to`.
158
+ function gradientText(screen, x, y, text, from, to, base = {}) {
159
+ const chars = Array.from(text)
160
+ let cx = x
161
+ for (let i = 0; i < chars.length; i++) {
162
+ const t = chars.length > 1 ? i / (chars.length - 1) : 0
163
+ cx = screen.text(cx, y, chars[i], makeStyle({ ...base, fg: mixColor(from, to, t) }))
164
+ }
165
+ return cx
166
+ }
167
+
168
+ // A transcript block. Fields vary by kind.
169
+ // user: { kind, text, time }
170
+ // assistant: { kind, text, reasoning, streaming, thinkingCollapsed, time }
171
+ // tool: { kind, callId, name, args, status, result, time }
172
+ // todo: { kind, todos, time }
173
+ // system: { kind, text, level }
174
+ // note: { kind, text, label, collapsed, time }
175
+ export function makeBlock(kind, data = {}) {
176
+ return { kind, time: Date.now(), rev: 0, ...data }
177
+ }
178
+
179
+ // Classify a non-user context message into a labeled collapsible note.
180
+ // `system-reminder` frames and compaction checkpoints get labeled boxes;
181
+ // everything else stays a plain system line.
182
+ export function noteFromContext(src, text) {
183
+ if (typeof text !== 'string') return null
184
+ if (text.includes('<system-reminder>')) {
185
+ return { label: 'system-reminder', text: stripTag(text, 'system-reminder') }
186
+ }
187
+ if (src && src.kind === 'plugin' && src.plugin === 'compact') {
188
+ return { label: 'compaction', text: stripTag(text, 'compacted-summary') }
189
+ }
190
+ return null
191
+ }
192
+
193
+ function stripTag(text, name) {
194
+ return text.replace(new RegExp('<\\s*/?\\s*' + name + '\\s*>', 'g'), '').trim()
195
+ }
196
+
197
+ // The application view state + layout + painting. It is dsh-agnostic: the
198
+ // plugin feeds it events and key presses.
199
+ export class App {
200
+ constructor(terminal, { sidebarWidth = 30 } = {}) {
201
+ this.term = terminal
202
+ this.sidebarWidth = sidebarWidth
203
+ this.blocks = []
204
+ this.assistantHeaderPending = true
205
+ this.title = 'DeepSeek Harness'
206
+ this.titleScreen = true
207
+ this.workingDirectory = ''
208
+ this.gitBranch = ''
209
+ this.sessionId = ''
210
+ this.model = ''
211
+ this.provider = ''
212
+ this.status = 'idle' // idle | running
213
+ this.usage = { input: 0, output: 0 }
214
+ this.metrics = {}
215
+ this.contextMeter = null // { percent, usedTokens, contextWindow, breakdown } — web ContextMeter port
216
+ this.contextMeterOpen = false // click-open breakdown panel
217
+ this.sidebarVisible = false
218
+ this.sidebarAgents = [] // [{ id, label }]
219
+ this.sidebarSessions = [] // [{ id, label, time }]
220
+ this.sidebarSelection = -1
221
+ this.inputText = ''
222
+ this.inputCursor = 0
223
+ this.inputImages = [] // pasted images: [{ status, ref, mediaType, label }]
224
+ this.history = []
225
+ this.historyIndex = -1
226
+ this.scroll = 0 // lines scrolled up from bottom (0 = follow)
227
+ this.overlay = null // 'help' | 'settings' | null
228
+ this.settingsSelection = 0
229
+ this.settingsEditing = null
230
+ this.settingsDraft = ''
231
+ this.settingsSecret = false
232
+ this.settingsConfirm = null
233
+ this.settingsTitle = 'Settings'
234
+ this.settingsSubtitle = ''
235
+ this.settingsItems = []
236
+ this.settingsMenu = [] // left menu of the settings dialog: [{ id, label }]
237
+ this.settingsMenuIndex = 0 // active left-menu entry (Tab switches it)
238
+ this.settingsScrollOffset = 0 // scroll offset for the settings window (wheel scroll support)
239
+ this.toast = null // { text, level }
240
+ this.effortSlider = null // { levels: [{id, name}], current } — the current model's real reasoning levels
241
+ this.effortSliderVisible = false
242
+ this.pendingApproval = null // { toolName, reason, resolve, timer }
243
+ this.focusedRegion = 'keyboard' // mouse hover temporarily owns focus
244
+ this._lastHover = '' // last hovered target (focus-follows-mouse cache)
245
+ this.hitRegions = [] // topmost interactive regions from the latest render
246
+ this._blockLineCache = new Map() // rendered lines per block, keyed by rev+width
247
+ this._streamingRenderAt = 0 // last time the live streaming block was re-rendered
248
+ this.textSelection = null // { startX, startY, endX, endY, text } for mouse text selection
249
+ this.textSelectionDragging = false // true while left button is held and dragging
250
+ }
251
+
252
+ addHitRegion(kind, x, y, width, height = 1, data = {}) {
253
+ if (width <= 0 || height <= 0) return
254
+ this.hitRegions.push({ kind, x, y, width, height, ...data })
255
+ }
256
+
257
+ hitTest(x, y, kinds) {
258
+ for (let i = this.hitRegions.length - 1; i >= 0; i--) {
259
+ const region = this.hitRegions[i]
260
+ if (kinds && !kinds.includes(region.kind)) continue
261
+ if (x >= region.x && x < region.x + region.width && y >= region.y && y < region.y + region.height) return region
262
+ }
263
+ return undefined
264
+ }
265
+
266
+ placeInputCursor(x, y) {
267
+ const region = this.hitTest(x, y, ['composer'])
268
+ if (!region) return false
269
+ const visualRow = region.firstVisual + Math.max(0, y - region.composerTop - 1)
270
+ const visualCol = Math.max(0, x - region.x - 2)
271
+ this.inputCursor = cursorAtVisual(this.inputText, Math.max(1, region.width - 4), visualRow, visualCol)
272
+ return true
273
+ }
274
+
275
+ // Focus follows the cursor: hovering an interactive row (settings item,
276
+ // sidebar session) moves the keyboard selection there. Returns true when
277
+ // the pointer moved the focus (or left it stale relative to the current
278
+ // selection), so the caller repaints; returns false when nothing changed.
279
+ // The last-hover cache alone is not enough — the selection can move away
280
+ // (keyboard, click, reopened list) while the pointer never leaves the row,
281
+ // and re-hovering that row must move the focus back even though the target
282
+ // did not change.
283
+ hoverFocus(x, y) {
284
+ const region = this.hitTest(x, y)
285
+ // The settings left menu switches on Tab/click only; hovering it never
286
+ // steals focus from the item list.
287
+ if (region?.kind === 'settings-menu') return false
288
+ const target = region ? region.kind + ':' + (region.settingsIndex ?? region.sessionIndex ?? '') : ''
289
+ const focusIndex = region ? (region.settingsIndex ?? region.sessionIndex ?? -1) : -1
290
+ const focusDiffers = focusIndex >= 0 && focusIndex !== (region.kind === 'settings-item' ? this.settingsSelection : this.sidebarSelection)
291
+ if (target === this._lastHover && !focusDiffers) return false
292
+ this._lastHover = target
293
+ if (!region) return false
294
+ this.focusedRegion = 'mouse'
295
+ if (region.kind === 'settings-item') this.settingsSelection = region.settingsIndex
296
+ if (region.kind === 'session') this.sidebarSelection = region.sessionIndex
297
+ return true
298
+ }
299
+
300
+ // ---- mouse text selection ------------------------------------------------
301
+ // Left-drag selects what is on screen; a right click copies it. The
302
+ // selection is stored as screen-space coordinates, and both the highlight
303
+ // and the text extraction read from the last rendered screen — so the copy
304
+ // always matches exactly what is highlighted (wide runes included).
305
+
306
+ startTextSelection(x, y) {
307
+ this.textSelectionDragging = true
308
+ this.textSelection = { startX: x, startY: y, endX: x, endY: y, text: '' }
309
+ }
310
+
311
+ updateTextSelection(x, y) {
312
+ if (!this.textSelection) return false
313
+ if (this.textSelection.endX === x && this.textSelection.endY === y) return false
314
+ this.textSelection.endX = x
315
+ this.textSelection.endY = y
316
+ this.textSelection.text = this.selectionText()
317
+ return true
318
+ }
319
+
320
+ clearTextSelection() {
321
+ const had = this.textSelection !== null || this.textSelectionDragging
322
+ this.textSelection = null
323
+ this.textSelectionDragging = false
324
+ return had
325
+ }
326
+
327
+ // The selection normalized to a reading-order rect: a row range plus the
328
+ // column bounds of its top and bottom rows (middle rows span the full row).
329
+ _selectionRect() {
330
+ const sel = this.textSelection
331
+ if (!sel) return null
332
+ if (sel.startY === sel.endY) {
333
+ return {
334
+ y0: sel.startY, y1: sel.endY,
335
+ topFrom: Math.min(sel.startX, sel.endX),
336
+ bottomFrom: Math.min(sel.startX, sel.endX),
337
+ bottomTo: Math.max(sel.startX, sel.endX),
338
+ }
339
+ }
340
+ const down = sel.startY < sel.endY
341
+ return {
342
+ y0: down ? sel.startY : sel.endY,
343
+ y1: down ? sel.endY : sel.startY,
344
+ topFrom: down ? sel.startX : sel.endX,
345
+ bottomFrom: 0,
346
+ bottomTo: down ? sel.endX : sel.startX,
347
+ }
348
+ }
349
+
350
+ // The plain text under the selection rect, taken from the last rendered
351
+ // screen so it matches what the user sees. Wide-rune continuation cells
352
+ // ('') are skipped; trailing fill spaces are trimmed per row.
353
+ selectionText() {
354
+ const screen = this._lastScreen
355
+ const rect = this._selectionRect()
356
+ if (!screen || !rect) return ''
357
+ const y0 = Math.max(0, Math.min(rect.y0, screen.rows - 1))
358
+ const y1 = Math.max(0, Math.min(rect.y1, screen.rows - 1))
359
+ const lines = []
360
+ for (let y = y0; y <= y1; y++) {
361
+ const row = screen.cells[y]
362
+ if (!row) continue
363
+ const from = y === y0 ? Math.max(0, rect.topFrom) : 0
364
+ const to = y === y1 ? Math.min(rect.bottomTo, row.length - 1) : row.length - 1
365
+ let out = ''
366
+ for (let x = from; x <= to; x++) {
367
+ const ch = row[x]?.ch
368
+ if (!ch) continue
369
+ out += ch
370
+ }
371
+ lines.push(out.replace(/\s+$/, ''))
372
+ }
373
+ while (lines.length > 0 && lines[0] === '') lines.shift()
374
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
375
+ return lines.join('\n')
376
+ }
377
+
378
+ // Highlight the selection rect. Applied after background normalization so
379
+ // every cell has a style to merge into; skipped while an overlay (settings
380
+ // / help) covers the screen.
381
+ _paintTextSelection(screen) {
382
+ if (!this.textSelection || this.overlay) return
383
+ const rect = this._selectionRect()
384
+ if (!rect) return
385
+ const y0 = Math.max(0, Math.min(rect.y0, screen.rows - 1))
386
+ const y1 = Math.max(0, Math.min(rect.y1, screen.rows - 1))
387
+ for (let y = y0; y <= y1; y++) {
388
+ const row = screen.cells[y]
389
+ if (!row) continue
390
+ const from = y === y0 ? Math.max(0, rect.topFrom) : 0
391
+ const to = y === y1 ? Math.min(rect.bottomTo, row.length - 1) : row.length - 1
392
+ for (let x = from; x <= to; x++) {
393
+ const cell = row[x]
394
+ if (!cell) continue
395
+ cell.style = cell.style
396
+ ? mergeStyle(cell.style, { bg: THEME.selection })
397
+ : makeStyle({ bg: THEME.selection })
398
+ }
399
+ }
400
+ }
401
+
402
+ // ---- state mutations -------------------------------------------------
403
+
404
+ setWelcome({ workingDirectory = '', gitBranch = '', model, provider } = {}) {
405
+ this.titleScreen = true
406
+ this.workingDirectory = workingDirectory
407
+ this.gitBranch = gitBranch
408
+ this.sessionId = ''
409
+ // No active session: the top bar names the empty workspace instead of
410
+ // leaking the previous session's title.
411
+ this.title = 'New session'
412
+ if (model !== undefined) this.model = model
413
+ if (provider !== undefined) this.provider = provider
414
+ }
415
+
416
+ setSession({ id, title, model, provider }) {
417
+ if (id !== undefined) {
418
+ this.sessionId = id
419
+ if (id) this.titleScreen = false
420
+ }
421
+ if (title !== undefined) this.title = title
422
+ if (model !== undefined) this.model = model
423
+ if (provider !== undefined) this.provider = provider
424
+ }
425
+
426
+ setStatus(status) {
427
+ this.status = status
428
+ }
429
+
430
+ // The reasoning-effort slider data: the current model's ACTUAL selectable
431
+ // levels (in provider order, weakest -> strongest — a boolean-thinking model
432
+ // exposes two, a full-range one exposes every level the provider advertises)
433
+ // plus the selected id. `null` means the current model exposes no reasoning.
434
+ setEffortSlider(slider) {
435
+ this.effortSlider = slider
436
+ }
437
+
438
+ _effortIndex() {
439
+ const slider = this.effortSlider
440
+ if (!slider) return -1
441
+ return Math.max(0, slider.levels.findIndex((level) => level.id === slider.current))
442
+ }
443
+
444
+ _effortLevel() {
445
+ const slider = this.effortSlider
446
+ if (!slider || slider.levels.length === 0) return null
447
+ return slider.levels[this._effortIndex()] ?? null
448
+ }
449
+
450
+ // The animation/styling is reserved for the strongest level the model
451
+ // actually exposes. A one-level model (e.g. Off-only, thinking disabled)
452
+ // has no meaningful max and never animates.
453
+ _effortAtMax() {
454
+ const slider = this.effortSlider
455
+ if (!slider || slider.levels.length <= 1) return false
456
+ return this._effortIndex() === slider.levels.length - 1
457
+ }
458
+
459
+ setMetrics(metrics) {
460
+ this.metrics = metrics
461
+ this.usage = { input: metrics.inputTokens ?? 0, output: metrics.outputTokens ?? 0 }
462
+ }
463
+
464
+ // Port of the web composer's ContextMeter data: the token-meter
465
+ // `contextPressure` projection (current context length over the context
466
+ // window limit) plus the heuristic `contextBreakdown` composition. The
467
+ // numerator is `projectedTokens` (the provider sample carried over the
468
+ // surface's movement since) so a compaction shows at once; it falls back to
469
+ // the bare sample only for a projection that predates that field. Renders
470
+ // nothing until the provider reports both a numerator and a capacity.
471
+ setContextMeter(meter = {}) {
472
+ const { pressure, breakdown } = meter ?? {}
473
+ const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
474
+ if (usedTokens === undefined || pressure?.contextWindow === undefined) {
475
+ this.contextMeter = null
476
+ this.contextMeterOpen = false
477
+ return
478
+ }
479
+ const partsTotal = breakdown?.systemTokens + breakdown?.toolsTokens + breakdown?.messageTokens
480
+ this.contextMeter = {
481
+ percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
482
+ usedTokens,
483
+ contextWindow: pressure.contextWindow,
484
+ breakdown: breakdown && partsTotal > 0 ? breakdown : null,
485
+ }
486
+ }
487
+
488
+ // `menu` (optional) drives the settings dialog's left menu bar (Main / Model);
489
+ // views without a menu (choice lists, session manager, model picker) render
490
+ // the classic single-column layout.
491
+ openSettings(items, { title = 'Settings', subtitle = '', menu = [], menuIndex = 0 } = {}) {
492
+ this.settingsItems = items
493
+ this.setSettingsSelection(0)
494
+ this.settingsEditing = null
495
+ this.settingsDraft = ''
496
+ this.settingsSecret = false
497
+ this.settingsConfirm = null
498
+ this.settingsTitle = title
499
+ this.settingsSubtitle = subtitle
500
+ this.settingsMenu = Array.isArray(menu) ? menu : []
501
+ this.settingsMenuIndex = this.settingsMenu.length > 0
502
+ ? Math.max(0, Math.min(menuIndex, this.settingsMenu.length - 1))
503
+ : 0
504
+ this.settingsScrollOffset = 0
505
+ this.overlay = 'settings'
506
+ }
507
+
508
+ // Point the selection at an item index, skipping group headers (kind
509
+ // 'header' rows are never selectable): a landing on a header moves down to
510
+ // the next real item, or back to the first selectable one.
511
+ setSettingsSelection(index) {
512
+ const items = this.settingsItems
513
+ if (items.length === 0) {
514
+ this.settingsSelection = 0
515
+ return
516
+ }
517
+ let target = Math.max(0, Math.min(index, items.length - 1))
518
+ while (target < items.length && items[target].kind === 'header') target++
519
+ if (target >= items.length) {
520
+ const first = items.findIndex((item) => item.kind !== 'header')
521
+ target = first < 0 ? 0 : first
522
+ }
523
+ this.settingsSelection = target
524
+ }
525
+
526
+ // Move the selection by one row in either direction, wrapping around and
527
+ // stepping over group headers so it always rests on a selectable item.
528
+ moveSettingsSelection(delta) {
529
+ const count = this.settingsItems.length
530
+ if (count === 0) return
531
+ let index = this.settingsSelection
532
+ let steps = count
533
+ while (steps-- > 0) {
534
+ index = (index + delta + count) % count
535
+ if (this.settingsItems[index].kind !== 'header') break
536
+ }
537
+ this.settingsSelection = index
538
+ }
539
+
540
+ addSystem(text, level = 'info') {
541
+ this.blocks.push(makeBlock('system', { text, level }))
542
+ this._maybeFollow()
543
+ }
544
+
545
+ addNote(text, label = 'context') {
546
+ this.blocks.push(makeBlock('note', { text, label, collapsed: true }))
547
+ this.scroll = 0
548
+ }
549
+
550
+ addUser(text) {
551
+ this.blocks.push(makeBlock('user', { text }))
552
+ this.assistantHeaderPending = true
553
+ this.scroll = 0
554
+ }
555
+
556
+ startAssistant() {
557
+ const showHeader = this.assistantHeaderPending
558
+ this.assistantHeaderPending = false
559
+ this.blocks.push(makeBlock('assistant', { text: '', reasoning: '', streaming: true, showHeader }))
560
+ this.scroll = 0
561
+ }
562
+
563
+ // Append a stream chunk to the live assistant block.
564
+ streamChunk(chunk) {
565
+ let last = this.blocks[this.blocks.length - 1]
566
+ if (!last || last.kind !== 'assistant' || !last.streaming) {
567
+ this.startAssistant()
568
+ last = this.blocks[this.blocks.length - 1]
569
+ }
570
+ if (chunk.type === 'text-delta') last.text += chunk.text
571
+ else if (chunk.type === 'reasoning-delta') last.reasoning += chunk.text
572
+ last.rev++
573
+ this.scroll = 0
574
+ }
575
+
576
+ finalizeAssistant() {
577
+ for (let i = this.blocks.length - 1; i >= 0; i--) {
578
+ const b = this.blocks[i]
579
+ if (b.kind === 'assistant' && b.streaming) {
580
+ b.streaming = false
581
+ b.rev++
582
+ // Thinking is collapsed by default once it has finished streaming.
583
+ if (b.reasoning && b.thinkingCollapsed === undefined) b.thinkingCollapsed = true
584
+ if (b.text === '' && b.reasoning === '') this.blocks.splice(i, 1)
585
+ break
586
+ }
587
+ }
588
+ }
589
+
590
+ // Ensure an assistant block exists (e.g. replay); returns it.
591
+ ensureAssistantBlock(time) {
592
+ const last = this.blocks[this.blocks.length - 1]
593
+ if (last && last.kind === 'assistant') return last
594
+ const showHeader = this.assistantHeaderPending
595
+ this.assistantHeaderPending = false
596
+ const b = makeBlock('assistant', { text: '', reasoning: '', streaming: false, time, showHeader })
597
+ this.blocks.push(b)
598
+ return b
599
+ }
600
+
601
+ setAssistantText(text, time) {
602
+ const b = this.ensureAssistantBlock(time)
603
+ b.text = text
604
+ b.streaming = false
605
+ b.rev++
606
+ if (b.reasoning && b.thinkingCollapsed === undefined) b.thinkingCollapsed = true
607
+ b.time = time ?? b.time
608
+ this.scroll = 0
609
+ }
610
+
611
+ startTool({ callId, name, args }) {
612
+ const b = makeBlock('tool', { callId, name, args, status: 'running', result: '' })
613
+ this.blocks.push(b)
614
+ this.scroll = 0
615
+ return b
616
+ }
617
+
618
+ updateTool(callId, patch) {
619
+ for (let i = this.blocks.length - 1; i >= 0; i--) {
620
+ const b = this.blocks[i]
621
+ if (b.kind === 'tool' && b.callId === callId) {
622
+ Object.assign(b, patch)
623
+ b.rev++
624
+ this.scroll = 0
625
+ return b
626
+ }
627
+ }
628
+ return undefined
629
+ }
630
+
631
+ setTodo(todos) {
632
+ const existing = this.blocks.find((b) => b.kind === 'todo')
633
+ if (existing) {
634
+ existing.todos = todos
635
+ existing.rev++
636
+ } else {
637
+ this.blocks.push(makeBlock('todo', { todos }))
638
+ }
639
+ this.scroll = 0
640
+ }
641
+
642
+ resetView() {
643
+ this.blocks = []
644
+ this.usage = { input: 0, output: 0 }
645
+ this.metrics = {}
646
+ this.scroll = 0
647
+ this._blockLineCache.clear()
648
+ }
649
+
650
+ // Toggle a completed thinking box between collapsed and expanded. Thinking
651
+ // stays collapsed while it is still streaming.
652
+ toggleThinking(block) {
653
+ if (!block || block.kind !== 'assistant' || !block.reasoning || block.streaming) return
654
+ block.thinkingCollapsed = block.thinkingCollapsed !== true
655
+ block.rev++
656
+ }
657
+
658
+ // Toggle a context note (system-reminder / compaction) between collapsed
659
+ // and expanded.
660
+ toggleNote(block) {
661
+ if (!block || block.kind !== 'note') return
662
+ block.collapsed = block.collapsed !== true
663
+ block.rev++
664
+ }
665
+
666
+ // Current flowing-spinner frame (time-based so it animates across paints
667
+ // without any per-frame state).
668
+ animChar(interval = 80) {
669
+ return SPINNER[Math.floor(Date.now() / interval) % SPINNER.length]
670
+ }
671
+
672
+ // Style for one cell of the composer's flowing frame: while a turn is
673
+ // running, gold dashes with a bright head sweep clockwise around the
674
+ // border (marching ants) instead of a flat yellow frame. `pos` is the
675
+ // cell's distance along the perimeter from the top-left corner - top edge
676
+ // left -> right, right edge downward, bottom edge right -> left, left
677
+ // edge upward. `phase` advances with time (like the spinners) so the
678
+ // pattern flows without any per-frame state.
679
+ _flowBorderStyle(pos, phase) {
680
+ if (!flowFramePalette) {
681
+ flowFramePalette = {
682
+ head: makeStyle({ fg: mixColor(THEME.warning, '#ffffff', 0.5) }),
683
+ body: makeStyle({ fg: THEME.warning }),
684
+ rest: makeStyle({ fg: THEME.warningDim }),
685
+ }
686
+ }
687
+ const d = (((pos - phase) % 10) + 10) % 10
688
+ return d === 0 ? flowFramePalette.head : d < 3 ? flowFramePalette.body : flowFramePalette.rest
689
+ }
690
+
691
+ // True while anything is still animating: a streaming turn or a running
692
+ // tool (read/write/bash/...). The UI loop keeps repainting while this is
693
+ // true so spinners keep flowing even when no events are arriving.
694
+ hasAnimation() {
695
+ if (this.status === 'running') return true
696
+ // The max effort effect keeps flowing even after the slider is closed —
697
+ // the composer's top-right effort label stays animated at the strongest
698
+ // level.
699
+ if (this.effortSlider && this._effortAtMax()) return true
700
+ for (const block of this.blocks) {
701
+ if (block.kind === 'assistant' && block.streaming) return true
702
+ if (block.kind === 'tool' && block.status === 'running') return true
703
+ }
704
+ return false
705
+ }
706
+
707
+
708
+ scrollTranscript(delta) {
709
+ const layout = this._layout()
710
+ const width = layout.cols - layout.sidebarW - (layout.sidebarW > 0 ? 1 : 0)
711
+ const maxScroll = Math.max(0, this._transcriptTotal(width) - layout.transcriptH)
712
+ this.scroll = Math.max(0, Math.min(maxScroll, this.scroll + delta))
713
+ }
714
+
715
+ scrollSettingsWindow(delta) {
716
+ // Scroll the settings window by moving the virtual scroll offset.
717
+ // The selection stays in the same place; the window contents scroll up/down.
718
+ if (!this.settingsScrollOffset) this.settingsScrollOffset = 0
719
+ this.settingsScrollOffset = Math.max(0, this.settingsScrollOffset + delta)
720
+ }
721
+
722
+ _maybeFollow() {
723
+ // Only auto-follow when the user has not scrolled up.
724
+ // (Appended content while scroll === 0 keeps following; scroll > 0 stays put.)
725
+ }
726
+
727
+ showToast(text, level = 'info') {
728
+ this.toast = { text, level }
729
+ if (this._toastTimer) clearTimeout(this._toastTimer)
730
+ this._toastTimer = null
731
+ // Auto-dismiss only makes sense when we can repaint to clear it; in
732
+ // headless/test use (no term.paint) the toast stays for the caller to
733
+ // inspect.
734
+ if (!this.term || typeof this.term.paint !== 'function') return
735
+ this._toastTimer = setTimeout(() => {
736
+ this._toastTimer = null
737
+ if (this.toast) {
738
+ this.toast = null
739
+ if (this.term.started) this.term.paint(this.render())
740
+ }
741
+ }, 2000)
742
+ }
743
+
744
+ // ---- layout ----------------------------------------------------------
745
+
746
+ _layout() {
747
+ const cols = this.term.cols
748
+ const rows = this.term.rows
749
+ const headerH = rows >= 20 ? 2 : 1
750
+ const statusH = 1
751
+ const sliderH = this.effortSliderVisible && this.effortSlider ? 1 : 0
752
+ let sidebarW = this.sidebarVisible && cols >= 92 ? Math.min(this.sidebarWidth, Math.floor(cols / 3)) : 0
753
+ if (cols - sidebarW < 52) sidebarW = 0
754
+ const composerWidth = Math.max(20, cols - sidebarW - 6)
755
+ const visual = inputRows(this.inputText, this.inputCursor, composerWidth)
756
+ const inputRowsVisible = Math.min(Math.max(1, visual.rows.length), rows >= 28 ? 6 : 3)
757
+ const suggestions = this.inputText.startsWith('/') && !this.inputText.includes(' ') ? Math.min(4, COMMAND_HINTS.filter(([command]) => command.startsWith(this.inputText)).length) : 0
758
+ const imageHintH = this.inputImages.length > 0 ? 1 : 0
759
+ const inputH = inputRowsVisible + 2 + suggestions
760
+ const transcriptTop = headerH
761
+ const transcriptBottom = rows - inputH - statusH - sliderH - imageHintH
762
+ const transcriptH = Math.max(1, transcriptBottom - transcriptTop)
763
+ return { cols, rows, headerH, inputH, inputRowsVisible, suggestions, imageHintH, statusH, sliderH, transcriptTop, transcriptBottom, transcriptH, sidebarW, composerWidth, visual }
764
+ }
765
+
766
+ // ---- block -> lines --------------------------------------------------
767
+
768
+ _blockLines(block, width) {
769
+ const t = THEME
770
+ const out = []
771
+ switch (block.kind) {
772
+ case 'user': {
773
+ out.push({ segs: [{ text: ' You ', style: makeStyle({ fg: t.primary, bold: true }) },
774
+ { text: '· ' + timeString(block.time), style: makeStyle({ fg: t.textMuted }) }] })
775
+ for (const line of renderMarkdown(block.text, t, width - 2)) {
776
+ out.push({ segs: [{ text: ' ', style: null }, ...line] })
777
+ }
778
+ out.push({ segs: [] })
779
+ break
780
+ }
781
+ case 'assistant': {
782
+ if (block.showHeader !== false) {
783
+ const header = [{ text: ' dsh ', style: makeStyle({ fg: t.accent, bold: true }) },
784
+ { text: '· ' + timeString(block.time), style: makeStyle({ fg: t.textMuted }) }]
785
+ out.push({ segs: header })
786
+ }
787
+ // Thinking is rendered as a gray-emphasised box (no solid border).
788
+ // It stays collapsed while streaming and collapses by default once
789
+ // streaming finishes; the whole box is a click target that toggles
790
+ // between collapsed and expanded (only after it has finished).
791
+ if (block.reasoning) {
792
+ const boxBg = t.backgroundElement
793
+ const streaming = block.streaming === true
794
+ const collapsed = block.thinkingCollapsed === true || streaming
795
+ const hint = streaming ? ' · streaming…' : (collapsed ? ' · click to expand' : ' · click to collapse')
796
+ // While streaming, a flowing spinner leads the header (replaced at
797
+ // draw time so the cached lines can stay static between frames).
798
+ const marker = streaming
799
+ ? { text: '⠿', style: makeStyle({ fg: t.primary, bg: boxBg, bold: true }), anim: 'spinner' }
800
+ : { text: collapsed ? '▸' : '▾', style: makeStyle({ fg: t.thinking, bg: boxBg, bold: true }) }
801
+ const headerSegs = [
802
+ { text: ' ', style: makeStyle({ fg: t.thinking, bg: boxBg }) },
803
+ marker,
804
+ { text: ' thinking' + hint, style: makeStyle({ fg: t.thinking, bg: boxBg, bold: true }) },
805
+ ]
806
+ out.push({ segs: boxPad(headerSegs, width, boxBg), thinking: { block } })
807
+ if (!collapsed) {
808
+ for (const line of renderMarkdown(block.reasoning, t, width - 4)) {
809
+ const segs = boxPad([
810
+ { text: ' ', style: makeStyle({ fg: t.thinking, bg: boxBg }) },
811
+ ...line.map((s) => ({
812
+ ...s,
813
+ style: mergeStyle(s.style, { fg: t.thinking, italic: true, bg: boxBg }),
814
+ })),
815
+ ], width, boxBg)
816
+ out.push({ segs, thinking: { block } })
817
+ }
818
+ }
819
+ }
820
+ let text = block.text
821
+ if (block.streaming) text += '▍'
822
+ // A blank line separates the thinking box from the visible answer so
823
+ // the two never run together; skipped when there is no visible answer
824
+ // (pure reasoning) to avoid doubling the block's trailing gap.
825
+ if (block.reasoning && text) out.push({ segs: [] })
826
+ if (text) {
827
+ const lines = renderMarkdown(text, t, width - 2)
828
+ if (lines.length === 0) lines.push([])
829
+ for (const line of lines) out.push({ segs: [{ text: ' ', style: null }, ...line] })
830
+ }
831
+ out.push({ segs: [] })
832
+ break
833
+ }
834
+ case 'tool': {
835
+ const running = block.status === 'running'
836
+ const statusColor = running ? t.warning : block.status === 'error' ? t.error : t.success
837
+ const label = truncateWidth(toolSummary(block.name, block.args), width - 24)
838
+ // A running tool shows a flowing spinner instead of a static marker.
839
+ out.push({ segs: [
840
+ { text: ' ', style: null },
841
+ ...(running
842
+ ? [{ text: '⠿', style: makeStyle({ fg: statusColor }), anim: 'spinner' }]
843
+ : [{ text: (block.status === 'error' ? '✗' : '✓'), style: makeStyle({ fg: statusColor }) }]),
844
+ { text: ' ', style: null },
845
+ { text: label, style: makeStyle({ fg: t.text }) },
846
+ ] })
847
+ if (running) {
848
+ out.push({ segs: [] })
849
+ break
850
+ }
851
+ if (block.result) {
852
+ const resultLines = renderMarkdown(block.result, t, width - 4)
853
+ for (const line of resultLines.slice(0, 6)) {
854
+ out.push({ segs: [{ text: ' ', style: null },
855
+ ...line.map((s) => ({ ...s, style: makeStyle({ fg: t.textMuted }) }))] })
856
+ }
857
+ if (resultLines.length > 6) {
858
+ out.push({ segs: [{ text: ' … ' + (resultLines.length - 6) + ' more lines', style: makeStyle({ fg: t.textMuted, dim: true }) }] })
859
+ }
860
+ }
861
+ out.push({ segs: [] })
862
+ break
863
+ }
864
+ case 'todo': {
865
+ out.push({ segs: [{ text: ' tasks', style: makeStyle({ fg: t.info, bold: true }) }] })
866
+ for (const item of block.todos ?? []) {
867
+ const mark = item.status === 'completed' ? '☑' : item.status === 'in_progress' ? '◐' : '□'
868
+ const color = item.status === 'completed' ? t.success : item.status === 'in_progress' ? t.warning : t.textMuted
869
+ out.push({ segs: [{ text: ' ' + mark + ' ', style: makeStyle({ fg: color }) },
870
+ { text: item.content, style: makeStyle({ fg: t.text }) }] })
871
+ }
872
+ out.push({ segs: [] })
873
+ break
874
+ }
875
+ case 'note': {
876
+ // Context notes (system-reminder / compaction) reuse the thinking-box
877
+ // treatment: full-width emphasized background, collapsed by default,
878
+ // whole box clickable to toggle — each label gets its own palette.
879
+ const palette = block.label === 'compaction'
880
+ ? { fg: t.compaction, bg: t.compactionBg }
881
+ : { fg: t.reminder, bg: t.reminderBg }
882
+ const collapsed = block.collapsed === true
883
+ const hint = collapsed ? ' · click to expand' : ' · click to collapse'
884
+ const headerSegs = [
885
+ { text: ' ', style: makeStyle({ fg: palette.fg, bg: palette.bg }) },
886
+ { text: collapsed ? '▸' : '▾', style: makeStyle({ fg: palette.fg, bg: palette.bg, bold: true }) },
887
+ { text: ' ' + block.label + hint, style: makeStyle({ fg: palette.fg, bg: palette.bg, bold: true }) },
888
+ ]
889
+ out.push({ segs: boxPad(headerSegs, width, palette.bg), note: { block } })
890
+ if (!collapsed) {
891
+ for (const line of renderMarkdown(block.text, t, width - 4)) {
892
+ const segs = boxPad([
893
+ { text: ' ', style: makeStyle({ fg: palette.fg, bg: palette.bg }) },
894
+ ...line.map((s) => ({ ...s, style: mergeStyle(s.style, { fg: palette.fg, bg: palette.bg }) })),
895
+ ], width, palette.bg)
896
+ out.push({ segs, note: { block } })
897
+ }
898
+ }
899
+ out.push({ segs: [] })
900
+ break
901
+ }
902
+ case 'system': {
903
+ const color = block.level === 'error' ? t.error : block.level === 'warn' ? t.warning : t.textMuted
904
+ for (const line of renderMarkdown(block.text, t, width - 2)) {
905
+ out.push({ segs: [{ text: ' ', style: null }, ...line.map((s) => ({ ...s, style: makeStyle({ fg: color, italic: true }) }))] })
906
+ }
907
+ out.push({ segs: [] })
908
+ break
909
+ }
910
+ default:
911
+ break
912
+ }
913
+ return out
914
+ }
915
+
916
+ // Return the cached rendered lines for a block, re-rendering only when its
917
+ // content (rev) changed. The live streaming block is re-rendered at a
918
+ // throttled rate: re-parsing its whole markdown on every paint is the main
919
+ // cost that grows with output, so short frames reuse the previous render.
920
+ _ensureBlockLines(block, width) {
921
+ const key = block.rev + ':' + width
922
+ const cached = this._blockLineCache.get(block)
923
+ if (cached && cached.key === key) return cached.lines
924
+ if (block.streaming && cached && Date.now() - this._streamingRenderAt < 120) {
925
+ return cached.lines
926
+ }
927
+ const rendered = this._blockLines(block, width)
928
+ if (block.streaming) this._streamingRenderAt = Date.now()
929
+ this._blockLineCache.set(block, { key, lines: rendered })
930
+ return rendered
931
+ }
932
+
933
+ // Total rendered line count for the transcript at a given width.
934
+ _transcriptTotal(width) {
935
+ let total = 0
936
+ for (const block of this.blocks) total += this._ensureBlockLines(block, width).length
937
+ return total
938
+ }
939
+
940
+ // ---- paint -----------------------------------------------------------
941
+
942
+ render() {
943
+ const { cols, rows, headerH, inputRowsVisible, suggestions, imageHintH, transcriptTop, transcriptBottom, transcriptH, sidebarW, visual } = this._layout()
944
+ this.hitRegions = []
945
+ const screen = new Screen(cols, rows)
946
+ const t = THEME
947
+ screen.clear(makeStyle({ bg: t.background }))
948
+
949
+ // Header: brand + session name live in the top bar; model at the right.
950
+ const headerStyle = makeStyle({ fg: t.textMuted, bg: t.backgroundPanel })
951
+ screen.fill(0, 0, cols, ' ', headerStyle)
952
+ const modelInfo = this.model ? this.model : '…'
953
+ const modelX = cols - displayWidth(modelInfo) - 2
954
+ let hx = 2
955
+ hx = screen.text(hx, 0, '◈ ', makeStyle({ fg: t.primary, bold: true, bg: t.backgroundPanel }))
956
+ hx = gradientText(screen, hx, 0, 'DeepSeek Harness TUI', t.primary, '#dce6ff', { bold: true, bg: t.backgroundPanel })
957
+ if (this.title && this.title !== 'DeepSeek Harness') {
958
+ hx = screen.text(hx, 0, ' · ', makeStyle({ fg: t.textMuted, bg: t.backgroundPanel }))
959
+ hx = screen.text(hx, 0, truncateWidth(this.title, Math.max(1, modelX - hx - 1)), makeStyle({ fg: t.text, bold: true, bg: t.backgroundPanel }))
960
+ }
961
+ if (modelX > hx) {
962
+ screen.text(modelX, 0, modelInfo, makeStyle({ fg: t.accent, bg: t.backgroundPanel }))
963
+ screen.fill(hx, 0, modelX - hx, ' ', headerStyle)
964
+ screen.fillToEnd(modelX + displayWidth(modelInfo), 0, headerStyle)
965
+ } else {
966
+ screen.fillToEnd(hx, 0, headerStyle)
967
+ }
968
+ if (headerH > 1) {
969
+ screen.fill(0, 1, cols, ' ', makeStyle({ bg: t.background }))
970
+ }
971
+
972
+ // Sidebar
973
+ let transcriptX = 0
974
+ if (sidebarW > 0) {
975
+ transcriptX = sidebarW + 1
976
+ const sideStyle = makeStyle({ fg: t.textMuted, bg: t.backgroundPanel })
977
+ for (let y = headerH; y < transcriptBottom; y++) screen.fill(0, y, sidebarW, ' ', sideStyle)
978
+ screen.text(2, headerH, 'SESSIONS', makeStyle({ fg: t.textMuted, bold: true, bg: t.backgroundPanel }))
979
+ const newSessionStyle = makeStyle({ fg: t.primary, bold: true, bg: t.backgroundPanel })
980
+ screen.text(1, headerH + 1, '+ New session', newSessionStyle)
981
+ this.addHitRegion('new-session', 0, headerH + 1, sidebarW)
982
+ let sy = headerH + 3
983
+ const active = this.sessionId
984
+ for (const agent of this.sidebarAgents) {
985
+ if (sy >= transcriptTop + transcriptH - 1) break
986
+ const selected = agent.id === active
987
+ const st = selected
988
+ ? makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement })
989
+ : sideStyle
990
+ if (selected) screen.fill(0, sy, sidebarW, ' ', st)
991
+ screen.text(1, sy, (selected ? '▸ ' : ' ') + truncateWidth(agent.label, sidebarW - 4), st)
992
+ sy++
993
+ }
994
+ if (this.sidebarSessions.length > 0) {
995
+ if (sy < transcriptTop + transcriptH - 1) {
996
+ screen.text(2, sy, 'RECENT', makeStyle({ fg: t.textMuted, bold: true, bg: t.backgroundPanel }))
997
+ sy++
998
+ }
999
+ for (let i = 0; i < this.sidebarSessions.length; i++) {
1000
+ if (sy >= transcriptTop + transcriptH - 1) break
1001
+ const s = this.sidebarSessions[i]
1002
+ const selected = s.id === active || i === this.sidebarSelection
1003
+ const st = selected
1004
+ ? makeStyle({ fg: s.id === active ? t.primary : t.text, bold: true, bg: t.backgroundElement })
1005
+ : sideStyle
1006
+ if (selected) screen.fill(0, sy, sidebarW, ' ', st)
1007
+ screen.text(1, sy, (selected ? '▸ ' : ' ') + truncateWidth(s.label, sidebarW - 4), st)
1008
+ this.addHitRegion('session', 0, sy, sidebarW, 1, { sessionIndex: i, sessionId: s.id })
1009
+ sy++
1010
+ }
1011
+ }
1012
+ // vertical border
1013
+ for (let y = 1; y < transcriptBottom; y++) screen.set(sidebarW, y, '│', makeStyle({ fg: t.borderSubtle }))
1014
+ }
1015
+
1016
+ // Transcript. Only the visible window is materialised (never the whole
1017
+ // history), so render cost stays bounded no matter how long the session is.
1018
+ const transWidth = cols - transcriptX
1019
+ this.addHitRegion('transcript', transcriptX, transcriptTop, transWidth, transcriptH)
1020
+ const total = this._transcriptTotal(transWidth)
1021
+ const maxScroll = Math.max(0, total - transcriptH)
1022
+ let offset = maxScroll - this.scroll
1023
+ if (this.scroll === 0) offset = maxScroll
1024
+ offset = Math.max(0, Math.min(offset, maxScroll))
1025
+ const endIndex = Math.min(total, offset + transcriptH)
1026
+ let cursor = 0
1027
+ let row = 0
1028
+ for (const block of this.blocks) {
1029
+ const lines = this._ensureBlockLines(block, transWidth)
1030
+ const blockEnd = cursor + lines.length
1031
+ if (blockEnd > offset && cursor < endIndex) {
1032
+ const from = Math.max(0, offset - cursor)
1033
+ const to = Math.min(lines.length, endIndex - cursor)
1034
+ for (let i = from; i < to; i++) {
1035
+ const y = transcriptTop + row
1036
+ const line = lines[i]
1037
+ let x = transcriptX
1038
+ for (const seg of line.segs) {
1039
+ // Animated placeholders (spinners) are resolved at draw time so
1040
+ // the cached lines stay static between animation frames.
1041
+ x = screen.text(x, y, seg.anim ? this.animChar() : seg.text, seg.style)
1042
+ }
1043
+ screen.fillToEnd(x, y, makeStyle({ fg: t.text }))
1044
+ // The whole thinking box is a click target that toggles expand/collapse.
1045
+ if (line.thinking) {
1046
+ this.addHitRegion('thinking', transcriptX, y, transWidth, 1, { thinkingBlock: line.thinking.block })
1047
+ }
1048
+ // Context notes (system-reminder / compaction) toggle the same way.
1049
+ if (line.note) {
1050
+ this.addHitRegion('note', transcriptX, y, transWidth, 1, { noteBlock: line.note.block })
1051
+ }
1052
+ row++
1053
+ }
1054
+ }
1055
+ cursor = blockEnd
1056
+ if (cursor >= endIndex) break
1057
+ }
1058
+ for (; row < transcriptH; row++) {
1059
+ const y = transcriptTop + row
1060
+ screen.fill(transcriptX, y, cols - transcriptX, ' ', makeStyle({ bg: t.background }))
1061
+ }
1062
+
1063
+ if (this.titleScreen && this.blocks.length === 0) {
1064
+ const centerX = transcriptX + Math.floor(transWidth / 2)
1065
+ const desiredY = transcriptTop + Math.max(2, Math.floor(transcriptH / 2) - 3)
1066
+ const centerY = Math.max(transcriptTop, Math.min(desiredY, transcriptBottom - 6))
1067
+ const brand = 'DeepSeek Harness'
1068
+ // Blue → white gradient title.
1069
+ gradientText(screen, centerX - Math.floor(displayWidth(brand) / 2), centerY, brand,
1070
+ t.primary, '#ffffff', { bold: true, bg: t.background })
1071
+ const cwd = truncateWidth(this.workingDirectory, Math.max(12, transWidth - 8))
1072
+ screen.text(centerX - Math.floor(displayWidth(cwd) / 2), centerY + 2, cwd, makeStyle({ fg: t.text, bg: t.background }))
1073
+ if (this.gitBranch) {
1074
+ const branch = 'git: ' + this.gitBranch
1075
+ screen.text(centerX - Math.floor(displayWidth(branch) / 2), centerY + 3, branch, makeStyle({ fg: t.success, bg: t.background }))
1076
+ }
1077
+ const hint = 'Ctrl+P Settings · Tab thinking'
1078
+ screen.text(centerX - Math.floor(displayWidth(hint) / 2), centerY + 5, hint, makeStyle({ fg: t.textMuted, bg: t.background }))
1079
+ }
1080
+
1081
+ // Composer
1082
+ const composerX = sidebarW > 0 ? sidebarW + 2 : 1
1083
+ const composerW = cols - composerX - 1
1084
+ let composerTop = transcriptBottom
1085
+ if (suggestions > 0) {
1086
+ const matches = COMMAND_HINTS.filter(([command]) => command.startsWith(this.inputText)).slice(0, suggestions)
1087
+ for (const [command, description] of matches) {
1088
+ screen.fill(composerX, composerTop, composerW, ' ', makeStyle({ bg: t.backgroundElement }))
1089
+ screen.text(composerX + 2, composerTop, command, makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
1090
+ screen.text(composerX + 18, composerTop, description, makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
1091
+ composerTop++
1092
+ }
1093
+ }
1094
+ if (imageHintH) {
1095
+ const hint = '已粘贴图片:需使用多模态模型/插件或 deepseek-v4-flash-vision-exp,否则无法读取图片'
1096
+ screen.fill(composerX, composerTop, composerW, ' ', makeStyle({ bg: t.background }))
1097
+ screen.text(composerX + 2, composerTop, truncateWidth(hint, composerW - 4), makeStyle({ fg: t.warning, bold: true, bg: t.background }))
1098
+ composerTop++
1099
+ }
1100
+ // Composer frame: idle draws the static border. While a turn runs the
1101
+ // frame flows instead of sitting flat yellow - gold dashes with a bright
1102
+ // head chase each other clockwise around the box (marching ants),
1103
+ // time-based like the transcript spinners.
1104
+ const running = this.status === 'running'
1105
+ const framePhase = Math.floor(Date.now() / 80)
1106
+ const frameH = inputRowsVisible + 2
1107
+ const borderStyle = running ? null : makeStyle({ fg: t.border })
1108
+ if (running) {
1109
+ for (let i = 0; i < composerW; i++) {
1110
+ screen.set(composerX + i, composerTop, i === 0 ? '╭' : i === composerW - 1 ? '╮' : '─', this._flowBorderStyle(i, framePhase))
1111
+ }
1112
+ } else {
1113
+ screen.text(composerX, composerTop, '╭' + '─'.repeat(Math.max(0, composerW - 2)) + '╮', borderStyle)
1114
+ }
1115
+ this._paintEffortLabel(screen, composerX, composerTop, composerW)
1116
+ const firstVisual = Math.max(0, visual.cursorRow - inputRowsVisible + 1)
1117
+ for (let i = 0; i < inputRowsVisible; i++) {
1118
+ const y = composerTop + 1 + i
1119
+ const visualIndex = firstVisual + i
1120
+ screen.fill(composerX, y, composerW, ' ', makeStyle({ bg: t.backgroundPanel }))
1121
+ if (running) {
1122
+ // Side edges follow the perimeter path: down the right edge, then
1123
+ // back up the left one (see _flowBorderStyle for the mapping).
1124
+ const edge = i + 1
1125
+ screen.set(composerX + composerW - 1, y, '│', this._flowBorderStyle(composerW - 1 + edge, framePhase))
1126
+ screen.set(composerX, y, '│', this._flowBorderStyle(2 * (composerW - 1) + (frameH - 1 - edge), framePhase))
1127
+ } else {
1128
+ screen.text(composerX, y, '│', borderStyle)
1129
+ screen.text(composerX + composerW - 1, y, '│', borderStyle)
1130
+ }
1131
+ if (this.pendingApproval) {
1132
+ if (i === 0) screen.text(composerX + 2, y, 'Approval · ' + this.pendingApproval.toolName + ' · y allow / n deny', makeStyle({ fg: t.warning, bold: true, bg: t.backgroundPanel }))
1133
+ continue
1134
+ }
1135
+ const line = visual.rows[visualIndex] ?? ''
1136
+ let lx = composerX + 2
1137
+ const textStyle = makeStyle({ fg: t.text, bg: t.backgroundPanel })
1138
+ const chipStyle = makeStyle({ fg: t.imageChipText, bg: t.imageChipBg, bold: true })
1139
+ const markerRe = /\[Image \d+\]/g
1140
+ let last = 0
1141
+ let match
1142
+ while ((match = markerRe.exec(line)) !== null) {
1143
+ if (match.index > last) lx = screen.text(lx, y, line.slice(last, match.index), textStyle)
1144
+ lx = screen.text(lx, y, match[0], chipStyle)
1145
+ last = match.index + match[0].length
1146
+ }
1147
+ if (last < line.length) lx = screen.text(lx, y, line.slice(last), textStyle)
1148
+ if (visualIndex === visual.cursorRow) {
1149
+ const cursorX = composerX + 2 + visual.cursorCol
1150
+ const current = Array.from(this.inputText.slice(this.inputCursor))[0] ?? ' '
1151
+ screen.text(cursorX, y, current === '\n' ? ' ' : current, makeStyle({ fg: t.background, bg: t.primary }))
1152
+ }
1153
+ }
1154
+ const bottom = composerTop + inputRowsVisible + 1
1155
+ this.addHitRegion('composer', composerX, composerTop, composerW, inputRowsVisible + 2, { composerTop, firstVisual })
1156
+ if (running) {
1157
+ for (let i = 0; i < composerW; i++) {
1158
+ // Bottom edge continues the perimeter: right -> left.
1159
+ screen.set(composerX + i, bottom, i === 0 ? '╰' : i === composerW - 1 ? '╯' : '─', this._flowBorderStyle(composerW + frameH - 3 + (composerW - 1 - i), framePhase))
1160
+ }
1161
+ } else {
1162
+ screen.text(composerX, bottom, '╰' + '─'.repeat(Math.max(0, composerW - 2)) + '╯', borderStyle)
1163
+ }
1164
+ const mode = this.status === 'running' ? 'interrupt: ctrl+c' : (this.provider ? this.provider + ' · ' : '') + (this.model || 'model')
1165
+ screen.text(composerX + 2, bottom, ' ' + truncateWidth(mode, Math.max(0, composerW - 6)) + ' ', makeStyle({ fg: this.status === 'running' ? t.warning : t.textMuted, bg: t.background }))
1166
+
1167
+ // Reasoning-effort slider: one row between the composer and the status
1168
+ // row, driven by the current model's real selectable levels.
1169
+ if (this.effortSliderVisible) this._paintEffortSlider(screen, cols, rows)
1170
+
1171
+ // Status row
1172
+ const statusRow = rows - 1
1173
+ const statusStyle = makeStyle({ fg: t.textMuted, bg: t.background })
1174
+ screen.fill(0, statusRow, cols, ' ', statusStyle)
1175
+ let leftX = 3
1176
+ if (this.status === 'running' && this.hasAnimation()) {
1177
+ // Flowing wave indicator while the agent is working (read/write/tools/
1178
+ // thinking): consecutive spinner frames render side by side so the
1179
+ // pattern visibly flows left to right.
1180
+ const phase = Math.floor(Date.now() / 70) % SPINNER.length
1181
+ const flow = SPINNER[phase] + SPINNER[(phase + 1) % SPINNER.length] + SPINNER[(phase + 2) % SPINNER.length]
1182
+ screen.text(1, statusRow, flow, makeStyle({ fg: t.primary, bg: t.background }))
1183
+ leftX = 4
1184
+ } else {
1185
+ const statusDot = this.status === 'running' ? '▮' : '·'
1186
+ const dotColor = this.status === 'running' ? t.success : t.textMuted
1187
+ screen.text(1, statusRow, statusDot, makeStyle({ fg: dotColor, bg: t.background }))
1188
+ }
1189
+ const tokText = this.usage.input > 0
1190
+ ? '↑' + this.usage.input + ' ↓' + this.usage.output
1191
+ : roughTokens(this.inputText) + ' draft tok'
1192
+ const modelName = this.model || 'model'
1193
+ const leftBase = ' ' + modelName
1194
+ const leftWithTokens = leftBase + ' · ' + tokText
1195
+ const readings = []
1196
+ if (this.metrics.ttftAverageMs !== undefined) readings.push('TTFT avg ' + formatDuration(this.metrics.ttftAverageMs))
1197
+ if (this.metrics.tokensPerSecond !== undefined) readings.push(formatMetric(this.metrics.tokensPerSecond) + ' tok/s')
1198
+ if (this.metrics.cacheHitRate !== undefined) readings.push('cache ' + this.metrics.cacheHitRate + '%')
1199
+ const right = readings.join(' · ')
1200
+ const metricRight = cols - 2
1201
+ const metricLeft = 3 + Math.floor(cols * 0.42)
1202
+ let metricsWidth = 0
1203
+ if (right && metricRight > metricLeft) {
1204
+ const clipped = truncateWidth(right, metricRight - metricLeft)
1205
+ metricsWidth = displayWidth(clipped)
1206
+ screen.text(Math.max(metricLeft, metricRight - metricsWidth), statusRow, clipped, makeStyle({ fg: t.secondary, bg: t.background }))
1207
+ }
1208
+ // The context meter has priority over the model/token cluster and the
1209
+ // metrics: it sits just left of the metrics (or the row's right edge), and
1210
+ // the left cluster is sized to whatever space remains — the model name
1211
+ // survives before the token counts do when the row is crowded.
1212
+ const meterGeom = this._contextMeterGeometry(statusRow, leftX + 1,
1213
+ metricsWidth > 0 ? metricRight - metricsWidth - 1 : metricRight)
1214
+ const leftBudget = meterGeom
1215
+ ? Math.max(0, meterGeom.x0 - leftX - 1)
1216
+ : Math.max(0, Math.floor(cols * 0.42))
1217
+ const leftClipped = displayWidth(leftWithTokens) <= leftBudget
1218
+ ? leftWithTokens
1219
+ : displayWidth(leftBase) <= leftBudget
1220
+ ? leftBase
1221
+ : truncateWidth(leftBase, leftBudget)
1222
+ screen.text(leftX, statusRow, leftClipped, statusStyle)
1223
+ if (meterGeom) this._paintContextMeter(screen, statusRow, meterGeom)
1224
+
1225
+ // Toast: bottom-center, on the same status row as the model/metrics
1226
+ // readings; it auto-dismisses after 2 seconds.
1227
+ if (this.toast) {
1228
+ const st = makeStyle({ fg: this.toast.level === 'error' ? t.error : t.info, bg: t.backgroundElement })
1229
+ const toastText = ' ' + this.toast.text + ' '
1230
+ const toastX = Math.max(0, Math.floor((cols - displayWidth(toastText)) / 2))
1231
+ screen.fill(toastX, statusRow, displayWidth(toastText), ' ', st)
1232
+ screen.text(toastX, statusRow, toastText, st)
1233
+ }
1234
+
1235
+ // Overlays
1236
+ if (this.overlay === 'help') this._paintHelp(screen, cols, rows)
1237
+ if (this.overlay === 'settings') this._paintSettings(screen, cols, rows)
1238
+ if (this.contextMeterOpen && !this.overlay) this._paintContextPanel(screen, cols, rows)
1239
+
1240
+ // Normalize backgrounds: transcript markdown segments, indents, and row
1241
+ // tail fills carry fg-only styles, which would otherwise fall back to the
1242
+ // terminal's own default background (usually black). Every cell in the
1243
+ // finished frame sits on the themed canvas instead; cells with their own
1244
+ // background (panels, overlays, code spans) keep it.
1245
+ screen.defaultBackground(t.background)
1246
+
1247
+ // Mouse text-selection highlight, applied after normalization so every
1248
+ // cell has a background to merge into.
1249
+ this._paintTextSelection(screen)
1250
+ // Keep the finished frame so selection text extraction reads exactly the
1251
+ // cells the user sees (selectionText / right-click copy).
1252
+ this._lastScreen = screen
1253
+
1254
+ // Remember the input caret's screen position so term.paint can park the
1255
+ // (hidden) terminal cursor there — the OS IME composition window then
1256
+ // anchors inside the composer instead of at the bottom-left corner.
1257
+ screen.cursorX = composerX + 2 + visual.cursorCol
1258
+ screen.cursorY = composerTop + 1 + (visual.cursorRow - firstVisual)
1259
+
1260
+ return screen
1261
+ }
1262
+
1263
+ _paintEffortSlider(screen, cols, rows) {
1264
+ const t = THEME
1265
+ const slider = this.effortSlider
1266
+ if (!slider || slider.levels.length === 0) return
1267
+ const y = rows - 2
1268
+ const bg = t.background
1269
+ screen.fill(0, y, cols, ' ', makeStyle({ bg }))
1270
+ const levels = slider.levels
1271
+ const index = Math.min(this._effortIndex(), levels.length - 1)
1272
+ const atMax = this._effortAtMax()
1273
+ // The level name alone carries the meaning — no "effort" caption.
1274
+ const left = 2
1275
+ const trackWidth = Math.max(10, Math.min(26, cols - left - 48))
1276
+ const fill = levels.length <= 1 ? 1 : Math.max(1, Math.round((index / (levels.length - 1)) * trackWidth))
1277
+ const phase = Math.floor(Date.now() / 60)
1278
+ const head = atMax ? phase % fill : -1
1279
+ for (let i = 0; i < trackWidth; i++) {
1280
+ if (i < fill) {
1281
+ // Filled segment: flat primary, or at max a gradient that flows
1282
+ // left -> right (wave index shrinks with position: (phase - i)), with
1283
+ // a bright comet head sweeping across the fill and a short trail.
1284
+ let color = t.primary
1285
+ if (atMax) {
1286
+ const wave = ((phase - i) % (fill + 1) + (fill + 1)) % (fill + 1) / Math.max(1, fill)
1287
+ const base = mixColor(t.primary, t.accent, wave)
1288
+ const dist = Math.abs(head - i)
1289
+ color = dist === 0 ? mixColor(base, '#ffffff', 0.85)
1290
+ : dist === 1 ? mixColor(base, '#ffffff', 0.45)
1291
+ : dist === 2 ? mixColor(base, '#ffffff', 0.2)
1292
+ : base
1293
+ }
1294
+ screen.text(left + i, y, '█', makeStyle({ fg: color, bg }))
1295
+ } else {
1296
+ // Empty segment: at max the cells shimmer, moving rightward too.
1297
+ const ch = atMax && ((phase - i) % 2 + 2) % 2 === 0 ? '▒' : '░'
1298
+ screen.text(left + i, y, ch, makeStyle({ fg: t.border, bg }))
1299
+ }
1300
+ }
1301
+ // Text-safe slider thumb on the right edge of the fill.
1302
+ screen.text(left + Math.min(fill, trackWidth) - 1, y, '▮', makeStyle({ fg: atMax ? '#ffffff' : t.secondary, bold: true, bg }))
1303
+ // Keep the current effort value in its original slider-row position as
1304
+ // well as in the composer's top-right corner.
1305
+ const current = levels[index] ?? levels[0]
1306
+ const name = truncateWidth(String(current?.name ?? current?.id ?? '—'), 18)
1307
+ let x = left + trackWidth + 2
1308
+ screen.text(x, y, name, makeStyle({ fg: atMax ? t.warning : t.secondary, bold: true, bg }))
1309
+ x += displayWidth(name)
1310
+ // The real range the provider exposed, so a boolean-thinking model shows
1311
+ // exactly its two ends rather than a fake none..max scale.
1312
+ if (levels.length > 1) {
1313
+ const range = ' · ' + String(levels[0]?.name ?? levels[0]?.id) + ' → ' + String(levels[levels.length - 1]?.name ?? levels[levels.length - 1]?.id)
1314
+ const clipped = truncateWidth(range, Math.max(4, cols - x - 22))
1315
+ if (displayWidth(clipped) > 4) {
1316
+ screen.text(x, y, clipped, makeStyle({ fg: t.textMuted, bg }))
1317
+ x += displayWidth(clipped)
1318
+ }
1319
+ }
1320
+ const hint = 'Tab / ←/→ adjust · Esc close'
1321
+ const hintX = cols - displayWidth(hint) - 1
1322
+ if (hintX > x + 2) screen.text(hintX, y, hint, makeStyle({ fg: t.textMuted, dim: true, bg }))
1323
+ }
1324
+
1325
+ // The current effort value, pinned to the top-right corner of the composer
1326
+ // box — diagonally opposite the `provider · model` label at bottom-left.
1327
+ // `↑ max` gets a blinking text arrow and per-letter flowing gradient; any
1328
+ // other level is a static bold label.
1329
+ _paintEffortLabel(screen, composerX, composerTop, composerW) {
1330
+ const t = THEME
1331
+ const level = this._effortLevel()
1332
+ if (!level || composerW < 16) return
1333
+ const atMax = this._effortAtMax()
1334
+ const name = truncateWidth(String(level.name ?? level.id), Math.max(4, composerW - 16))
1335
+ const bg = t.background
1336
+ if (atMax) {
1337
+ const text = '↑ ' + name
1338
+ const x = composerX + composerW - displayWidth(text) - 2
1339
+ const phase = Math.floor(Date.now() / 60)
1340
+ const blink = Math.floor(Date.now() / 130) % 2 === 0 ? t.warning : mixColor(t.warning, '#ffffff', 0.75)
1341
+ screen.text(x, composerTop, '↑', makeStyle({ fg: blink, bold: true, bg }))
1342
+ let cx = x + 2
1343
+ let glyphIndex = 0
1344
+ for (const ch of Array.from(name)) {
1345
+ const wave = (((phase - glyphIndex * 3) % 10) + 10) % 10 / 10
1346
+ screen.text(cx, composerTop, ch, makeStyle({ fg: mixColor(t.primary, t.accent, wave), bold: true, bg }))
1347
+ cx += displayWidth(ch)
1348
+ glyphIndex++
1349
+ }
1350
+ } else {
1351
+ // The level name alone labels the corner — no "effort" prefix.
1352
+ const text = name
1353
+ const x = composerX + composerW - displayWidth(text) - 2
1354
+ screen.text(x, composerTop, text, makeStyle({ fg: t.secondary, bold: true, bg }))
1355
+ }
1356
+ }
1357
+
1358
+ // Port of the web composer's ContextMeter ring: an always-visible occupancy
1359
+ // bar in the status row (`ctx ▓▓░░ 32K/128K 25%`) fed by the token-meter
1360
+ // `contextPressure` projection. The whole meter is a click target that
1361
+ // toggles the breakdown panel (`_paintContextPanel`). Renders nothing until
1362
+ // both a numerator and a capacity are known; the fill shifts toward the
1363
+ // warning/error palette as occupancy climbs.
1364
+ _contextMeterGeometry(y, leftFloor, rightLimit) {
1365
+ const meter = this.contextMeter
1366
+ if (!meter) return null
1367
+ const used = formatTokens(meter.usedTokens)
1368
+ const cap = formatTokens(meter.contextWindow)
1369
+ const pct = meter.percent
1370
+ const available = rightLimit - leftFloor
1371
+ if (available < 6) return null
1372
+ const barW = Math.max(3, Math.min(12, Math.floor(available * 0.4)))
1373
+ const text = ' ' + used + '/' + cap + ' ' + pct + '%'
1374
+ const total = displayWidth('ctx') + 1 + barW + displayWidth(text)
1375
+ const x0 = rightLimit - total
1376
+ if (x0 < leftFloor) return null
1377
+ return {
1378
+ x0, y, total, barW, used, cap, pct,
1379
+ fillColor: pct >= 100 ? THEME.error : pct >= 90 ? THEME.warning : THEME.primary,
1380
+ }
1381
+ }
1382
+
1383
+ _paintContextMeter(screen, y, geom) {
1384
+ const t = THEME
1385
+ let x = geom.x0
1386
+ x = screen.text(x, y, 'ctx', makeStyle({ fg: t.textMuted, bold: true, bg: t.background }))
1387
+ x += 1
1388
+ const fillW = Math.min(geom.barW, Math.max(0, Math.round(geom.pct / 100 * geom.barW)))
1389
+ for (let i = 0; i < geom.barW; i++) {
1390
+ if (i < fillW) {
1391
+ screen.text(x + i, y, '█', makeStyle({ fg: mixColor(geom.fillColor, t.accent, Math.min(1, i / Math.max(1, geom.barW - 1))), bg: t.background }))
1392
+ } else {
1393
+ screen.text(x + i, y, '░', makeStyle({ fg: t.borderSubtle, bg: t.background }))
1394
+ }
1395
+ }
1396
+ x += geom.barW
1397
+ screen.text(x, y, ' ' + geom.used + '/' + geom.cap + ' ' + geom.pct + '%',
1398
+ makeStyle({ fg: geom.pct >= 90 ? geom.fillColor : t.textMuted, bg: t.background }))
1399
+ this.addHitRegion('context-meter', geom.x0, y, geom.total, 1)
1400
+ }
1401
+
1402
+ // Click-open breakdown panel, ported from the web ContextMeter dialog:
1403
+ // headline occupancy, the current/limit reading, an occupancy bar whose
1404
+ // colored parts are proportioned by the heuristic `contextBreakdown`
1405
+ // composition (system prompt, tools, messages), and a per-part legend. The
1406
+ // bar's overall length stays the provider-exact percent; the breakdown only
1407
+ // proportions its colored parts (a zero-width part is dropped).
1408
+ _paintContextPanel(screen, cols, rows) {
1409
+ const t = THEME
1410
+ const meter = this.contextMeter
1411
+ if (!meter) return
1412
+ const w = Math.min(46, cols - 4)
1413
+ const h = meter.breakdown ? 10 : 8
1414
+ const x0 = Math.max(0, Math.floor((cols - w) / 2))
1415
+ const y0 = Math.max(1, Math.floor((rows - h) / 2) - 2)
1416
+ const box = makeStyle({ fg: t.text, bg: t.backgroundElement })
1417
+ const border = makeStyle({ fg: t.border, bg: t.backgroundElement })
1418
+ for (let y = y0; y < y0 + h; y++) screen.fill(x0, y, w, ' ', box)
1419
+ for (let x = 0; x < w; x++) {
1420
+ screen.set(x0 + x, y0, '─', border)
1421
+ screen.set(x0 + x, y0 + h - 1, '─', border)
1422
+ }
1423
+ for (let y = 0; y < h; y++) {
1424
+ screen.set(x0, y0 + y, '│', border)
1425
+ screen.set(x0 + w - 1, y0 + y, '│', border)
1426
+ }
1427
+ screen.set(x0, y0, '┌', border); screen.set(x0 + w - 1, y0, '┐', border)
1428
+ screen.set(x0, y0 + h - 1, '└', border); screen.set(x0 + w - 1, y0 + h - 1, '┘', border)
1429
+ screen.text(x0 + 3, y0 + 1, 'context', makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
1430
+ screen.text(x0 + w - displayWidth('Esc close') - 3, y0 + 1, 'Esc close', makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
1431
+ screen.text(x0 + 3, y0 + 2, 'used ' + meter.percent + '% ~' + formatTokens(meter.usedTokens) + ' / ' + formatTokens(meter.contextWindow),
1432
+ makeStyle({ fg: t.text, bold: true, bg: t.backgroundElement }))
1433
+ const barX = x0 + 3
1434
+ const barW = w - 8
1435
+ const fillTotal = Math.min(barW, Math.max(0, Math.round(meter.percent / 100 * barW)))
1436
+ const parts = meter.breakdown
1437
+ ? [
1438
+ { tokens: meter.breakdown.systemTokens, color: t.contextSystem, label: 'system prompt' },
1439
+ { tokens: meter.breakdown.toolsTokens, color: t.contextTools, label: 'tools' },
1440
+ { tokens: meter.breakdown.messageTokens, color: t.contextMessages, label: 'messages' },
1441
+ ].filter((p) => p.tokens > 0)
1442
+ : null
1443
+ const partTotal = parts ? parts.reduce((sum, p) => sum + p.tokens, 0) : 0
1444
+ if (parts && partTotal > 0 && fillTotal > 0) {
1445
+ let cx = barX
1446
+ for (const part of parts) {
1447
+ const partW = Math.max(1, Math.round(part.tokens / partTotal * fillTotal))
1448
+ screen.fill(cx, y0 + 3, Math.min(partW, barX + fillTotal - cx), '█', makeStyle({ fg: part.color, bg: t.backgroundElement }))
1449
+ cx += partW
1450
+ }
1451
+ screen.fill(Math.min(cx, barX + fillTotal), y0 + 3, Math.max(0, barX + barW - Math.min(cx, barX + fillTotal)), '░', makeStyle({ fg: t.borderSubtle, bg: t.backgroundElement }))
1452
+ } else {
1453
+ screen.fill(barX, y0 + 3, fillTotal, '█', makeStyle({ fg: t.primary, bg: t.backgroundElement }))
1454
+ screen.fill(barX + fillTotal, y0 + 3, barW - fillTotal, '░', makeStyle({ fg: t.borderSubtle, bg: t.backgroundElement }))
1455
+ }
1456
+ if (parts && partTotal > 0) {
1457
+ let yy = y0 + 5
1458
+ for (const part of parts) {
1459
+ screen.text(x0 + 3, yy, '■', makeStyle({ fg: part.color, bg: t.backgroundElement }))
1460
+ screen.text(x0 + 6, yy, part.label, makeStyle({ fg: t.text, bg: t.backgroundElement }))
1461
+ screen.text(x0 + 26, yy, '~' + formatTokens(part.tokens), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
1462
+ yy++
1463
+ }
1464
+ } else {
1465
+ screen.text(x0 + 3, y0 + 5, 'composition unavailable', makeStyle({ fg: t.textMuted, italic: true, bg: t.backgroundElement }))
1466
+ }
1467
+ }
1468
+
1469
+ // Settings dialog. Views that carry a left menu (Main / Model) render a
1470
+ // menu column on the left — Tab switches the active entry, clicking works
1471
+ // too; views without a menu (choice lists, session manager, model picker)
1472
+ // keep the classic single-column layout.
1473
+ _paintSettings(screen, cols, rows) {
1474
+ const t = THEME
1475
+ const menu = Array.isArray(this.settingsMenu) ? this.settingsMenu : []
1476
+ const hasMenu = menu.length > 0
1477
+ const menuW = hasMenu ? 12 : 0
1478
+ const w = Math.min(hasMenu ? 84 : 68, cols - 4)
1479
+ // Display rows: group headers (kind 'header') break the list into
1480
+ // sections with a blank separator line; every selectable item is one
1481
+ // compact row. Headers are never selectable and carry no hit region.
1482
+ const display = []
1483
+ for (let i = 0; i < this.settingsItems.length; i++) {
1484
+ const item = this.settingsItems[i]
1485
+ if (item.kind === 'header') {
1486
+ if (display.length > 0) display.push({ blank: true })
1487
+ display.push({ header: item })
1488
+ } else {
1489
+ display.push({ item, index: i })
1490
+ }
1491
+ }
1492
+ const h = Math.max(9, Math.min(rows - 4, display.length + 6))
1493
+ const contentH = h - 6
1494
+ const x0 = Math.max(0, Math.floor((cols - w) / 2))
1495
+ const y0 = Math.max(0, Math.floor((rows - h) / 2))
1496
+ const box = makeStyle({ fg: t.text, bg: t.backgroundElement })
1497
+ const border = makeStyle({ fg: t.border, bg: t.backgroundElement })
1498
+ for (let y = y0; y < y0 + h; y++) screen.fill(x0, y, w, ' ', box)
1499
+ for (let x = 0; x < w; x++) {
1500
+ screen.set(x0 + x, y0, '─', border)
1501
+ screen.set(x0 + x, y0 + h - 1, '─', border)
1502
+ }
1503
+ for (let y = 0; y < h; y++) {
1504
+ screen.set(x0, y0 + y, '│', border)
1505
+ screen.set(x0 + w - 1, y0 + y, '│', border)
1506
+ }
1507
+ screen.set(x0, y0, '┌', border); screen.set(x0 + w - 1, y0, '┐', border)
1508
+ screen.set(x0, y0 + h - 1, '└', border); screen.set(x0 + w - 1, y0 + h - 1, '┘', border)
1509
+ // Content area: everything right of the menu column.
1510
+ const cx = x0 + menuW
1511
+ const cw = w - menuW
1512
+ if (hasMenu) {
1513
+ // Left menu column with a divider; the active entry is highlighted and
1514
+ // every entry is a click target.
1515
+ screen.set(x0 + menuW, y0, '┬', border)
1516
+ for (let yy = y0 + 1; yy < y0 + h - 1; yy++) screen.set(x0 + menuW, yy, '│', border)
1517
+ screen.set(x0 + menuW, y0 + h - 1, '┴', border)
1518
+ screen.text(x0 + 2, y0 + 1, 'MENU', makeStyle({ fg: t.textMuted, bold: true, bg: t.backgroundElement }))
1519
+ for (let i = 0; i < menu.length; i++) {
1520
+ const my = y0 + 3 + i
1521
+ if (my >= y0 + h - 1) break
1522
+ const active = i === this.settingsMenuIndex
1523
+ const menuStyle = makeStyle({
1524
+ fg: active ? t.primary : t.textMuted,
1525
+ bold: active,
1526
+ bg: active ? t.backgroundPanel : t.backgroundElement,
1527
+ })
1528
+ if (active) screen.fill(x0 + 1, my, menuW - 1, ' ', menuStyle)
1529
+ screen.text(x0 + 2, my, (active ? '▸ ' : ' ') + truncateWidth(menu[i].label, menuW - 5), menuStyle)
1530
+ this.addHitRegion('settings-menu', x0 + 1, my, menuW - 1, 1, { menuIndex: i })
1531
+ }
1532
+ }
1533
+ screen.text(cx + 3, y0 + 1, truncateWidth(this.settingsTitle, cw - 6), makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
1534
+
1535
+ screen.text(cx + 3, y0 + 2, truncateWidth(this.settingsSubtitle || 'Shared with DeepSeek Harness WebUI', cw - 6), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
1536
+ // Scroll window with explicit scroll offset (wheel scrolling support).
1537
+ // The scroll offset is clamped to ensure the window always shows content.
1538
+ const maxScroll = Math.max(0, display.length - contentH)
1539
+ const scrollOffset = Math.max(0, Math.min(this.settingsScrollOffset, maxScroll))
1540
+ const selRow = display.findIndex((entry) => entry.index === this.settingsSelection)
1541
+ // Auto-scroll to keep the selected row visible when keyboard navigation moves it
1542
+ let first = scrollOffset
1543
+ if (selRow >= 0) {
1544
+ if (selRow < first) first = selRow
1545
+ if (selRow >= first + contentH) first = selRow - contentH + 1
1546
+ }
1547
+ first = Math.max(0, Math.min(first, maxScroll))
1548
+ this.settingsScrollOffset = first
1549
+ let y = y0 + 4
1550
+ for (let r = first; r < Math.min(display.length, first + contentH); r++) {
1551
+ const entry = display[r]
1552
+ if (entry.header) {
1553
+ screen.text(cx + 3, y, truncateWidth(String(entry.header.label).toUpperCase(), cw - 6), makeStyle({ fg: t.textMuted, bold: true, bg: t.backgroundElement }))
1554
+ } else if (!entry.blank) {
1555
+ const item = entry.item
1556
+ const i = entry.index
1557
+ const selected = i === this.settingsSelection
1558
+ const style = makeStyle({
1559
+ fg: item.disabled ? t.textMuted : selected ? t.text : t.textMuted,
1560
+ bg: selected ? t.backgroundPanel : t.backgroundElement,
1561
+ bold: selected && !item.disabled,
1562
+ })
1563
+ if (selected) screen.fill(cx + 2, y, cw - 4, ' ', style)
1564
+ this.addHitRegion('settings-item', cx + 2, y, cw - 4, 1, { settingsIndex: i })
1565
+ screen.text(cx + 3, y, selected ? '› ' : ' ', makeStyle({ fg: item.disabled ? t.textMuted : t.primary, bg: style.bg }))
1566
+ const draft = this.settingsSecret ? '•'.repeat(Array.from(this.settingsDraft).length) : this.settingsDraft
1567
+ const confirming = this.settingsConfirm?.item === item
1568
+ const confirmHint = confirming && this.settingsConfirm.item.kind === 'session' ? 'Ctrl+D again to delete' : 'Y confirm · N cancel'
1569
+ const value = confirming ? confirmHint : this.settingsEditing === i ? draft + '█' : item.value
1570
+ const valueStyle = makeStyle({ fg: confirming ? t.warning : selected && !item.disabled ? t.secondary : t.textMuted, bg: style.bg })
1571
+ // Nested rows (e.g. a provider's models) indent one step per level.
1572
+ const indent = (item.indent ?? 0) * 3
1573
+ if (item.kind === 'session') {
1574
+ // Session rows right-align the timestamp and clamp the title so the
1575
+ // name and time columns stay clearly separated instead of running
1576
+ // together at a fixed 36-column boundary.
1577
+ const valueText = truncateWidth(value, cw - 41)
1578
+ const valueX = cx + cw - 5 - displayWidth(valueText)
1579
+ const labelMax = Math.max(6, Math.min(29, valueX - (cx + 6) - 3))
1580
+ screen.text(cx + 6, y, truncateWidth(item.label, labelMax), style)
1581
+ screen.text(valueX, y, valueText, valueStyle)
1582
+ } else {
1583
+ screen.text(cx + 6 + indent, y, truncateWidth(item.label, Math.max(6, 29 - indent)), style)
1584
+ screen.text(cx + 36, y, truncateWidth(value, cw - 41), valueStyle)
1585
+ }
1586
+ }
1587
+ y++
1588
+ }
1589
+ const footer = this.settingsEditing !== null
1590
+ ? 'Enter save · Esc cancel'
1591
+ : hasMenu
1592
+ ? '↑/↓ move · Tab menu · Enter select · Esc back'
1593
+ : '↑/↓ move · Enter select · Esc back'
1594
+ screen.text(cx + 3, y0 + h - 2, footer, makeStyle({ fg: this.settingsConfirm ? t.warning : t.textMuted, bg: t.backgroundElement }))
1595
+ }
1596
+
1597
+ _paintHelp(screen, cols, rows) {
1598
+ const t = THEME
1599
+ const w = Math.min(64, cols - 4)
1600
+ const h = 22
1601
+ const x0 = Math.max(0, Math.floor((cols - w) / 2))
1602
+ const y0 = Math.max(0, Math.floor((rows - h) / 2))
1603
+ const box = makeStyle({ fg: t.text, bg: t.backgroundElement })
1604
+ const border = makeStyle({ fg: t.border, bg: t.backgroundElement })
1605
+ for (let y = y0; y < y0 + h; y++) screen.fill(x0, y, w, ' ', box)
1606
+ for (let i = 0; i < w; i++) {
1607
+ screen.set(x0 + i, y0, '─', border)
1608
+ screen.set(x0 + i, y0 + h - 1, '─', border)
1609
+ }
1610
+ for (let i = 0; i < h; i++) {
1611
+ screen.set(x0, y0 + i, '│', border)
1612
+ screen.set(x0 + w - 1, y0 + i, '│', border)
1613
+ }
1614
+ screen.set(x0, y0, '┌', border); screen.set(x0 + w - 1, y0, '┐', border)
1615
+ screen.set(x0, y0 + h - 1, '└', border); screen.set(x0 + w - 1, y0 + h - 1, '┘', border)
1616
+ screen.text(x0 + 2, y0 + 1, 'DeepSeek Harness TUI — help', makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
1617
+ const rows2 = [
1618
+ ['Enter', 'send message'],
1619
+ ['Ctrl+Enter', 'insert newline'],
1620
+ ['Ctrl+C', 'cancel running turn; press again to quit'],
1621
+ ['Ctrl+P', 'open settings'],
1622
+ ['Ctrl+E', 'toggle thinking slider'],
1623
+ ['Tab', 'cycle thinking intensity'],
1624
+ ['Ctrl+N', 'new session'],
1625
+ ['Ctrl+D', 'delete session in Manage sessions (press twice)'],
1626
+ ['PgUp / PgDn', 'scroll transcript'],
1627
+ ['Up / Down', 'caret up/down; history at edges'],
1628
+ ['Mouse drag', 'select text; right-click copies'],
1629
+ ['Wheel', 'scroll transcript / settings'],
1630
+ ['Esc', 'close help / cancel'],
1631
+ ]
1632
+ let yy = y0 + 3
1633
+ for (const [key, desc] of rows2) {
1634
+ screen.text(x0 + 3, yy, key, makeStyle({ fg: t.success, bg: t.backgroundElement }))
1635
+ screen.text(x0 + 3 + 14, yy, desc, makeStyle({ fg: t.text, bg: t.backgroundElement }))
1636
+ yy++
1637
+ }
1638
+ yy++
1639
+ screen.text(x0 + 3, yy, 'Commands:', makeStyle({ fg: t.accent, bold: true, bg: t.backgroundElement }))
1640
+ yy++
1641
+ for (const cmd of ['/help /settings /new /resume <id>', '/model <id> /provider <route> /clear /cancel /quit']) {
1642
+ screen.text(x0 + 3, yy, cmd, makeStyle({ fg: t.text, bg: t.backgroundElement }))
1643
+ yy++
1644
+ }
1645
+ }
1646
+ }