dsh-oc-tui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +21 -563
  2. package/bin/mcp-sse-bridge.mjs +499 -499
  3. package/docs/assets/conversation.png +0 -0
  4. package/docs/assets/cover.png +0 -0
  5. package/docs/assets/home.png +0 -0
  6. package/docs/assets/settings.png +0 -0
  7. package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
  8. package/docs/user-guide.md +616 -0
  9. package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
  10. package/dsh-plugin.json +69 -69
  11. package/lib/boot-block.js +129 -0
  12. package/lib/diagnostics.js +162 -120
  13. package/lib/dsh-version.js +161 -0
  14. package/lib/git-branch.js +157 -157
  15. package/lib/i18n.js +283 -283
  16. package/lib/image.js +2 -1
  17. package/lib/index.js +5183 -4812
  18. package/lib/locales/ar.js +670 -635
  19. package/lib/locales/de.js +639 -604
  20. package/lib/locales/en.js +35 -0
  21. package/lib/locales/es.js +639 -604
  22. package/lib/locales/fr.js +639 -604
  23. package/lib/locales/id.js +641 -606
  24. package/lib/locales/it.js +639 -604
  25. package/lib/locales/ja.js +639 -604
  26. package/lib/locales/ko.js +639 -604
  27. package/lib/locales/nl.js +639 -604
  28. package/lib/locales/pl.js +653 -618
  29. package/lib/locales/pt-BR.js +639 -604
  30. package/lib/locales/ru.js +653 -618
  31. package/lib/locales/tr.js +639 -604
  32. package/lib/locales/vi.js +641 -606
  33. package/lib/locales/zh-CN.js +640 -605
  34. package/lib/locales/zh-TW.js +640 -605
  35. package/lib/markdown.js +101 -27
  36. package/lib/mcp-monitor.js +189 -0
  37. package/lib/mcp-skill.js +93 -93
  38. package/lib/mcp.js +865 -838
  39. package/lib/model-route.js +66 -0
  40. package/lib/providers.js +712 -712
  41. package/lib/screenshot.js +140 -0
  42. package/lib/splash-opencode.js +29 -26
  43. package/lib/startup.js +66 -48
  44. package/lib/term.js +53 -11
  45. package/lib/themes.js +101 -0
  46. package/lib/ui.js +685 -443
  47. package/lib/updates.js +5 -2
  48. package/lib/web-settings.js +52 -21
  49. package/package.json +160 -146
  50. package/skills/mcp-import/SKILL.md +216 -216
package/lib/ui.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // The TUI view model and renderer: opencode-inspired layout with a dark
2
2
  // theme, session sidebar, chat transcript, input row, and status bar.
3
+ import { stripVTControlCharacters } from 'node:util'
3
4
  import { Screen, makeStyle, mergeStyle, hexToAnsi } from './term.js'
4
5
  import { renderMarkdown } from './markdown.js'
6
+ import { DEFAULT_THEME, THEME, getTheme, themeId as normalizeThemeId } from './themes.js'
5
7
  import { sha16 } from './image.js'
6
8
  import { displayWidth, truncateWidth, timeString, toolSummary, roughTokens, formatTokens, wrapText, shortenPath, decodeDataUrl, reasoningText } from './util.js'
7
9
  import { paintSplashOpenCode } from './splash-opencode.js'
@@ -17,61 +19,7 @@ const MERMAID_INDENT = ' '
17
19
  const MERMAID_INDENT_WIDTH = displayWidth(MERMAID_INDENT)
18
20
  const MERMAID_MARGIN = 4
19
21
 
20
- // DeepSeek brand palette: deep blue accents on a blue-tinted dark canvas.
21
- export const THEME = {
22
- primary: '4d6bfe', // DeepSeek blue
23
- secondary: '6c9cff', // light blue
24
- accent: '7c9cff', // light blue accent
25
- error: 'e06c75',
26
- warning: 'e8c468', // soft gold (no orange)
27
- warningDim: '806c39', // dimmed gold: resting cells of the flowing composer frame
28
- success: '7fd88f',
29
- info: '56b6c2',
30
- text: 'f0f4ff', // blue-white text
31
- textMuted: '8a93a8',
32
- background: '0a0e18', // blue-tinted dark background
33
- backgroundPanel: '111a2c',
34
- backgroundElement: '1b2740',
35
- border: '3d4d73',
36
- borderSubtle: '2b3a5c',
37
- markdownHeading: '7c9cff',
38
- markdownLinkText: '6c9cff',
39
- markdownCode: '7fd88f',
40
- markdownCodeBlock: 'f0f4ff',
41
- markdownBlockQuote: '9fb0d8',
42
- markdownListItem: '4d6bfe',
43
- markdownHorizontalRule: '46547a',
44
- codeBg: '1b2740',
45
- thinking: '9aa6c2',
46
- reminder: 'c5bdf7', // system-reminder row text (pale violet)
47
- imageChipBg: 'd97706', // orange emphasis for pasted-image markers
48
- imageChipText: '1a0d00', // text on the orange image chip
49
- compaction: '95d8c0', // compaction row text (pale mint)
50
- inbox: '9fd0e8', // pending-inbox row text (pale cyan)
51
- steering: '9fd0e8', // inline badge on a user turn that arrived as steering
52
- systemPrompt: 'b9c4dd', // system-prompt row text (cool gray)
53
- // Context-meter segments (web ContextMeter port): heuristic composition
54
- // shares get distinct hues so the breakdown bar reads at a glance.
55
- contextSystem: '7fd88f',
56
- contextTools: 'e8c468',
57
- contextMessages: '56b6c2',
58
- // Mouse text-selection highlight (left-drag select, right-click copy).
59
- selection: '3153b8',
60
- // Syntax highlighting palette (highlight.js token classes), tuned for the
61
- // blue-tinted dark canvas and the code background.
62
- syntaxKeyword: 'c792ea',
63
- syntaxString: '9ece8f',
64
- syntaxNumber: 'e5b567',
65
- syntaxComment: '6b7490',
66
- syntaxFunction: '82aaff',
67
- syntaxType: 'e5c07b',
68
- tableBorder: '41528a',
69
- // Transcript markers: the pointer that opens a user turn carries the brand
70
- // blue, the assistant's own label a quiet one, so a long transcript reads at
71
- // a glance without any frame around either.
72
- userPointer: '4d6bfe',
73
- assistantLabel: '9aa6c2',
74
- }
22
+ export { THEME } from './themes.js'
75
23
 
76
24
  // Flowing activity indicator frames (clockwise Braille flow).
77
25
  const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
@@ -82,11 +30,6 @@ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇',
82
30
  const WORKSPACE_LABEL_KEY = 'header.workspace'
83
31
  const WORKSPACE_MIN_PATH = 8
84
32
 
85
- // Cached styles for the composer's flowing (marching-ants) frame: a bright
86
- // gold head, a warning-gold body, and the dimmed resting tone. Built once
87
- // because the whole border re-resolves its tone on every animation frame.
88
- let flowFramePalette = null
89
-
90
33
  // Composer command palette: the command stays literal, the description is a
91
34
  // catalog key resolved at paint time (so a language switch is live).
92
35
  const COMMAND_HINTS = [
@@ -116,14 +59,19 @@ const RAIL_MIN_COLS = 140
116
59
  const RAIL_WIDTH = 34
117
60
 
118
61
  // Usable text width inside the composer box, derived from the same geometry
119
- // render() uses (composerX = sidebarW + 2 or 1; the box spans to the main
120
- // area's right edge, with two columns of padding on each side). Shared so the
62
+ // render() uses, including the centered homepage and its width cap. Shared so the
121
63
  // approval prompt's line count and its painting can never disagree.
122
- function composerTextWidth(cols, sidebarW, railW = 0) {
123
- const composerX = sidebarW > 0 ? sidebarW + 2 : 1
64
+ function composerGeometry(cols, sidebarW, railW = 0, home = false) {
65
+ const left = sidebarW > 0 ? sidebarW + 2 : 2
124
66
  const rightEdge = cols - (railW > 0 ? railW + 1 : 0)
125
- const composerW = rightEdge - composerX - 1
126
- return Math.max(1, composerW - 4)
67
+ const available = Math.max(5, rightEdge - left - 2)
68
+ const composerW = home ? Math.min(76, available) : available
69
+ const composerX = home ? left + Math.floor((available - composerW) / 2) : left
70
+ return { composerX, composerW }
71
+ }
72
+
73
+ function composerTextWidth(cols, sidebarW, railW = 0, home = false) {
74
+ return Math.max(1, composerGeometry(cols, sidebarW, railW, home).composerW - 4)
127
75
  }
128
76
 
129
77
  // Width of the region an overlay may occupy: every column left of the rail's
@@ -243,6 +191,63 @@ function clipSegs(segs, width) {
243
191
  return out
244
192
  }
245
193
 
194
+ function tintMarkdownSegment(seg, style, theme, replace = false) {
195
+ if (!seg.style) return { ...seg, style: makeStyle(style) }
196
+ if (seg.style?.bg === theme.codeBg) return seg
197
+ return { ...seg, style: replace ? makeStyle(style) : mergeStyle(seg.style, style) }
198
+ }
199
+
200
+ function transcriptText(text) {
201
+ return stripVTControlCharacters(String(text ?? ''))
202
+ .replace(/\r\n?/g, '\n')
203
+ .replace(/\t/g, ' ')
204
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
205
+ }
206
+
207
+ function indentTranscriptRow(row, amount) {
208
+ // Protocol images retain the old two-column origin; text rows arrive bare.
209
+ return {
210
+ ...row,
211
+ segs: [{ text: ' '.repeat(amount), style: null }, ...row.segs],
212
+ ...(row.image ? { image: { ...row.image, x: amount + row.image.x - 2 } } : {}),
213
+ }
214
+ }
215
+
216
+ function transcriptPanel(body, width, border, theme, tag) {
217
+ const rows = [{ segs: [] }, ...body, { segs: [] }]
218
+ return rows.map((row) => ({
219
+ ...indentTranscriptRow(row, 3),
220
+ segs: [
221
+ { text: '┃', style: makeStyle({ fg: border }) },
222
+ { text: ' ', style: null },
223
+ ...clipSegs(row.segs, Math.max(1, width - 5)),
224
+ ],
225
+ background: theme.backgroundPanel,
226
+ ...tag,
227
+ }))
228
+ }
229
+
230
+ // Catalog key for a context note's marker row. An unknown label (a context
231
+ // kind a newer host introduced) falls back to the label itself, which at least
232
+ // names the subsystem that produced it.
233
+ const NOTE_LABEL_KEYS = {
234
+ compaction: 'note.compaction',
235
+ 'system-reminder': 'note.systemReminder',
236
+ 'runtime-context': 'note.runtimeContext',
237
+ }
238
+
239
+ function noteLabelKey(label) {
240
+ return NOTE_LABEL_KEYS[label] ?? String(label ?? 'context')
241
+ }
242
+
243
+ // A row that paints nothing: no cell text and no image. Block renderers pad
244
+ // their own edges with these, and the transcript collapses them into the one
245
+ // gutter row between two blocks (see App._blockRows).
246
+ function isBlankRow(row) {
247
+ if (!row || row.image) return false
248
+ return row.segs.every((seg) => !seg?.text || seg.text.trim() === '')
249
+ }
250
+
246
251
  // A flat, unboxed block: the marker row comes first, body lines hang under it
247
252
  // at `hang` columns in the block's own color, and every row is tagged so the
248
253
  // whole block stays one click target. Transcript rows are plain segment lists
@@ -332,14 +337,23 @@ export function makeBlock(kind, data = {}) {
332
337
  }
333
338
 
334
339
  // Classify a non-user context message into a labeled collapsible note.
335
- // `system-reminder` frames and compaction checkpoints get labeled boxes;
336
- // everything else stays a plain system line.
340
+ // `system-reminder` frames, the runtime-context snapshot and compaction
341
+ // checkpoints get labeled boxes; everything else stays a plain system line.
342
+ //
343
+ // These are the messages the harness assembles for the model rather than
344
+ // something the human typed, and the first two restate state on every change
345
+ // (the working directory, the approval policy) — so they render collapsed, and
346
+ // `contextDetails: hide` (a developer setting) drops them entirely.
347
+ const CONTEXT_LABELS = new Set(['system-reminder', 'runtime-context'])
348
+
337
349
  export function noteFromContext(src, text) {
338
350
  if (typeof text !== 'string') return null
339
351
  if (text.includes('<system-reminder>')) {
340
352
  return { label: 'system-reminder', text: stripTag(text, 'system-reminder') }
341
353
  }
342
- if (src && src.kind === 'plugin' && src.plugin === 'compact') {
354
+ if (src?.kind === 'runtime-context') return { label: 'runtime-context', text }
355
+ if (src && ((src.kind === 'plugin' && src.plugin === 'compact')
356
+ || src.kind === 'compact-checkpoint' || src.kind === 'compact-summary')) {
343
357
  return { label: 'compaction', text: stripTag(text, 'compacted-summary') }
344
358
  }
345
359
  return null
@@ -375,17 +389,22 @@ function inboxPreview(text, width) {
375
389
  // The application view state + layout + painting. It is dsh-agnostic: the
376
390
  // plugin feeds it events and key presses.
377
391
  export class App {
378
- constructor(terminal, { sidebarWidth = 18 } = {}) {
392
+ constructor(terminal, { sidebarWidth = 26 } = {}) {
379
393
  this.terminal = terminal
380
394
  this.sidebarWidth = sidebarWidth
395
+ this.themeId = DEFAULT_THEME
396
+ this.theme = THEME
381
397
  this.blocks = []
382
- this.assistantHeaderPending = true
398
+ this._turnStartedAt = undefined
399
+ this._turnAssistant = null
400
+ this._pendingModelChanges = []
383
401
  this.title = 'DeepSeek Harness'
384
402
  this.titleScreen = true
385
403
  this.workingDirectory = ''
386
404
  this.gitBranch = ''
387
405
  this.sessionId = ''
388
406
  this.model = ''
407
+ this._requestModel = undefined
389
408
  this.provider = ''
390
409
  this.status = 'idle' // idle | running
391
410
  this.usage = { input: 0, output: 0 }
@@ -409,6 +428,9 @@ export class App {
409
428
  this.gitBranches = null // git branch switcher state, or null
410
429
  this.inputText = ''
411
430
  this.inputCursor = 0
431
+ this.commandSelection = 0
432
+ this._commandQuery = ''
433
+ this._commandDismissed = false
412
434
  this.inputImages = [] // pasted images: [{ status, ref, mediaType, label }]
413
435
  this.history = []
414
436
  this.historyIndex = -1
@@ -427,6 +449,11 @@ export class App {
427
449
  this.settingsMenuIndex = 0 // active left-menu entry (Tab switches it)
428
450
  this.settingsScrollOffset = 0 // scroll offset for the settings window (wheel scroll support)
429
451
  this.toast = null // { text, level }
452
+ this.cornerNotice = null
453
+ this._cornerNoticeTimer = null
454
+ this.mcpNotice = null // { name, status } — the corner notice
455
+ this._mcpNotices = []
456
+ this._mcpNoticeTimer = null
430
457
  this.effortSlider = null // { levels: [{id, name}], current } — the current model's real reasoning levels
431
458
  this.effortSliderVisible = false
432
459
  this.pendingApproval = null // { toolName, summary, origin, details, risk, settle }
@@ -442,6 +469,9 @@ export class App {
442
469
  this.inboxExpanded = false
443
470
  this.systemPromptText = '' // last rendered system-prompt text (dedupe)
444
471
  this.systemPromptBlock = null
472
+ // 'show' renders the context boxes collapsed; 'hide' drops them from the
473
+ // transcript entirely (developer setting, see App.setContextDetails).
474
+ this.contextDetails = 'show'
445
475
  this.focusedRegion = 'keyboard' // mouse hover temporarily owns focus
446
476
  this._lastHover = '' // last hovered target (focus-follows-mouse cache)
447
477
  this.hitRegions = [] // topmost interactive regions from the latest render
@@ -484,6 +514,15 @@ export class App {
484
514
  this._lastTransWidth = 80
485
515
  }
486
516
 
517
+ setTheme(value) {
518
+ const id = normalizeThemeId(value)
519
+ if (id === this.themeId) return false
520
+ this.themeId = id
521
+ this.theme = getTheme(id)
522
+ this._blockLineCache.clear()
523
+ return true
524
+ }
525
+
487
526
  addHitRegion(kind, x, y, width, height = 1, data = {}) {
488
527
  if (width <= 0 || height <= 0) return
489
528
  this.hitRegions.push({ kind, x, y, width, height, ...data })
@@ -640,8 +679,8 @@ export class App {
640
679
  const cell = row[x]
641
680
  if (!cell) continue
642
681
  cell.style = cell.style
643
- ? mergeStyle(cell.style, { bg: THEME.selection })
644
- : makeStyle({ bg: THEME.selection })
682
+ ? mergeStyle(cell.style, { bg: this.theme.selection })
683
+ : makeStyle({ bg: this.theme.selection })
645
684
  }
646
685
  }
647
686
  }
@@ -680,12 +719,23 @@ export class App {
680
719
  this.title = title
681
720
  this.untitled = title === '' || title === undefined
682
721
  }
683
- if (model !== undefined) this.model = model
722
+ if (model !== undefined) {
723
+ if (model !== this.model) this._requestModel = undefined
724
+ this.model = model
725
+ }
684
726
  if (provider !== undefined) this.provider = provider
685
727
  }
686
728
 
729
+ setRequestModel(model) {
730
+ if (typeof model === 'string') this._requestModel = model
731
+ }
732
+
687
733
  setStatus(status) {
734
+ if (status === 'idle' && this.status !== 'idle') this.finalizeAssistant()
688
735
  this.status = status
736
+ if (status === 'idle' && this._pendingModelChanges.length) {
737
+ for (const block of this._pendingModelChanges.splice(0)) this._insertModelChange(block)
738
+ }
689
739
  }
690
740
 
691
741
  // The reasoning-effort slider data: the current model's ACTUAL selectable
@@ -801,7 +851,7 @@ export class App {
801
851
  // from the web chat stats strip. Every group drops out whole when it has no
802
852
  // data, and a session whose steps all settled without billing keeps its
803
853
  // counts without a zero-token group.
804
- _statsGroups() {
854
+ _statsGroups({ billing = true } = {}) {
805
855
  const m = this.metrics ?? {}
806
856
  const groups = []
807
857
  if ((m.steps ?? 0) > 0) {
@@ -815,7 +865,7 @@ export class App {
815
865
  if ((m.decodeMs ?? 0) > 0) speeds.push(tr('stats.tokensPerSecond', { value: formatMetric(m.decodeTokens / (m.decodeMs / 1000)) }))
816
866
  if (speeds.length > 0) groups.push(speeds.join(' · '))
817
867
  }
818
- if ((m.billedInputTokens ?? 0) > 0 || (m.outputTokens ?? 0) > 0) {
868
+ if (billing && ((m.billedInputTokens ?? 0) > 0 || (m.outputTokens ?? 0) > 0)) {
819
869
  if (m.cacheHitRate !== undefined) groups.push(tr('stats.cacheRate', { percent: m.cacheHitRate }))
820
870
  groups.push(tr('stats.inOut', { input: formatTokens(m.billedInputTokens ?? 0), output: formatTokens(m.outputTokens ?? 0) }))
821
871
  }
@@ -1037,18 +1087,44 @@ export class App {
1037
1087
  this._maybeFollow()
1038
1088
  }
1039
1089
 
1090
+ addModelChange(from, to, { beforeLatestUser = false, defer = false } = {}) {
1091
+ const oldModel = transcriptText(from?.model).trim()
1092
+ const newModel = transcriptText(to?.model).trim()
1093
+ const oldProvider = transcriptText(from?.provider).trim()
1094
+ const newProvider = transcriptText(to?.provider).trim()
1095
+ if (!oldModel || !newModel || (oldModel === newModel && oldProvider === newProvider)) return
1096
+ const block = makeBlock('model-change', {
1097
+ from: { model: oldModel, provider: oldProvider },
1098
+ to: { model: newModel, provider: newProvider },
1099
+ })
1100
+ if (defer && this.status !== 'idle') this._pendingModelChanges.push(block)
1101
+ else this._insertModelChange(block, beforeLatestUser)
1102
+ }
1103
+
1104
+ _insertModelChange(block, beforeLatestUser = false) {
1105
+ this.detachInboxBlock()
1106
+ let index = this.blocks.length
1107
+ if (beforeLatestUser) {
1108
+ const user = this.blocks.findLastIndex((entry) => entry.kind === 'user')
1109
+ if (user >= 0 && !this.blocks.slice(user + 1).some((entry) => entry.kind === 'assistant' || entry.kind === 'tool')) index = user
1110
+ }
1111
+ this.blocks.splice(index, 0, block)
1112
+ this._maybeFollow()
1113
+ }
1114
+
1040
1115
  addNote(text, label = 'context') {
1041
1116
  this.detachInboxBlock()
1042
1117
  this.blocks.push(makeBlock('note', { text, label, collapsed: true }))
1043
1118
  this.scroll = 0
1044
1119
  }
1045
1120
 
1046
- addUser(text, { steering = false, images } = {}) {
1121
+ addUser(text, { steering = false, images, time = Date.now() } = {}) {
1047
1122
  this.detachInboxBlock()
1048
- const block = makeBlock('user', { text, steering })
1123
+ const block = makeBlock('user', { text, steering, time })
1049
1124
  if (images?.length) block.images = images
1050
1125
  this.blocks.push(block)
1051
- this.assistantHeaderPending = true
1126
+ this._turnStartedAt = time
1127
+ this._turnAssistant = null
1052
1128
  this.scroll = 0
1053
1129
  }
1054
1130
 
@@ -1067,17 +1143,33 @@ export class App {
1067
1143
  return false
1068
1144
  }
1069
1145
 
1146
+ // Badge the most recent user block as a steering turn. A steered prompt is
1147
+ // drawn by the composer first and echoed by the loop only when the inbox
1148
+ // claim happens, so the badge is applied to that block rather than to a new
1149
+ // one — the two would otherwise sit next to each other and read as a repeat.
1150
+ markLastUserSteering() {
1151
+ for (let i = this.blocks.length - 1; i >= 0; i--) {
1152
+ const b = this.blocks[i]
1153
+ if (b.kind !== 'user') continue
1154
+ if (b.steering) return true
1155
+ b.steering = true
1156
+ b.rev = (b.rev ?? 0) + 1
1157
+ return true
1158
+ }
1159
+ return false
1160
+ }
1161
+
1070
1162
  // Image render options for the host: the probed protocol, the size budget
1071
1163
  // (cells), the probed cell pixel size, and the canvas background the
1072
1164
  // renderer composes transparent pixels over.
1073
1165
  imageRenderOptions() {
1074
1166
  return {
1075
1167
  protocol: this.graphicsProtocol,
1076
- maxCellsW: Math.max(8, Math.min(72, this._lastTransWidth - 6)),
1168
+ maxCellsW: Math.max(1, Math.min(72, this._lastTransWidth - 9)),
1077
1169
  maxCellsH: this.imageMaxRows,
1078
1170
  cellW: this.imageCellW,
1079
1171
  cellH: this.imageCellH,
1080
- bgHex: THEME.background,
1172
+ bgHex: this.theme.background,
1081
1173
  }
1082
1174
  }
1083
1175
 
@@ -1088,7 +1180,7 @@ export class App {
1088
1180
  // until the session is reopened.
1089
1181
  imageRenderSignature() {
1090
1182
  const o = this.imageRenderOptions()
1091
- return [o.protocol, o.maxCellsW, o.maxCellsH, o.cellW, o.cellH].join('|')
1183
+ return [o.protocol, o.maxCellsW, o.maxCellsH, o.cellW, o.cellH, o.bgHex].join('|')
1092
1184
  }
1093
1185
 
1094
1186
  // A render landed for an image key: cache it and refresh every block that
@@ -1140,7 +1232,7 @@ export class App {
1140
1232
  // Chip mode: the settings turned pixels off entirely — images stay
1141
1233
  // placeholder chips and the pipeline never runs.
1142
1234
  if (this.imagePixels === false) {
1143
- out.push({ segs: [{ text: tr('image.attachment'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
1235
+ out.push({ segs: [{ text: tr('image.attachment'), style: makeStyle({ fg: this.theme.textMuted, italic: true }) }] })
1144
1236
  return
1145
1237
  }
1146
1238
  // Geometry check: a finished render whose signature no longer matches the
@@ -1157,7 +1249,7 @@ export class App {
1157
1249
  result = undefined
1158
1250
  }
1159
1251
  if (!result) {
1160
- result = { state: 'loading' }
1252
+ result = { state: 'loading', sig }
1161
1253
  this._imageResults.set(key, result)
1162
1254
  }
1163
1255
  // Remember which blocks show this key so setImageResult can bump their
@@ -1175,11 +1267,11 @@ export class App {
1175
1267
  this._imagePendingSig.set(key, sig)
1176
1268
  this.onImageRequest?.(src)
1177
1269
  }
1178
- out.push({ segs: [{ text: tr('image.rendering'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
1270
+ out.push({ segs: [{ text: tr('image.rendering'), style: makeStyle({ fg: this.theme.textMuted, italic: true }) }] })
1179
1271
  return
1180
1272
  }
1181
1273
  if (result.state === 'error') {
1182
- out.push({ segs: [{ text: tr('image.renderFailed', { error: truncateWidth(result.error ?? tr('common.unknown'), Math.max(8, width - 28)) }), style: makeStyle({ fg: THEME.error }) }] })
1274
+ out.push({ segs: [{ text: tr('image.renderFailed', { error: truncateWidth(result.error ?? tr('common.unknown'), Math.max(8, width - 28)) }), style: makeStyle({ fg: this.theme.error }) }] })
1183
1275
  return
1184
1276
  }
1185
1277
  if (result.protocol === 'halfblock') {
@@ -1204,7 +1296,7 @@ export class App {
1204
1296
  // falling back to the inline renderings otherwise.
1205
1297
  _markdownLines(text, block, width, out) {
1206
1298
  const hooks = { mermaid: true, image: true }
1207
- for (const line of renderMarkdown(text, THEME, width, hooks)) {
1299
+ for (const line of renderMarkdown(text, this.theme, width, hooks)) {
1208
1300
  if (line && line.mermaid !== undefined) this._mermaidLines(line.mermaid, block, width, out)
1209
1301
  else if (line && line.image !== undefined) this._markdownImageLines(line.image, block, width, out)
1210
1302
  else out.push({ segs: line })
@@ -1236,14 +1328,14 @@ export class App {
1236
1328
  // block settles — the finished block invalidates the line cache and draws
1237
1329
  // the diagram then.
1238
1330
  if (!off && block?.streaming === true) {
1239
- for (const line of renderMarkdown('```mermaid\n' + code + '\n```', THEME, width)) {
1331
+ for (const line of renderMarkdown('```mermaid\n' + code + '\n```', this.theme, width)) {
1240
1332
  out.push({ segs: line })
1241
1333
  }
1242
1334
  return
1243
1335
  }
1244
1336
  if (!off) {
1245
1337
  if (engine === 'loading') {
1246
- out.push({ segs: [{ text: tr('image.mermaidRendering'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
1338
+ out.push({ segs: [{ text: tr('image.mermaidRendering'), style: makeStyle({ fg: this.theme.textMuted, italic: true }) }] })
1247
1339
  return
1248
1340
  }
1249
1341
  if (engine) {
@@ -1251,15 +1343,15 @@ export class App {
1251
1343
  if (art) {
1252
1344
  const budget = width - MERMAID_INDENT_WIDTH - MERMAID_MARGIN
1253
1345
  if (art.width <= budget) {
1254
- for (const segs of paintMermaidArt(art, THEME, MERMAID_INDENT)) out.push({ segs })
1346
+ for (const segs of paintMermaidArt(art, this.theme, MERMAID_INDENT)) out.push({ segs })
1255
1347
  return
1256
1348
  }
1257
1349
  // Real art, wider than this pane: name what it needs and show the
1258
1350
  // source. A raster of the same diagram reads worse, and sending the
1259
1351
  // source to mermaid.ink to draw what the engine already drew leaks
1260
1352
  // it for nothing.
1261
- out.push({ segs: [{ text: tr('image.mermaidTooWide', { width: art.width, budget: Math.max(0, budget) }), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
1262
- for (const line of renderMarkdown('```mermaid\n' + code + '\n```', THEME, width)) {
1353
+ out.push({ segs: [{ text: tr('image.mermaidTooWide', { width: art.width, budget: Math.max(0, budget) }), style: makeStyle({ fg: this.theme.textMuted, italic: true }) }] })
1354
+ for (const line of renderMarkdown('```mermaid\n' + code + '\n```', this.theme, width)) {
1263
1355
  out.push({ segs: line })
1264
1356
  }
1265
1357
  return
@@ -1270,7 +1362,7 @@ export class App {
1270
1362
  // Off, or a grammar the engine does not draw: the provider chain draws it
1271
1363
  // as an image unless the setting says a diagram stays its own source.
1272
1364
  if (off) {
1273
- for (const line of renderMarkdown('```mermaid\n' + code + '\n```', THEME, width)) {
1365
+ for (const line of renderMarkdown('```mermaid\n' + code + '\n```', this.theme, width)) {
1274
1366
  out.push({ segs: line })
1275
1367
  }
1276
1368
  return
@@ -1286,15 +1378,15 @@ export class App {
1286
1378
  this._mermaidRequested.add(key)
1287
1379
  this.onMermaidRequest?.(code, key)
1288
1380
  }
1289
- out.push({ segs: [{ text: tr('image.mermaidRendering'), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
1381
+ out.push({ segs: [{ text: tr('image.mermaidRendering'), style: makeStyle({ fg: this.theme.textMuted, italic: true }) }] })
1290
1382
  return
1291
1383
  }
1292
1384
  if (result.state === 'done') {
1293
- this._imageLines({ key: result.imageKey }, block, width, out)
1385
+ this._imageLines({ kind: 'bytes', key: result.imageKey, bytes: result.bytes }, block, width, out)
1294
1386
  return
1295
1387
  }
1296
1388
  // fallback / error: the source, highlighted.
1297
- for (const line of renderMarkdown('```mermaid\n' + code + '\n```', THEME, width)) {
1389
+ for (const line of renderMarkdown('```mermaid\n' + code + '\n```', this.theme, width)) {
1298
1390
  out.push({ segs: line })
1299
1391
  }
1300
1392
  }
@@ -1325,7 +1417,7 @@ export class App {
1325
1417
  this._imageLines({ kind: 'url', key: 'url:' + sha16(Buffer.from(text, 'utf8')), url: text }, block, width, out)
1326
1418
  return
1327
1419
  }
1328
- out.push({ segs: [{ text: '🖼 ' + (alt || tr('image.altFallback')), style: makeStyle({ fg: THEME.textMuted, italic: true }) }] })
1420
+ out.push({ segs: [{ text: '🖼 ' + (alt || tr('image.altFallback')), style: makeStyle({ fg: this.theme.textMuted, italic: true }) }] })
1329
1421
  }
1330
1422
 
1331
1423
  _trackMediaBlock(map, key, block) {
@@ -1347,13 +1439,28 @@ export class App {
1347
1439
  }
1348
1440
 
1349
1441
  startAssistant() {
1350
- this.detachInboxBlock()
1351
- const showHeader = this.assistantHeaderPending
1352
- this.assistantHeaderPending = false
1353
- this.blocks.push(makeBlock('assistant', { text: '', reasoning: '', streaming: true, showHeader }))
1442
+ this._appendAssistant(Date.now(), true)
1354
1443
  this.scroll = 0
1355
1444
  }
1356
1445
 
1446
+ _hideAssistantFooter() {
1447
+ if (!this._turnAssistant || this._turnAssistant.showFooter === false) return
1448
+ this._turnAssistant.showFooter = false
1449
+ this._turnAssistant.rev++
1450
+ }
1451
+
1452
+ _appendAssistant(time = Date.now(), streaming = false) {
1453
+ this.detachInboxBlock()
1454
+ this._hideAssistantFooter()
1455
+ const block = makeBlock('assistant', {
1456
+ text: '', reasoning: '', streaming, time, showFooter: true,
1457
+ model: this._requestModel ?? this.model, turnStartedAt: this._turnStartedAt,
1458
+ })
1459
+ this.blocks.push(block)
1460
+ this._turnAssistant = block
1461
+ return block
1462
+ }
1463
+
1357
1464
  // Append a stream chunk to the live assistant block.
1358
1465
  streamChunk(chunk) {
1359
1466
  let last = this.blocks[this.blocks.length - 1]
@@ -1372,11 +1479,10 @@ export class App {
1372
1479
  const b = this.blocks[i]
1373
1480
  if (b.kind === 'assistant' && b.streaming) {
1374
1481
  b.streaming = false
1482
+ b.time = Date.now()
1375
1483
  b.rev++
1376
1484
  // Thinking is collapsed by default once it has finished streaming.
1377
1485
  if (b.reasoning && b.thinkingCollapsed === undefined) b.thinkingCollapsed = true
1378
- // A signature-only block is as empty as one that never reasoned: left
1379
- // in place it would draw a bare `dsh` header over nothing.
1380
1486
  if (b.text === '' && !reasoningText(b.reasoning)) this.blocks.splice(i, 1)
1381
1487
  break
1382
1488
  }
@@ -1388,11 +1494,7 @@ export class App {
1388
1494
  this.detachInboxBlock()
1389
1495
  const last = this.blocks[this.blocks.length - 1]
1390
1496
  if (last && last.kind === 'assistant') return last
1391
- const showHeader = this.assistantHeaderPending
1392
- this.assistantHeaderPending = false
1393
- const b = makeBlock('assistant', { text: '', reasoning: '', streaming: false, time, showHeader })
1394
- this.blocks.push(b)
1395
- return b
1497
+ return this._appendAssistant(time)
1396
1498
  }
1397
1499
 
1398
1500
  setAssistantText(text, time) {
@@ -1407,6 +1509,7 @@ export class App {
1407
1509
 
1408
1510
  startTool({ callId, name, args }) {
1409
1511
  this.detachInboxBlock()
1512
+ this._hideAssistantFooter()
1410
1513
  const b = makeBlock('tool', { callId, name, args, status: 'running', result: '' })
1411
1514
  this.blocks.push(b)
1412
1515
  this.scroll = 0
@@ -1554,6 +1657,20 @@ export class App {
1554
1657
  return true
1555
1658
  }
1556
1659
 
1660
+ // Show or hide the harness-plumbing boxes (runtime-context snapshot, system
1661
+ // prompt, system reminders). The blocks stay in the list either way — only
1662
+ // their rendering is gated — so flipping the setting back is instant and
1663
+ // needs no replay of the session log. Every block's cached rows are dropped
1664
+ // because a hidden block leaves no rows behind, which changes the gutter the
1665
+ // neighbours get.
1666
+ setContextDetails(value) {
1667
+ const next = value === 'hide' ? 'hide' : 'show'
1668
+ if (next === this.contextDetails) return false
1669
+ this.contextDetails = next
1670
+ this._blockLineCache.clear()
1671
+ return true
1672
+ }
1673
+
1557
1674
  setTodo(todos) {
1558
1675
  this.detachInboxBlock()
1559
1676
  const existing = this.blocks.find((b) => b.kind === 'todo')
@@ -1568,6 +1685,10 @@ export class App {
1568
1685
 
1569
1686
  resetView() {
1570
1687
  this.blocks = []
1688
+ this._turnStartedAt = undefined
1689
+ this._turnAssistant = null
1690
+ this._pendingModelChanges = []
1691
+ this._requestModel = undefined
1571
1692
  this.usage = { input: 0, output: 0 }
1572
1693
  this.metrics = {}
1573
1694
  this.scroll = 0
@@ -1622,25 +1743,6 @@ export class App {
1622
1743
  return SPINNER[Math.floor(Date.now() / interval) % SPINNER.length]
1623
1744
  }
1624
1745
 
1625
- // Style for one cell of the composer's flowing frame: while a turn is
1626
- // running, gold dashes with a bright head sweep clockwise around the
1627
- // border (marching ants) instead of a flat yellow frame. `pos` is the
1628
- // cell's distance along the perimeter from the top-left corner - top edge
1629
- // left -> right, right edge downward, bottom edge right -> left, left
1630
- // edge upward. `phase` advances with time (like the spinners) so the
1631
- // pattern flows without any per-frame state.
1632
- _flowBorderStyle(pos, phase) {
1633
- if (!flowFramePalette) {
1634
- flowFramePalette = {
1635
- head: makeStyle({ fg: mixColor(THEME.warning, '#ffffff', 0.5) }),
1636
- body: makeStyle({ fg: THEME.warning }),
1637
- rest: makeStyle({ fg: THEME.warningDim }),
1638
- }
1639
- }
1640
- const d = (((pos - phase) % 10) + 10) % 10
1641
- return d === 0 ? flowFramePalette.head : d < 3 ? flowFramePalette.body : flowFramePalette.rest
1642
- }
1643
-
1644
1746
  // True while anything is still animating: a streaming turn or a running
1645
1747
  // tool (read/write/bash/...). The UI loop keeps repainting while this is
1646
1748
  // true so spinners keep flowing even when no events are arriving.
@@ -1649,10 +1751,6 @@ export class App {
1649
1751
  // The settings update page keeps repainting while a registry check or
1650
1752
  // install is in flight so its subtitle spinner keeps flowing.
1651
1753
  if (this.settingsBusy) return true
1652
- // The max effort effect keeps flowing even after the slider is closed —
1653
- // the composer's top-right effort label stays animated at the strongest
1654
- // level.
1655
- if (this.effortSlider && this._effortAtMax()) return true
1656
1754
  for (const block of this.blocks) {
1657
1755
  if (block.kind === 'assistant' && block.streaming) return true
1658
1756
  if (block.kind === 'tool' && block.status === 'running') return true
@@ -1697,27 +1795,102 @@ export class App {
1697
1795
  }, 2000)
1698
1796
  }
1699
1797
 
1798
+ showCornerNotice(title, detail, level = 'info') {
1799
+ this.cornerNotice = { title: String(title), detail: String(detail), level }
1800
+ if (this._cornerNoticeTimer) clearTimeout(this._cornerNoticeTimer)
1801
+ if (!this.terminal || typeof this.terminal.paint !== 'function') return
1802
+ this._cornerNoticeTimer = setTimeout(() => {
1803
+ this.clearCornerNotice()
1804
+ if (this.terminal.started) this.terminal.paint(this.render())
1805
+ }, 5000)
1806
+ this._cornerNoticeTimer.unref?.()
1807
+ }
1808
+
1809
+ clearCornerNotice() {
1810
+ if (this._cornerNoticeTimer) clearTimeout(this._cornerNoticeTimer)
1811
+ this._cornerNoticeTimer = null
1812
+ this.cornerNotice = null
1813
+ }
1814
+
1815
+ // An MCP server's connection result. Shown in the corner for two seconds,
1816
+ // independent of the bottom toast: a modal or a status line must not hide
1817
+ // the answer to "did that server come up".
1818
+ showMcpNotice(name, status) {
1819
+ this._mcpNotices.push({ name: String(name), status })
1820
+ if (!this.mcpNotice) this._nextMcpNotice()
1821
+ }
1822
+
1823
+ _nextMcpNotice() {
1824
+ this.mcpNotice = this._mcpNotices.shift() ?? null
1825
+ if (!this.mcpNotice || !this.terminal || typeof this.terminal.paint !== 'function') return
1826
+ this._mcpNoticeTimer = setTimeout(() => {
1827
+ this._mcpNoticeTimer = null
1828
+ this._nextMcpNotice()
1829
+ if (this.terminal.started) this.terminal.paint(this.render())
1830
+ }, 2000)
1831
+ this._mcpNoticeTimer.unref?.()
1832
+ }
1833
+
1834
+ clearMcpNotices() {
1835
+ if (this._mcpNoticeTimer) clearTimeout(this._mcpNoticeTimer)
1836
+ this._mcpNoticeTimer = null
1837
+ this._mcpNotices = []
1838
+ this.mcpNotice = null
1839
+ }
1840
+
1700
1841
  // ---- layout ----------------------------------------------------------
1701
1842
 
1843
+ commandMatches() {
1844
+ if (this._commandQuery !== this.inputText) {
1845
+ this._commandQuery = this.inputText
1846
+ this.commandSelection = 0
1847
+ this._commandDismissed = false
1848
+ }
1849
+ if (this._commandDismissed || !/^\/\S*$/.test(this.inputText)) return []
1850
+ return COMMAND_HINTS.filter(([command]) => command.startsWith(this.inputText))
1851
+ }
1852
+
1853
+ moveCommandSelection(delta) {
1854
+ const matches = this.commandMatches()
1855
+ if (!matches.length) return false
1856
+ this.commandSelection = (this.commandSelection + delta + matches.length) % matches.length
1857
+ return true
1858
+ }
1859
+
1860
+ completeCommand(index = this.commandSelection) {
1861
+ const match = this.commandMatches()[index]
1862
+ if (!match) return false
1863
+ this.inputText = match[0] + ' '
1864
+ this.inputCursor = this.inputText.length
1865
+ return true
1866
+ }
1867
+
1868
+ dismissCommands() {
1869
+ if (!this.commandMatches().length) return false
1870
+ this._commandDismissed = true
1871
+ return true
1872
+ }
1873
+
1702
1874
  _layout() {
1703
1875
  const cols = this.terminal.cols
1704
1876
  const rows = this.terminal.rows
1705
1877
  const headerH = 1
1706
1878
  const statusH = 1
1707
1879
  const sliderH = this.effortSliderVisible && this.effortSlider ? 1 : 0
1708
- // OpenCode-style: very narrow sidebar (max 12 cols), auto-hide aggressively
1709
- // Prioritize conversation view - sidebar only shows when explicitly toggled AND space allows
1710
- let sidebarW = this.sidebarVisible && cols >= 110 ? Math.min(12, Math.floor(cols / 9)) : 0
1711
- if (cols - sidebarW < 75) sidebarW = 0
1880
+ const home = this.titleScreen && this.blocks.length === 0 && !this.pendingApproval
1712
1881
  // The right status rail claims one border column plus RAIL_WIDTH content
1713
1882
  // columns; `borderX` is the main area's exclusive right edge (the border
1714
1883
  // glyph's own column). Narrow windows keep the single-column layout.
1715
- const railW = cols >= RAIL_MIN_COLS ? RAIL_WIDTH : 0
1884
+ const railW = !home && cols >= RAIL_MIN_COLS ? RAIL_WIDTH : 0
1716
1885
  const borderX = railW > 0 ? cols - railW - 1 : cols
1717
- const composerWidth = Math.max(20, borderX - sidebarW - 6)
1886
+ const sidebarW = !home && this.sidebarVisible && borderX >= 110
1887
+ ? Math.max(22, Math.min(30, this.sidebarWidth)) : 0
1888
+ const { composerX, composerW } = composerGeometry(cols, sidebarW, railW, home)
1889
+ const composerWidth = Math.max(1, composerW - 4)
1718
1890
  const visual = inputRows(this.inputText, this.inputCursor, composerWidth)
1719
- const suggestions = this.inputText.startsWith('/') && !this.inputText.includes(' ') ? Math.min(COMMAND_HINTS.length, COMMAND_HINTS.filter(([command]) => command.startsWith(this.inputText)).length) : 0
1891
+ const suggestions = this.pendingApproval ? 0 : Math.min(Math.max(1, Math.min(6, rows - 12)), this.commandMatches().length)
1720
1892
  const imageHintH = this.inputImages.length > 0 ? 1 : 0
1893
+ const composerGap = !this.pendingApproval && rows >= 12 ? 1 : 0
1721
1894
  // The stats strip takes one row directly above the composer, the seat the
1722
1895
  // web chat stats line occupies in its composer dock. A session with nothing
1723
1896
  // to report keeps the row for the transcript, and the rail (which carries
@@ -1728,176 +1901,158 @@ export class App {
1728
1901
  // origin and details to be shown. Its height comes from the same helper the
1729
1902
  // painter uses, so the box is exactly as tall as its content, and never so
1730
1903
  // tall that the transcript loses its last row.
1731
- const maxComposerRows = Math.max(1, rows - headerH - statusH - sliderH - imageHintH - statsH - suggestions - 2)
1904
+ const maxComposerRows = Math.max(1, rows - headerH - statusH - sliderH - imageHintH - statsH - suggestions - 2 - composerGap)
1732
1905
  let inputRowsVisible
1733
1906
  if (this.pendingApproval) {
1734
- const approval = this._approvalLines(composerTextWidth(cols, sidebarW, railW), maxComposerRows)
1907
+ const approval = this._approvalLines(composerTextWidth(cols, sidebarW, railW, home), maxComposerRows)
1735
1908
  inputRowsVisible = Math.max(1, Math.min(approval.length, maxComposerRows))
1736
1909
  } else {
1737
- inputRowsVisible = Math.min(Math.max(1, visual.rows.length), rows >= 28 ? 6 : 3)
1910
+ inputRowsVisible = Math.min(Math.max(1, visual.rows.length), rows >= 28 ? 6 : 3, maxComposerRows)
1738
1911
  }
1739
- const inputH = inputRowsVisible + 2 + suggestions
1912
+ const inputH = inputRowsVisible + 2 + composerGap + suggestions
1740
1913
  const transcriptTop = headerH
1741
- const transcriptBottom = rows - inputH - statusH - sliderH - imageHintH - statsH
1914
+ const dockTop = rows - inputH - statusH - sliderH - imageHintH - statsH
1915
+ const transcriptBottom = home && rows >= 16
1916
+ ? Math.min(dockTop, Math.max(headerH + 4, Math.min(dockTop - 3, Math.floor(rows * 0.44)))) : dockTop
1742
1917
  const transcriptH = Math.max(1, transcriptBottom - transcriptTop)
1743
- return { cols, rows, headerH, inputH, inputRowsVisible, suggestions, imageHintH, statsH, statusH, sliderH, transcriptTop, transcriptBottom, transcriptH, sidebarW, railW, borderX, composerWidth, visual }
1918
+ return { cols, rows, home, headerH, inputH, inputRowsVisible, composerGap, suggestions, imageHintH, statsH, statusH, sliderH, transcriptTop, transcriptBottom, transcriptH, sidebarW, railW, borderX, composerX, composerW, composerWidth, visual }
1744
1919
  }
1745
1920
 
1746
1921
  // ---- block -> lines --------------------------------------------------
1747
1922
 
1748
1923
  _blockLines(block, width) {
1749
- const t = THEME
1924
+ const t = this.theme
1750
1925
  const out = []
1926
+ const gutter = ['user', 'assistant', 'tool', 'model-change'].includes(block.kind)
1927
+ ? Math.min(2, Math.max(0, Math.floor((width - 12) / 2)))
1928
+ : 0
1929
+ width = Math.max(1, width - gutter * 2)
1751
1930
  switch (block.kind) {
1752
- case 'user': {
1753
- // A pointer marks your turn and the text hangs under it — no frame, no
1754
- // fill, so a long prompt never has to be measured against a border.
1755
- // The timestamp trails the last line (or its own row when the last line
1756
- // is full) so a steering message still reads as its own event.
1757
- const pointerW = 4
1758
- const hang = ' '.repeat(pointerW)
1759
- const pointer = { text: ' ❯ ', style: makeStyle({ fg: t.userPointer, bold: true }) }
1760
- const lines = renderMarkdown(block.text, t, Math.max(8, width - pointerW))
1761
- if (lines.length === 0) lines.push([])
1762
- const tail = []
1763
- if (block.steering) tail.push({ text: tr('transcript.steeringBadge'), style: makeStyle({ fg: t.steering, bold: true }) })
1764
- tail.push({ text: ' · ' + timeString(block.time), style: makeStyle({ fg: t.textMuted }) })
1765
- const last = lines[lines.length - 1]
1766
- const tailW = tail.reduce((n, s) => n + displayWidth(s.text), 0)
1767
- const lastW = last.reduce((n, s) => n + displayWidth(s.text), 0)
1768
- // The text hangs at `pointerW`, so the tail only joins it when it fits
1769
- // in what is left of the row *after* that indent.
1770
- if (lastW + tailW <= width - pointerW) last.push(...tail)
1771
- else lines.push([{ text: hang, style: null }, ...tail])
1772
- for (let i = 0; i < lines.length; i++) {
1773
- out.push({ segs: i === 0 ? [pointer, ...lines[i]] : [{ text: hang, style: null }, ...lines[i]] })
1931
+ case 'model-change': {
1932
+ const route = (value) => value.model + (value.provider ? ' · ' + value.provider : '')
1933
+ const text = tr('transcript.modelChanged', { from: route(block.from), to: route(block.to) })
1934
+ const muted = makeStyle({ fg: t.textMuted })
1935
+ const rule = makeStyle({ fg: t.borderSubtle })
1936
+ const content = '◇ ' + text
1937
+ out.push({ segs: [] })
1938
+ if (displayWidth(content) + 6 <= width) {
1939
+ const available = width - displayWidth(content) - 2
1940
+ const left = Math.floor(available / 2)
1941
+ out.push({ segs: [
1942
+ { text: '─'.repeat(left) + ' ', style: rule },
1943
+ { text: content, style: muted },
1944
+ { text: ' ' + '─'.repeat(available - left), style: rule },
1945
+ ] })
1946
+ } else {
1947
+ for (const line of wrapText(content, Math.max(2, width))) {
1948
+ out.push({ segs: [{ text: line, style: muted }] })
1949
+ }
1774
1950
  }
1775
- for (const src of block.images ?? []) this._imageLines(src, block, width, out)
1776
1951
  out.push({ segs: [] })
1777
1952
  break
1778
1953
  }
1954
+ case 'user': {
1955
+ const bodyWidth = Math.max(2, width - 5)
1956
+ const body = []
1957
+ // Prompts preserve their literal markdown, as in OpenCode's text part.
1958
+ for (const text of wrapText(transcriptText(block.text), bodyWidth)) {
1959
+ body.push({ segs: [{ text, style: makeStyle({ fg: t.text }) }] })
1960
+ }
1961
+ if (block.images?.length) {
1962
+ if (block.text) body.push({ segs: [] })
1963
+ for (const src of block.images) this._imageLines(src, block, bodyWidth, body)
1964
+ }
1965
+ body.push({ segs: [
1966
+ { text: timeString(block.time), style: makeStyle({ fg: t.textMuted }) },
1967
+ ...(block.steering ? [{ text: tr('transcript.steeringBadge'), style: makeStyle({ fg: t.steering, bold: true }) }] : []),
1968
+ ] })
1969
+ if (this.blocks[0] !== block) out.push({ segs: [] })
1970
+ out.push(...transcriptPanel(body, width, block.steering ? t.steering : t.userPointer, t))
1971
+ break
1972
+ }
1779
1973
  case 'assistant': {
1780
- // The displayable reasoning, with the transport signature stripped. A
1781
- // block whose reasoning is only a signature has no thinking to show, so
1782
- // every decision below — including whether the block is empty at all —
1783
- // is taken on this text rather than on the raw accumulation.
1784
1974
  const reasoning = reasoningText(block.reasoning)
1785
- // A message that only issued tool calls has nothing to say on its own:
1786
- // its activity lives in the tool rows below, and drawing an empty frame
1787
- // anyway is what produced the empty bubbles in long tool turns. The
1788
- // turn's `dsh · time` header must not be swallowed here — pass it to
1789
- // the next assistant block if there is one.
1790
- if (block.text === '' && !reasoning) {
1791
- if (block.showHeader === true) {
1792
- // The next assistant message can sit behind this one's tool rows —
1793
- // scan past them.
1794
- let j = this.blocks.indexOf(block) + 1
1795
- while (j < this.blocks.length && this.blocks[j].kind === 'tool') j++
1796
- const next = this.blocks[j]
1797
- if (next?.kind === 'assistant' && next.showHeader === false) {
1798
- next.showHeader = true
1799
- next.rev = (next.rev ?? 0) + 1
1800
- }
1801
- block.showHeader = false
1802
- }
1803
- break
1804
- }
1805
- // Thinking is one flat row: collapsed it names itself, expanded the
1806
- // body hangs under it in muted italics. No frame and no full-width
1807
- // band — the boxed shapes measured their own borders and drifted on
1808
- // wide runes, and the band read as a bar across the transcript.
1975
+ if (!block.text && !reasoning && !block.images?.length) break
1976
+ out.push({ segs: [] })
1809
1977
  if (reasoning) {
1810
1978
  const streaming = block.streaming === true
1811
1979
  const collapsed = block.thinkingCollapsed === true || streaming
1812
1980
  const hint = streaming ? tr('transcript.streaming') : (collapsed ? tr('common.clickExpand') : tr('common.clickCollapse'))
1813
- // While streaming, a flowing spinner leads the marker (replaced at
1814
- // draw time so the cached lines can stay static between frames).
1815
1981
  const marker = streaming
1816
1982
  ? { text: '⠿', style: makeStyle({ fg: t.primary, bold: true }), anim: 'spinner' }
1817
- : { text: collapsed ? '▸' : '▾', style: makeStyle({ fg: t.thinking, bold: true }) }
1983
+ : { text: collapsed ? '+' : '−', style: makeStyle({ fg: t.thinking }) }
1818
1984
  const body = []
1819
1985
  if (!collapsed) {
1820
- for (const line of renderMarkdown(reasoning, t, Math.max(8, width - 6))) {
1821
- body.push(line.map((s) => ({ ...s, style: mergeStyle(s.style, { fg: t.thinking, italic: true }) })))
1986
+ body.push([])
1987
+ for (const line of renderMarkdown(reasoning, t, Math.max(1, width - 3))) {
1988
+ body.push(line.map((s) => tintMarkdownSegment(s, { fg: t.thinking, italic: true }, t)))
1822
1989
  }
1823
1990
  }
1824
- // Folded and finished: name the row, then show what it was about.
1825
- // While streaming the text is still arriving, so a preview would
1826
- // flicker; the running hint stands in for it.
1827
- const preview = collapsed && !streaming ? thinkingPreview(reasoning) : ''
1991
+ const previewBudget = Math.max(0, width - 7 - displayWidth(tr('transcript.thinking')) - displayWidth(hint))
1992
+ const preview = collapsed && !streaming && previewBudget > 0
1993
+ ? truncateWidth(thinkingPreview(reasoning), previewBudget) : ''
1828
1994
  out.push(...flatRows('thinking', block, [
1829
- { text: ' ', style: null },
1995
+ { text: ' ', style: null },
1830
1996
  marker,
1831
- { text: tr('transcript.thinking'), style: makeStyle({ fg: t.thinking, bold: true }) },
1997
+ { text: tr('transcript.thinking'), style: makeStyle({ fg: t.thinking }) },
1832
1998
  ...(preview
1833
1999
  ? [
1834
2000
  { text: ' · ', style: makeStyle({ fg: t.thinking, dim: true }) },
1835
- { text: truncateWidth(preview, 48), style: makeStyle({ fg: t.thinking, italic: true }) },
2001
+ { text: preview, style: makeStyle({ fg: t.thinking, italic: true }) },
1836
2002
  ]
1837
2003
  : []),
1838
- { text: hint, style: makeStyle({ fg: t.thinking, bold: true }) },
1839
- ], body, 4, width))
1840
- // A blank line separates the reasoning from the visible answer so
1841
- // the two never run together.
2004
+ { text: hint, style: makeStyle({ fg: t.textMuted, dim: true }) },
2005
+ ], body, 3, width))
1842
2006
  if (block.text) out.push({ segs: [] })
1843
2007
  }
1844
- let text = block.text
1845
- if (block.streaming) text += '▍'
1846
- // One header per request: a turn split by tool calls renders one
1847
- // answer per block, and only the first announces `dsh · time`.
1848
- if (block.showHeader !== false) {
1849
- out.push({
1850
- segs: [
1851
- { text: ' ', style: null },
1852
- { text: 'dsh', style: makeStyle({ fg: t.assistantLabel, bold: true }) },
1853
- { text: ' · ' + timeString(block.time), style: makeStyle({ fg: t.textMuted }) },
1854
- ],
1855
- })
1856
- }
2008
+ let text = block.text ?? ''
2009
+ if (block.streaming && text) text += '▍'
1857
2010
  if (text) {
1858
2011
  const body = []
1859
- this._markdownLines(text, block, Math.max(8, width - 2), body)
1860
- // The answer hangs at the same two columns as its header; a media row
1861
- // (mermaid art, a reserved image slab) already carries its own x
1862
- // offset, so it passes through untouched.
1863
- for (const line of body) {
1864
- if (line.image) {
1865
- out.push(line)
1866
- continue
1867
- }
1868
- out.push({ segs: [{ text: ' ', style: null }, ...(line.segs ?? [])] })
1869
- }
2012
+ this._markdownLines(text, block, Math.max(1, width - 3), body)
2013
+ for (const line of body) out.push(indentTranscriptRow(line, 3))
2014
+ }
2015
+ const images = []
2016
+ for (const src of block.images ?? []) this._imageLines(src, block, Math.max(1, width - 3), images)
2017
+ for (const line of images) out.push(indentTranscriptRow(line, 3))
2018
+ if (!block.streaming && block.showFooter !== false) {
2019
+ const model = block.model ?? this.model
2020
+ const duration = block.time - block.turnStartedAt
2021
+ out.push({ segs: [] })
2022
+ out.push({ segs: [
2023
+ { text: ' ▣ ', style: makeStyle({ fg: t.primary }) },
2024
+ { text: 'dsh', style: makeStyle({ fg: t.text }) },
2025
+ ...(model ? [{ text: ' · ' + model, style: makeStyle({ fg: t.textMuted }) }] : []),
2026
+ ...(Number.isFinite(duration) && duration > 0
2027
+ ? [{ text: ' · ' + formatDuration(duration), style: makeStyle({ fg: t.textMuted }) }] : []),
2028
+ ] })
1870
2029
  }
1871
- for (const src of block.images ?? []) this._imageLines(src, block, width, out)
1872
2030
  out.push({ segs: [] })
1873
2031
  break
1874
2032
  }
1875
2033
  case 'tool': {
1876
- // OpenCode-style activity row: a status marker, the action in its own
1877
- // accent, and the target it acts on (the path read, the command run)
1878
- // in muted text. Collapsed it is exactly that one row; expanded the
1879
- // result hangs under a ⎿ gutter. The whole block is a click target —
1880
- // a collapsed row hides its result precisely so a long turn of tool
1881
- // calls stays scannable, and one click brings the details back.
1882
2034
  const running = block.status === 'running'
1883
2035
  const expanded = block.expanded === true
2036
+ const shell = ['bash', 'pwsh', 'shell'].includes(block.name)
2037
+ const preview = !running && (shell || block.status === 'error') && !!block.result
2038
+ const panel = expanded || preview
1884
2039
  const accent = toolAccent(block.name, t)
1885
2040
  const statusColor = running ? t.warning : block.status === 'error' ? t.error : t.success
2041
+ const icon = shell ? '$' : ['read'].includes(block.name) ? '→'
2042
+ : ['write', 'edit', 'str_replace_editor'].includes(block.name) ? '←'
2043
+ : ['glob', 'grep', 'web_search'].includes(block.name) ? '✱' : '◇'
1886
2044
  const marker = running
1887
2045
  ? { text: '⠿', style: makeStyle({ fg: statusColor, bold: true }), anim: 'spinner' }
1888
- : { text: block.status === 'error' ? '✗' : '✓', style: makeStyle({ fg: statusColor, bold: true }) }
1889
- const summary = toolSummary(block.name, block.args)
2046
+ : { text: block.status === 'error' ? '✗' : icon, style: makeStyle({ fg: block.status === 'error' ? t.error : accent }) }
2047
+ const summary = transcriptText(toolSummary(block.name, block.args)).replace(/\n/g, ' ')
1890
2048
  const cut = summary.indexOf(' ')
1891
2049
  const verb = cut < 0 ? summary : summary.slice(0, cut)
1892
2050
  const rawTarget = cut < 0 ? '' : summary.slice(cut + 1)
1893
- // The tool row sets its hint one column further out than the notes do,
1894
- // so the shared " · click to ..." text gets one more leading space.
1895
- const hint = running ? '' : ' ' + (expanded ? tr('common.clickCollapse') : tr('common.clickExpand'))
1896
- const budget = Math.max(6, width - 7 - displayWidth(verb) - displayWidth(hint))
2051
+ const hint = running ? '' : (expanded ? tr('common.clickCollapse') : tr('common.clickExpand'))
2052
+ const budget = Math.max(1, width - (panel ? 10 : 7) - displayWidth(verb) - displayWidth(hint))
1897
2053
  const target = truncateWidth(rawTarget, budget)
1898
- out.push({
2054
+ const header = {
1899
2055
  segs: [
1900
- { text: ' ', style: null },
1901
2056
  marker,
1902
2057
  { text: ' ', style: null },
1903
2058
  { text: verb, style: makeStyle({ fg: accent, bold: true }) },
@@ -1905,37 +2060,40 @@ export class App {
1905
2060
  ...(hint ? [{ text: hint, style: makeStyle({ fg: t.textMuted, dim: true }) }] : []),
1906
2061
  ],
1907
2062
  tool: { block },
1908
- })
1909
- // Tool-result images (screenshots, generated pictures) render in full
1910
- // only while expanded — the collapse is a text-economy rule, not a
1911
- // media one.
1912
- if (expanded) {
1913
- const cap = 400
1914
- const resultLines = renderMarkdown(block.result ?? '', t, Math.max(8, width - 8))
1915
- const shown = Math.min(resultLines.length, cap)
1916
- for (let i = 0; i < shown; i++) {
1917
- out.push({
2063
+ }
2064
+ if (panel) {
2065
+ const cap = expanded ? 400 : 10
2066
+ const bodyWidth = Math.max(2, width - 5)
2067
+ const body = shell
2068
+ ? wrapText(rawTarget || summary, Math.max(2, bodyWidth - 2)).map((text, i) => ({
1918
2069
  segs: [
1919
- { text: ' ', style: null },
1920
- ...(i === 0
1921
- ? [{ text: '⎿', style: makeStyle({ fg: t.textMuted }) }, { text: ' ', style: null }]
1922
- : [{ text: ' ', style: null }]),
1923
- ...resultLines[i].map((s) => ({ ...s, style: makeStyle({ fg: t.text }) })),
2070
+ i === 0 ? marker : { text: ' ', style: null },
2071
+ { text: ' ' + text, style: makeStyle({ fg: t.text }) },
1924
2072
  ],
1925
- tool: { block },
1926
- })
2073
+ }))
2074
+ : [header]
2075
+ body.push({ segs: [] })
2076
+ const resultLines = shell
2077
+ ? wrapText(transcriptText(block.result), bodyWidth).map((text) => [{ text, style: makeStyle({ fg: t.text }) }])
2078
+ : renderMarkdown(block.result ?? '', t, bodyWidth)
2079
+ const shown = Math.min(resultLines.length, cap)
2080
+ for (let i = 0; i < shown; i++) {
2081
+ body.push({ segs: resultLines[i] })
1927
2082
  }
1928
2083
  if (resultLines.length > cap) {
1929
- out.push({
1930
- segs: [{ text: ' ' + tr('common.moreLines', { count: resultLines.length - cap }), style: makeStyle({ fg: t.textMuted, dim: true }) }],
1931
- tool: { block },
1932
- })
2084
+ body.push({ segs: [{ text: tr('common.moreLines', { count: resultLines.length - cap }), style: makeStyle({ fg: t.textMuted, dim: true }) }] })
2085
+ }
2086
+ if (expanded) for (const src of block.images ?? []) this._imageLines(src, block, bodyWidth, body)
2087
+ if (shell && hint) {
2088
+ body.push({ segs: [] })
2089
+ body.push({ segs: [{ text: hint.trimStart(), style: makeStyle({ fg: t.textMuted, dim: true }) }] })
1933
2090
  }
1934
- const from = out.length
1935
- for (const src of block.images ?? []) this._imageLines(src, block, width, out)
1936
- for (let i = from; i < out.length; i++) out[i].tool = { block }
2091
+ out.push({ segs: [] })
2092
+ out.push(...transcriptPanel(body, width, block.status === 'error' ? t.error : t.borderSubtle, t, { tool: { block } }))
2093
+ out.push({ segs: [] })
2094
+ } else {
2095
+ out.push(indentTranscriptRow(header, 3))
1937
2096
  }
1938
- out.push({ segs: [] })
1939
2097
  break
1940
2098
  }
1941
2099
  case 'todo': {
@@ -1955,22 +2113,25 @@ export class App {
1955
2113
  break
1956
2114
  }
1957
2115
  case 'note': {
1958
- // Context notes (system-reminder / compaction): one flat marker row
1959
- // naming the label, the body hanging under it in the label's own
1960
- // color, collapsed by default. Whole block clickable to toggle.
1961
- const fg = block.label === 'compaction' ? t.compaction : t.reminder
2116
+ // Context notes (system-reminder / runtime-context / compaction): one
2117
+ // flat marker row naming the label, the body hanging under it in the
2118
+ // label's own color, collapsed by default. Whole block clickable to
2119
+ // toggle. A developer setting can drop the harness-plumbing kinds.
2120
+ if (this.contextDetails === 'hide' && CONTEXT_LABELS.has(block.label)) break
2121
+ const fg = block.label === 'compaction' ? t.compaction
2122
+ : block.label === 'runtime-context' ? t.systemPrompt : t.reminder
1962
2123
  const collapsed = block.collapsed === true
1963
2124
  const hint = collapsed ? tr('common.clickExpand') : tr('common.clickCollapse')
1964
2125
  const body = []
1965
2126
  if (!collapsed) {
1966
2127
  for (const line of renderMarkdown(block.text, t, Math.max(8, width - 6))) {
1967
- body.push(line.map((s) => ({ ...s, style: mergeStyle(s.style, { fg }) })))
2128
+ body.push(line.map((s) => tintMarkdownSegment(s, { fg }, t)))
1968
2129
  }
1969
2130
  }
1970
2131
  out.push(...flatRows('note', block, [
1971
2132
  { text: ' ', style: null },
1972
2133
  { text: collapsed ? '▸' : '▾', style: makeStyle({ fg, bold: true }) },
1973
- { text: ' ' + tr(block.label === 'compaction' ? 'note.compaction' : 'note.systemReminder') + hint, style: makeStyle({ fg, bold: true }) },
2134
+ { text: ' ' + tr(noteLabelKey(block.label)) + hint, style: makeStyle({ fg, bold: true }) },
1974
2135
  ], body, 4, width))
1975
2136
  out.push({ segs: [] })
1976
2137
  break
@@ -1978,7 +2139,7 @@ export class App {
1978
2139
  case 'system': {
1979
2140
  const color = block.level === 'error' ? t.error : block.level === 'warn' ? t.warning : t.textMuted
1980
2141
  for (const line of renderMarkdown(block.text, t, width - 2)) {
1981
- out.push({ segs: [{ text: ' ', style: null }, ...line.map((s) => ({ ...s, style: makeStyle({ fg: color, italic: true }) }))] })
2142
+ out.push({ segs: [{ text: ' ', style: null }, ...line.map((s) => tintMarkdownSegment(s, { fg: color, italic: true }, t, true))] })
1982
2143
  }
1983
2144
  out.push({ segs: [] })
1984
2145
  break
@@ -1987,13 +2148,14 @@ export class App {
1987
2148
  // The session's system prompt (V3 `system/message`): the same flat
1988
2149
  // collapsible shape as the other notes, in its own neutral palette so
1989
2150
  // it never reads as a reminder or a compaction checkpoint.
2151
+ if (this.contextDetails === 'hide') break
1990
2152
  const fg = t.systemPrompt
1991
2153
  const collapsed = block.collapsed === true
1992
2154
  const hint = collapsed ? tr('common.clickExpand') : tr('common.clickCollapse')
1993
2155
  const body = []
1994
2156
  if (!collapsed) {
1995
2157
  for (const line of renderMarkdown(block.text, t, Math.max(8, width - 6))) {
1996
- body.push(line.map((s) => ({ ...s, style: mergeStyle(s.style, { fg }) })))
2158
+ body.push(line.map((s) => tintMarkdownSegment(s, { fg }, t)))
1997
2159
  }
1998
2160
  }
1999
2161
  out.push(...flatRows('note', block, [
@@ -2042,7 +2204,7 @@ export class App {
2042
2204
  default:
2043
2205
  break
2044
2206
  }
2045
- return out
2207
+ return out.map((row) => ({ ...row, inset: gutter, rightInset: gutter }))
2046
2208
  }
2047
2209
 
2048
2210
  // Return the cached rendered lines for a block, re-rendering only when its
@@ -2053,7 +2215,7 @@ export class App {
2053
2215
  const key = block.rev + ':' + width
2054
2216
  const cached = this._blockLineCache.get(block)
2055
2217
  if (cached && cached.key === key) return cached.lines
2056
- if (block.streaming && cached && Date.now() - this._streamingRenderAt < 120) {
2218
+ if (block.streaming && cached?.width === width && Date.now() - this._streamingRenderAt < 120) {
2057
2219
  return cached.lines
2058
2220
  }
2059
2221
  const rendered = this._blockLines(block, width)
@@ -2069,24 +2231,70 @@ export class App {
2069
2231
  img.start = prev && prev.gid === img.gid ? prev.start : i
2070
2232
  }
2071
2233
  if (block.streaming) this._streamingRenderAt = Date.now()
2072
- this._blockLineCache.set(block, { key, lines: rendered })
2234
+ this._blockLineCache.set(block, { key, width, lines: rendered })
2073
2235
  return rendered
2074
2236
  }
2075
2237
 
2076
2238
  // Total rendered line count for the transcript at a given width.
2077
2239
  _transcriptTotal(width) {
2078
2240
  let total = 0
2079
- for (const block of this.blocks) total += this._ensureBlockLines(block, width).length
2241
+ let prevKind = null
2242
+ for (const block of this.blocks) {
2243
+ const rows = this._blockRows(block, width, prevKind)
2244
+ if (rows.length === 0) continue
2245
+ total += rows.length
2246
+ prevKind = block.kind
2247
+ }
2080
2248
  return total
2081
2249
  }
2082
2250
 
2251
+ // The rows one block contributes to the transcript, with the block gutter
2252
+ // applied. Each block renderer pads its own edges and those paddings drifted:
2253
+ // a user bubble opened with a gap but left none behind it, so a context note
2254
+ // right after one sat flush under the panel, while a system line owned only a
2255
+ // trailing gap and was glued to the block above. The gutter is decided here
2256
+ // instead — strip whatever blank rows the block put at its own edges, then
2257
+ // place exactly one between two occupied blocks — so every pair of blocks
2258
+ // reads at the same distance. A block that renders nothing (an assistant
2259
+ // block with no text yet) takes no room and no gutter at all.
2260
+ //
2261
+ // A run of tool calls is the one exception: those rows are a list of what the
2262
+ // turn did, and one gap per call would scatter it across the screen.
2263
+ //
2264
+ // The trimmed rows are cached on the block's own line-cache entry, so every
2265
+ // invalidation site (theme switch, a settled mermaid engine, a reset) drops
2266
+ // them together with the lines they were cut from.
2267
+ _blockRows(block, width, prevKind) {
2268
+ const lead = prevKind !== null && !(prevKind === 'tool' && block.kind === 'tool')
2269
+ const lines = this._ensureBlockLines(block, width)
2270
+ const entry = this._blockLineCache.get(block)
2271
+ const key = (entry?.key ?? '') + ':' + (lead ? 1 : 0)
2272
+ if (entry?.rows?.key === key) return entry.rows.lines
2273
+ let from = 0
2274
+ let to = lines.length
2275
+ while (from < to && isBlankRow(lines[from])) from++
2276
+ while (to > from && isBlankRow(lines[to - 1])) to--
2277
+ const rows = from === to ? [] : lines.slice(from, to)
2278
+ if (rows.length > 0 && lead) rows.unshift({ segs: [] })
2279
+ // An image row carries the index the draw pass reads as "how many rows of
2280
+ // this group sit above the window"; after a trim that index has moved.
2281
+ for (let i = 0; i < rows.length; i++) {
2282
+ const img = rows[i]?.image
2283
+ if (!img) continue
2284
+ const prev = rows[i - 1]?.image
2285
+ img.start = prev && prev.gid === img.gid ? prev.start : i
2286
+ }
2287
+ if (entry) entry.rows = { key, lines: rows }
2288
+ return rows
2289
+ }
2290
+
2083
2291
  // ---- paint -----------------------------------------------------------
2084
2292
 
2085
2293
  render() {
2086
- const { cols, rows, headerH, inputRowsVisible, suggestions, imageHintH, statsH, transcriptTop, transcriptBottom, transcriptH, sidebarW, railW, borderX, visual } = this._layout()
2294
+ const { cols, rows, home, headerH, inputRowsVisible, composerGap, suggestions, imageHintH, statsH, transcriptTop, transcriptBottom, transcriptH, sidebarW, railW, borderX, composerX, composerW, visual } = this._layout()
2087
2295
  this.hitRegions = []
2088
2296
  const screen = new Screen(cols, rows)
2089
- const t = THEME
2297
+ const t = this.theme
2090
2298
  screen.clear(makeStyle({ bg: t.background }))
2091
2299
 
2092
2300
  // Header: brand + session name on the left. The right end carries the
@@ -2101,7 +2309,7 @@ export class App {
2101
2309
  const branch = this.gitBranch ? 'git: ' + this.gitBranch : ''
2102
2310
  let hx = 2
2103
2311
  hx = screen.text(hx, 0, '◈ ', makeStyle({ fg: t.primary, bold: true, bg: t.backgroundPanel }))
2104
- hx = gradientText(screen, hx, 0, 'DeepSeek Harness TUI', t.primary, '#dce6ff', { bold: true, bg: t.backgroundPanel })
2312
+ hx = gradientText(screen, hx, 0, 'DeepSeek Harness TUI', '#4d6bfe', '#dce6ff', { bold: true, bg: t.backgroundPanel })
2105
2313
  let workspace = null
2106
2314
  // The placeholder a session with no title of its own shows: resolved here,
2107
2315
  // not when the session was opened, so a language switch renames it.
@@ -2224,8 +2432,10 @@ export class App {
2224
2432
  let cursor = 0
2225
2433
  let row = 0
2226
2434
  const usedCrops = new Set()
2435
+ let prevKind = null
2227
2436
  for (const block of this.blocks) {
2228
- const lines = this._ensureBlockLines(block, transWidth)
2437
+ const lines = this._blockRows(block, transWidth, prevKind)
2438
+ if (lines.length > 0) prevKind = block.kind
2229
2439
  const blockEnd = cursor + lines.length
2230
2440
  if (blockEnd > offset && cursor < endIndex) {
2231
2441
  const from = Math.max(0, offset - cursor)
@@ -2233,23 +2443,24 @@ export class App {
2233
2443
  for (let i = from; i < to; i++) {
2234
2444
  const y = transcriptTop + row
2235
2445
  const line = lines[i]
2236
- let x = transcriptX
2446
+ let x = transcriptX + (line.inset ?? 0)
2237
2447
  // Clip every row to the transcript width at the draw boundary. A row
2238
2448
  // is composed from a fixed prefix plus block content, and a single
2239
2449
  // segment (model-authored text, a long path, a wide rune at the last
2240
2450
  // column) can be wider than the box even when the markdown renderer
2241
2451
  // respected its own limit. Letting it through would paint past the
2242
2452
  // terminal edge, wrap the row, and shift the rest of the frame.
2243
- const budget = transWidth
2453
+ const right = borderX - (line.rightInset ?? 0)
2454
+ const budget = Math.max(0, right - x)
2244
2455
  for (const seg of clipSegs(line.segs, budget)) {
2245
2456
  // Animated placeholders (spinners) are resolved at draw time so
2246
2457
  // the cached lines stay static between animation frames.
2247
- x = screen.text(x, y, seg.anim ? this.animChar() : seg.text, seg.style)
2458
+ const style = line.background ? makeStyle({ ...seg.style, bg: seg.style?.bg ?? line.background }) : seg.style
2459
+ x = screen.text(x, y, seg.anim ? this.animChar() : seg.text, style)
2248
2460
  }
2249
2461
  // The row tail fills to the main area's right edge; with the status
2250
2462
  // rail on screen the fill stops short of the rail's border column.
2251
- if (borderX < cols) screen.fill(x, y, Math.max(0, borderX - x), ' ', makeStyle({ fg: t.text }))
2252
- else screen.fillToEnd(x, y, makeStyle({ fg: t.text }))
2463
+ screen.fill(x, y, Math.max(0, right - x), ' ', makeStyle({ fg: t.text, bg: line.background }))
2253
2464
  // A graphics-protocol image row: the cells stay blank (background
2254
2465
  // above), and the terminal draws pixels over them via the slab
2255
2466
  // registered here.
@@ -2268,7 +2479,7 @@ export class App {
2268
2479
  if (clip > 0) this._requestImageCrop(img.key, clip)
2269
2480
  screen.setImageRow(y, {
2270
2481
  key: cropKey,
2271
- x: transcriptX + img.x,
2482
+ x: transcriptX + (line.inset ?? 0) + img.x,
2272
2483
  cellsW: img.cellsW,
2273
2484
  cellsH: img.cellsH - clip,
2274
2485
  top: true,
@@ -2316,22 +2527,19 @@ export class App {
2316
2527
  }
2317
2528
  this._imageCropsPrev = usedCrops
2318
2529
 
2319
- if (this.titleScreen && this.blocks.length === 0) {
2320
- // OpenCode-style splash: DeepSeek brand mark with workspace/git context.
2321
- // The composer claims the rows below `floor`, so the shortcut row has to
2322
- // sit above it or it would be painted over. The mark centers in the main
2323
- // area, never under the status rail.
2530
+ if (home) {
2324
2531
  paintSplashOpenCode(screen, cols, rows, t, {
2325
2532
  workingDirectory: this.workingDirectory,
2326
2533
  gitBranch: this.gitBranch,
2327
2534
  model: this.model,
2328
2535
  provider: this.provider,
2329
- }, transcriptBottom + statsH - 1, { x0: 0, width: borderX })
2536
+ }, transcriptBottom + statsH - 1, {
2537
+ x0: composerX, width: composerW, composerTop: transcriptBottom,
2538
+ composerBottom: transcriptBottom + suggestions + imageHintH + inputRowsVisible + composerGap + 1,
2539
+ })
2330
2540
  }
2331
2541
 
2332
2542
  // Composer
2333
- const composerX = sidebarW > 0 ? sidebarW + 2 : 1
2334
- const composerW = borderX - composerX - 1
2335
2543
  let composerTop = transcriptBottom + statsH
2336
2544
 
2337
2545
  // Stats strip: the web chat's session stats line, one row between the
@@ -2347,27 +2555,33 @@ export class App {
2347
2555
  const hint = '/stats'
2348
2556
  const hintW = displayWidth(hint)
2349
2557
  const lineWidth = groups.reduce((sum, group, i) => sum + (i > 0 ? 1 : 0) + displayWidth(group), 0)
2350
- const hintSeat = lineWidth + hintW + 6 <= cols - 2
2351
- const budget = cols - 2 - (hintSeat ? hintW + 3 : 0)
2558
+ const hintSeat = lineWidth + hintW + 6 <= composerW
2559
+ const budget = composerX + composerW - 2 - (hintSeat ? hintW + 3 : 0)
2352
2560
  screen.fill(0, stripY, cols, ' ', makeStyle({ bg }))
2353
2561
  let sx = screen.text(composerX, stripY, '▤ ', makeStyle({ fg: t.primary, bg }))
2354
2562
  let drawn = 0
2355
2563
  for (const group of groups) {
2356
- const lead = drawn > 0 ? '│' : ''
2564
+ const lead = drawn > 0 ? ' ' : ''
2357
2565
  if (sx + displayWidth(lead) + displayWidth(group) > budget) break
2358
2566
  sx = screen.text(sx, stripY, lead + group, makeStyle({ fg: t.textMuted, bg }))
2359
2567
  drawn++
2360
2568
  }
2361
- if (drawn < groups.length) screen.text(sx, stripY, '│…', makeStyle({ fg: t.border, bg }))
2362
- if (hintSeat) screen.text(cols - hintW - 2, stripY, hint, makeStyle({ fg: t.border, dim: true, bg }))
2569
+ if (drawn < groups.length) screen.text(sx, stripY, ' …', makeStyle({ fg: t.border, bg }))
2570
+ if (hintSeat) screen.text(composerX + composerW - hintW - 2, stripY, hint, makeStyle({ fg: t.textMuted, bg }))
2363
2571
  this.addHitRegion('stats', 0, stripY, cols, 1)
2364
2572
  }
2365
2573
  if (suggestions > 0) {
2366
- const matches = COMMAND_HINTS.filter(([command]) => command.startsWith(this.inputText)).slice(0, suggestions)
2367
- for (const [command, description] of matches) {
2368
- screen.fill(composerX, composerTop, composerW, ' ', makeStyle({ bg: t.backgroundElement }))
2369
- screen.text(composerX + 2, composerTop, command, makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
2370
- screen.text(composerX + 18, composerTop, tr(description), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
2574
+ const matches = this.commandMatches()
2575
+ const start = Math.max(0, Math.min(this.commandSelection - suggestions + 1, matches.length - suggestions))
2576
+ for (let i = start; i < start + suggestions; i++) {
2577
+ const [command, description] = matches[i]
2578
+ const selected = i === this.commandSelection
2579
+ const bg = selected ? t.backgroundElement : t.backgroundPanel
2580
+ screen.fill(composerX, composerTop, composerW, ' ', makeStyle({ bg }))
2581
+ screen.text(composerX, composerTop, selected ? '┃' : ' ', makeStyle({ fg: t.primary, bg }))
2582
+ screen.text(composerX + 2, composerTop, truncateWidth(command, composerW - 4), makeStyle({ fg: selected ? t.secondary : t.text, bold: selected, bg }))
2583
+ if (composerW >= 40) screen.text(composerX + 18, composerTop, truncateWidth(tr(description), composerW - 20), makeStyle({ fg: t.textMuted, bg }))
2584
+ this.addHitRegion('command', composerX, composerTop, composerW, 1, { commandIndex: i })
2371
2585
  composerTop++
2372
2586
  }
2373
2587
  }
@@ -2377,42 +2591,23 @@ export class App {
2377
2591
  screen.text(composerX + 2, composerTop, truncateWidth(hint, composerW - 4), makeStyle({ fg: t.warning, bold: true, bg: t.background }))
2378
2592
  composerTop++
2379
2593
  }
2380
- // Composer frame: idle draws the static border. While a turn runs the
2381
- // frame flows instead of sitting flat yellow - gold dashes with a bright
2382
- // head chase each other clockwise around the box (marching ants),
2383
- // time-based like the transcript spinners.
2384
2594
  const running = this.status === 'running'
2385
- const framePhase = Math.floor(Date.now() / 80)
2386
- const frameH = inputRowsVisible + 2
2387
- const borderStyle = running ? null : makeStyle({ fg: t.border })
2388
- if (running) {
2389
- for (let i = 0; i < composerW; i++) {
2390
- screen.set(composerX + i, composerTop, i === 0 ? '╭' : i === composerW - 1 ? '╮' : '─', this._flowBorderStyle(i, framePhase))
2391
- }
2392
- } else {
2393
- screen.text(composerX, composerTop, '╭' + '─'.repeat(Math.max(0, composerW - 2)) + '╮', borderStyle)
2595
+ const composerBg = t.backgroundPanel
2596
+ const focusColor = this.pendingApproval ? t.warning : t.primary
2597
+ for (let i = 0; i < inputRowsVisible + composerGap + 2; i++) {
2598
+ screen.fill(composerX, composerTop + i, composerW, ' ', makeStyle({ bg: composerBg }))
2599
+ screen.set(composerX, composerTop + i, '┃', makeStyle({ fg: focusColor, bg: composerBg }))
2394
2600
  }
2395
2601
  this._paintEffortLabel(screen, composerX, composerTop, composerW)
2396
2602
  const firstVisual = Math.max(0, visual.cursorRow - inputRowsVisible + 1)
2397
2603
  // The approval prompt replaces the composer's content. Computed once: the
2398
2604
  // same helper _layout used to size the box, so the two agree by construction.
2399
2605
  const approvalLines = this.pendingApproval
2400
- ? this._approvalLines(composerTextWidth(cols, sidebarW, railW), inputRowsVisible)
2606
+ ? this._approvalLines(composerTextWidth(cols, sidebarW, railW, home), inputRowsVisible)
2401
2607
  : null
2402
2608
  for (let i = 0; i < inputRowsVisible; i++) {
2403
2609
  const y = composerTop + 1 + i
2404
2610
  const visualIndex = firstVisual + i
2405
- screen.fill(composerX, y, composerW, ' ', makeStyle({ bg: t.backgroundPanel }))
2406
- if (running) {
2407
- // Side edges follow the perimeter path: down the right edge, then
2408
- // back up the left one (see _flowBorderStyle for the mapping).
2409
- const edge = i + 1
2410
- screen.set(composerX + composerW - 1, y, '│', this._flowBorderStyle(composerW - 1 + edge, framePhase))
2411
- screen.set(composerX, y, '│', this._flowBorderStyle(2 * (composerW - 1) + (frameH - 1 - edge), framePhase))
2412
- } else {
2413
- screen.text(composerX, y, '│', borderStyle)
2414
- screen.text(composerX + composerW - 1, y, '│', borderStyle)
2415
- }
2416
2611
  if (approvalLines) {
2417
2612
  const line = approvalLines[i]
2418
2613
  if (line) {
@@ -2424,6 +2619,9 @@ export class App {
2424
2619
  const line = visual.rows[visualIndex] ?? ''
2425
2620
  let lx = composerX + 2
2426
2621
  const textStyle = makeStyle({ fg: t.text, bg: t.backgroundPanel })
2622
+ if (!this.inputText && i === 0) {
2623
+ screen.text(lx, y, truncateWidth(tr('splash.hint'), composerW - 4), makeStyle({ fg: t.textMuted, bg: composerBg }))
2624
+ }
2427
2625
  const chipStyle = makeStyle({ fg: t.imageChipText, bg: t.imageChipBg, bold: true })
2428
2626
  const markerRe = /\[Image \d+\]/g
2429
2627
  let last = 0
@@ -2436,20 +2634,12 @@ export class App {
2436
2634
  if (last < line.length) lx = screen.text(lx, y, line.slice(last), textStyle)
2437
2635
  if (visualIndex === visual.cursorRow) {
2438
2636
  const cursorX = composerX + 2 + visual.cursorCol
2439
- const current = Array.from(this.inputText.slice(this.inputCursor))[0] ?? ' '
2637
+ const current = Array.from(this.inputText.slice(this.inputCursor))[0] ?? (!this.inputText ? Array.from(tr('splash.hint'))[0] : ' ')
2440
2638
  screen.text(cursorX, y, current === '\n' ? ' ' : current, makeStyle({ fg: t.background, bg: t.primary }))
2441
2639
  }
2442
2640
  }
2443
- const bottom = composerTop + inputRowsVisible + 1
2444
- this.addHitRegion('composer', composerX, composerTop, composerW, inputRowsVisible + 2, { composerTop, firstVisual })
2445
- if (running) {
2446
- for (let i = 0; i < composerW; i++) {
2447
- // Bottom edge continues the perimeter: right -> left.
2448
- screen.set(composerX + i, bottom, i === 0 ? '╰' : i === composerW - 1 ? '╯' : '─', this._flowBorderStyle(composerW + frameH - 3 + (composerW - 1 - i), framePhase))
2449
- }
2450
- } else {
2451
- screen.text(composerX, bottom, '╰' + '─'.repeat(Math.max(0, composerW - 2)) + '╯', borderStyle)
2452
- }
2641
+ const bottom = composerTop + inputRowsVisible + composerGap + 1
2642
+ this.addHitRegion('composer', composerX, composerTop, composerW, inputRowsVisible + composerGap + 2, { composerTop, firstVisual })
2453
2643
  // The composer's mode label names the model that will receive the message.
2454
2644
  // With the status rail on screen the rail's MODEL section owns that fact,
2455
2645
  // so the label only stays for the interrupt hint.
@@ -2457,7 +2647,12 @@ export class App {
2457
2647
  ? tr('status.interrupt')
2458
2648
  : railW > 0 ? '' : (this.provider ? this.provider + ' · ' : '') + (this.model || tr('status.model'))
2459
2649
  if (mode !== '') {
2460
- screen.text(composerX + 2, bottom, ' ' + truncateWidth(mode, Math.max(0, composerW - 6)) + ' ', makeStyle({ fg: this.status === 'running' ? t.warning : t.textMuted, bg: t.background }))
2650
+ screen.text(composerX + 2, bottom, truncateWidth(mode, Math.max(0, composerW - 4)), makeStyle({ fg: running ? t.warning : t.textMuted, bg: composerBg }))
2651
+ }
2652
+ const sendHints = tr('composer.keys').split(' · ').slice(0, 2).join(' · ')
2653
+ const hintsW = displayWidth(sendHints)
2654
+ if (!home && hintsW + displayWidth(mode) + 8 <= composerW) {
2655
+ paintKeyHints(screen, composerX + composerW - hintsW - 2, bottom, sendHints, hintsW, t, makeStyle({ fg: t.textMuted, bg: composerBg }))
2461
2656
  }
2462
2657
 
2463
2658
  // Reasoning-effort slider: one row between the composer and the status
@@ -2470,12 +2665,8 @@ export class App {
2470
2665
  screen.fill(0, statusRow, cols, ' ', statusStyle)
2471
2666
  let leftX = 3
2472
2667
  if (this.status === 'running' && this.hasAnimation()) {
2473
- // Flowing wave indicator while the agent is working (read/write/tools/
2474
- // thinking): consecutive spinner frames render side by side so the
2475
- // pattern visibly flows left to right.
2476
2668
  const phase = Math.floor(Date.now() / 70) % SPINNER.length
2477
- const flow = SPINNER[phase] + SPINNER[(phase + 1) % SPINNER.length] + SPINNER[(phase + 2) % SPINNER.length]
2478
- screen.text(1, statusRow, flow, makeStyle({ fg: t.primary, bg: t.background }))
2669
+ screen.text(1, statusRow, SPINNER[phase], makeStyle({ fg: t.primary, bg: t.background }))
2479
2670
  leftX = 4
2480
2671
  } else {
2481
2672
  const statusDot = this.status === 'running' ? '▮' : '·'
@@ -2513,6 +2704,14 @@ export class App {
2513
2704
  screen.text(leftX, statusRow, leftClipped, statusStyle)
2514
2705
  if (meterGeom) this._paintContextMeter(screen, statusRow, meterGeom)
2515
2706
  }
2707
+ const keyHints = tr(suggestions > 0 ? 'composer.commandKeys' : 'composer.keys')
2708
+ if (home || (suggestions > 0 && cols >= 76)) {
2709
+ const hintW = displayWidth(keyHints)
2710
+ if (hintW + 4 <= borderX) {
2711
+ screen.fill(0, statusRow, borderX, ' ', statusStyle)
2712
+ paintKeyHints(screen, borderX - hintW - 2, statusRow, keyHints, hintW, t, statusStyle)
2713
+ }
2714
+ }
2516
2715
 
2517
2716
  // Toast: bottom-center, on the same status row as the model/meter
2518
2717
  // readings; it auto-dismisses after 2 seconds.
@@ -2530,6 +2729,21 @@ export class App {
2530
2729
  if (railW > 0) this._paintRail(screen, { cols, rows, headerH, railW, borderX })
2531
2730
 
2532
2731
  // Overlays
2732
+ if (this.overlay || this.pendingQuestions || this.pendingSecret || this.statsOpen) {
2733
+ const shades = new Map()
2734
+ for (const row of screen.cells) {
2735
+ for (const cell of row) {
2736
+ const style = cell.style ?? {}
2737
+ const key = (style.fg ?? t.text) + ':' + (style.bg ?? t.background)
2738
+ if (!shades.has(key)) shades.set(key, makeStyle({
2739
+ fg: mixColor(style.fg ?? t.text, t.background, 0.6),
2740
+ bg: mixColor(style.bg ?? t.background, t.background, 0.35),
2741
+ }))
2742
+ cell.style = shades.get(key)
2743
+ }
2744
+ }
2745
+ screen.images = []
2746
+ }
2533
2747
  if (this.overlay === 'help') this._paintHelp(screen, cols, rows, railW)
2534
2748
  if (this.overlay === 'settings') this._paintSettings(screen, cols, rows, railW)
2535
2749
  if (this.overlay === 'rewind') this._paintRewind(screen, cols, rows, railW)
@@ -2540,6 +2754,32 @@ export class App {
2540
2754
  else if (this.statsOpen && !this.overlay) this._paintStats(screen, cols, rows, railW)
2541
2755
  if (this.pendingSecret) this._paintSecret(screen, cols, rows, railW)
2542
2756
 
2757
+ // MCP connection notice: top-right, above the header, dismissed after two
2758
+ // seconds. It is painted last so an overlay cannot bury the answer to
2759
+ // "did that server come up".
2760
+ if (this.mcpNotice && cols > 0 && rows > 0) {
2761
+ const connected = this.mcpNotice.status === 'connected'
2762
+ const label = tr(connected ? 'mcp.notice.connected' : 'mcp.notice.failed', { name: this.mcpNotice.name })
2763
+ const text = truncateWidth(' ' + label + ' ', cols)
2764
+ const width = displayWidth(text)
2765
+ const x = Math.max(0, cols - width)
2766
+ const style = makeStyle({ fg: connected ? t.success : t.error, bg: t.backgroundElement, bold: true })
2767
+ screen.fill(x, 0, width, ' ', style)
2768
+ screen.text(x, 0, text, style)
2769
+ }
2770
+ if (this.cornerNotice && cols > 0 && rows > 0) {
2771
+ const width = Math.min(cols, Math.max(20, Math.min(64, Math.max(displayWidth(this.cornerNotice.title), displayWidth(this.cornerNotice.detail)) + 4)))
2772
+ const x = cols - width
2773
+ const titleStyle = makeStyle({ fg: this.cornerNotice.level === 'error' ? t.error : t.success, bg: t.backgroundElement, bold: true })
2774
+ const detailStyle = makeStyle({ fg: t.text, bg: t.backgroundElement })
2775
+ screen.fill(x, 0, width, ' ', titleStyle)
2776
+ screen.text(x + 1, 0, truncateWidth(this.cornerNotice.title, Math.max(0, width - 2)), titleStyle)
2777
+ if (rows > 1) {
2778
+ screen.fill(x, 1, width, ' ', detailStyle)
2779
+ screen.text(x + 1, 1, shortenPath(this.cornerNotice.detail, Math.max(0, width - 2)), detailStyle)
2780
+ }
2781
+ }
2782
+
2543
2783
  // Normalize backgrounds: transcript markdown segments, indents, and row
2544
2784
  // tail fills carry fg-only styles, which would otherwise fall back to the
2545
2785
  // terminal's own default background (usually black). Every cell in the
@@ -2576,7 +2816,7 @@ export class App {
2576
2816
  // the origin and the policy-permitted details. The composer previously showed
2577
2817
  // the action alone, so three of the four were dropped.
2578
2818
  _approvalLines(width, maxRows) {
2579
- const t = THEME
2819
+ const t = this.theme
2580
2820
  const state = this.pendingApproval
2581
2821
  const rows = Math.max(1, Math.floor(maxRows))
2582
2822
  if (!state || !(width >= 8)) return []
@@ -2667,7 +2907,7 @@ export class App {
2667
2907
  }
2668
2908
 
2669
2909
  _paintEffortSlider(screen, cols, rows, rightEdge = cols) {
2670
- const t = THEME
2910
+ const t = this.theme
2671
2911
  const slider = this.effortSlider
2672
2912
  if (!slider || slider.levels.length === 0) return
2673
2913
  const y = rows - 2
@@ -2680,28 +2920,11 @@ export class App {
2680
2920
  const left = 2
2681
2921
  const trackWidth = Math.max(10, Math.min(26, rightEdge - left - 48))
2682
2922
  const fill = levels.length <= 1 ? 1 : Math.max(1, Math.round((index / (levels.length - 1)) * trackWidth))
2683
- const phase = Math.floor(Date.now() / 60)
2684
- const head = atMax ? phase % fill : -1
2685
2923
  for (let i = 0; i < trackWidth; i++) {
2686
2924
  if (i < fill) {
2687
- // Filled segment: flat primary, or at max a gradient that flows
2688
- // left -> right (wave index shrinks with position: (phase - i)), with
2689
- // a bright comet head sweeping across the fill and a short trail.
2690
- let color = t.primary
2691
- if (atMax) {
2692
- const wave = ((phase - i) % (fill + 1) + (fill + 1)) % (fill + 1) / Math.max(1, fill)
2693
- const base = mixColor(t.primary, t.accent, wave)
2694
- const dist = Math.abs(head - i)
2695
- color = dist === 0 ? mixColor(base, '#ffffff', 0.85)
2696
- : dist === 1 ? mixColor(base, '#ffffff', 0.45)
2697
- : dist === 2 ? mixColor(base, '#ffffff', 0.2)
2698
- : base
2699
- }
2700
- screen.text(left + i, y, '█', makeStyle({ fg: color, bg }))
2925
+ screen.text(left + i, y, '█', makeStyle({ fg: t.primary, bg }))
2701
2926
  } else {
2702
- // Empty segment: at max the cells shimmer, moving rightward too.
2703
- const ch = atMax && ((phase - i) % 2 + 2) % 2 === 0 ? '▒' : '░'
2704
- screen.text(left + i, y, ch, makeStyle({ fg: t.border, bg }))
2927
+ screen.text(left + i, y, '░', makeStyle({ fg: t.border, bg }))
2705
2928
  }
2706
2929
  }
2707
2930
  // Text-safe slider thumb on the right edge of the fill.
@@ -2711,7 +2934,7 @@ export class App {
2711
2934
  const current = levels[index] ?? levels[0]
2712
2935
  const name = truncateWidth(String(current?.name ?? current?.id ?? '—'), 18)
2713
2936
  let x = left + trackWidth + 2
2714
- screen.text(x, y, name, makeStyle({ fg: atMax ? t.warning : t.secondary, bold: true, bg }))
2937
+ screen.text(x, y, name, makeStyle({ fg: t.secondary, bold: true, bg }))
2715
2938
  x += displayWidth(name)
2716
2939
  // The real range the provider exposed, so a boolean-thinking model shows
2717
2940
  // exactly its two ends rather than a fake none..max scale.
@@ -2739,7 +2962,7 @@ export class App {
2739
2962
  // panel background (the renderer's default-background pass would otherwise
2740
2963
  // repaint text cells with the page background over the rail's panel).
2741
2964
  _railLines(width) {
2742
- const t = THEME
2965
+ const t = this.theme
2743
2966
  const bg = t.backgroundPanel
2744
2967
  const lines = []
2745
2968
  const st = (extra = {}) => makeStyle({ bg, ...extra })
@@ -2776,7 +2999,10 @@ export class App {
2776
2999
  const barW = Math.max(3, width - 2)
2777
3000
  const fill = Math.min(barW, Math.max(0, Math.round(meter.percent / 100 * barW)))
2778
3001
  const fillColor = meter.percent >= 100 ? t.error : meter.percent >= 90 ? t.warning : t.primary
2779
- lines.push([{ text: '█'.repeat(fill) + '░'.repeat(barW - fill), style: st({ fg: fillColor }) }])
3002
+ lines.push([
3003
+ { text: '━'.repeat(fill), style: st({ fg: fillColor }) },
3004
+ { text: '─'.repeat(barW - fill), style: st({ fg: t.border }) },
3005
+ ])
2780
3006
  row(tr('rail.percentUsed', { percent: meter.percent }), meter.percent >= 90 ? st({ fg: fillColor }) : muted)
2781
3007
  row(tr('rail.tokens', { used: formatTokens(meter.usedTokens), window: formatTokens(meter.contextWindow) }), muted)
2782
3008
  const m = this.metrics ?? {}
@@ -2799,9 +3025,10 @@ export class App {
2799
3025
 
2800
3026
  // The figures the narrow window's stats strip carries; the strip drops out
2801
3027
  // while the rail is up, so this is their only home in the wide layout.
2802
- if (this._statsStripVisible()) {
3028
+ const sessionStats = this._statsGroups({ billing: !meter })
3029
+ if (sessionStats.length > 0) {
2803
3030
  section(tr('rail.session'))
2804
- for (const group of this._statsGroups()) {
3031
+ for (const group of sessionStats) {
2805
3032
  for (const piece of wrapText(group, width)) row(piece, muted)
2806
3033
  }
2807
3034
  }
@@ -2845,7 +3072,7 @@ export class App {
2845
3072
  }
2846
3073
 
2847
3074
  _paintRail(screen, { cols, rows, headerH, railW, borderX }) {
2848
- const t = THEME
3075
+ const t = this.theme
2849
3076
  const bg = t.backgroundPanel
2850
3077
  const sideStyle = makeStyle({ fg: t.textMuted, bg })
2851
3078
  const x0 = borderX + 1
@@ -2919,37 +3146,17 @@ export class App {
2919
3146
  }
2920
3147
  }
2921
3148
 
2922
- // The current effort value, pinned to the top-right corner of the composer
2923
- // box — diagonally opposite the `provider · model` label at bottom-left.
2924
- // `↑ max` gets a blinking text arrow and per-letter flowing gradient; any
2925
- // other level is a static bold label.
3149
+ // A stable label keeps the selected reasoning level visible while idle.
2926
3150
  _paintEffortLabel(screen, composerX, composerTop, composerW) {
2927
- const t = THEME
3151
+ const t = this.theme
2928
3152
  const level = this._effortLevel()
2929
3153
  if (!level || composerW < 16) return
2930
3154
  const atMax = this._effortAtMax()
2931
3155
  const name = truncateWidth(String(level.name ?? level.id), Math.max(4, composerW - 16))
2932
- const bg = t.background
2933
- if (atMax) {
2934
- const text = '↑ ' + name
2935
- const x = composerX + composerW - displayWidth(text) - 2
2936
- const phase = Math.floor(Date.now() / 60)
2937
- const blink = Math.floor(Date.now() / 130) % 2 === 0 ? t.warning : mixColor(t.warning, '#ffffff', 0.75)
2938
- screen.text(x, composerTop, '↑', makeStyle({ fg: blink, bold: true, bg }))
2939
- let cx = x + 2
2940
- let glyphIndex = 0
2941
- for (const ch of Array.from(name)) {
2942
- const wave = (((phase - glyphIndex * 3) % 10) + 10) % 10 / 10
2943
- screen.text(cx, composerTop, ch, makeStyle({ fg: mixColor(t.primary, t.accent, wave), bold: true, bg }))
2944
- cx += displayWidth(ch)
2945
- glyphIndex++
2946
- }
2947
- } else {
2948
- // The level name alone labels the corner — no "effort" prefix.
2949
- const text = name
2950
- const x = composerX + composerW - displayWidth(text) - 2
2951
- screen.text(x, composerTop, text, makeStyle({ fg: t.secondary, bold: true, bg }))
2952
- }
3156
+ const text = (atMax ? '↑ ' : '') + name + ' · Tab'
3157
+ const x = composerX + composerW - displayWidth(text) - 2
3158
+ screen.text(x, composerTop, text, makeStyle({ fg: t.secondary, bold: true, bg: t.backgroundPanel }))
3159
+ this.addHitRegion('effort', x, composerTop, displayWidth(text))
2953
3160
  }
2954
3161
 
2955
3162
  // Port of the web composer's ContextMeter ring: an always-visible occupancy
@@ -2973,12 +3180,12 @@ export class App {
2973
3180
  if (x0 < leftFloor) return null
2974
3181
  return {
2975
3182
  x0, y, total, barW, used, cap, pct,
2976
- fillColor: pct >= 100 ? THEME.error : pct >= 90 ? THEME.warning : THEME.primary,
3183
+ fillColor: pct >= 100 ? this.theme.error : pct >= 90 ? this.theme.warning : this.theme.primary,
2977
3184
  }
2978
3185
  }
2979
3186
 
2980
3187
  _paintContextMeter(screen, y, geom) {
2981
- const t = THEME
3188
+ const t = this.theme
2982
3189
  let x = geom.x0
2983
3190
  x = screen.text(x, y, 'ctx', makeStyle({ fg: t.textMuted, bold: true, bg: t.background }))
2984
3191
  x += 1
@@ -3005,7 +3212,7 @@ export class App {
3005
3212
  // percent; a zero-width part is dropped. Opened by clicking the stats strip
3006
3213
  // or the context meter, or with `/stats`.
3007
3214
  _paintStats(screen, cols, rows, railW = 0) {
3008
- const t = THEME
3215
+ const t = this.theme
3009
3216
  const m = this.metrics ?? {}
3010
3217
  const meter = this.contextMeter
3011
3218
  const body = []
@@ -3146,7 +3353,7 @@ export class App {
3146
3353
  // `plan-review` intent renders its `detail` — the plan under review — above
3147
3354
  // the choices; the answer encoding is identical either way.
3148
3355
  _paintQuestions(screen, cols, rows, railW = 0) {
3149
- const t = THEME
3356
+ const t = this.theme
3150
3357
  const q = this.pendingQuestions
3151
3358
  if (!q) return
3152
3359
  const question = q.questions[q.index]
@@ -3295,7 +3502,7 @@ export class App {
3295
3502
  // have their own inline masked field; this is the same masking applied to a
3296
3503
  // one-field modal that a standard protocol request can drive.
3297
3504
  _paintSecret(screen, cols, rows, railW = 0) {
3298
- const t = THEME
3505
+ const t = this.theme
3299
3506
  const state = this.pendingSecret
3300
3507
  // Clamped to the available columns as well as the 64-column cap: a 30-column
3301
3508
  // floor with a centred x would push the right edge past the frame on a very
@@ -3343,17 +3550,18 @@ export class App {
3343
3550
  // too; views without a menu (choice lists, session manager, model picker)
3344
3551
  // keep the classic single-column layout.
3345
3552
  _paintSettings(screen, cols, rows, railW = 0) {
3346
- const t = THEME
3553
+ const t = this.theme
3347
3554
  const menu = Array.isArray(this.settingsMenu) ? this.settingsMenu : []
3348
3555
  const hasMenu = menu.length > 0
3349
3556
  // The menu column is sized from its labels: "Aggiornamento" or "Обновление"
3350
3557
  // must not be cut to the English "Update" width. Capped so a runaway label
3351
3558
  // cannot eat the content pane.
3352
- const menuW = hasMenu ? Math.min(20, Math.max(12, ...menu.map((entry) => displayWidth(entry.label))) + 5) : 0
3353
3559
  const w = Math.min(hasMenu ? 84 : 68, overlayRegion(cols, railW) - 4)
3560
+ const menuW = hasMenu && w >= 72 ? Math.min(20, Math.max(12, ...menu.map((entry) => displayWidth(entry.label))) + 5) : 0
3561
+ const stacked = w - menuW < 50
3354
3562
  // Display rows: group headers (kind 'header') break the list into
3355
3563
  // sections with a blank separator line; every selectable item is one
3356
- // compact row. Headers are never selectable and carry no hit region.
3564
+ // compact row, or a label/value pair on narrow terminals.
3357
3565
  const display = []
3358
3566
  for (let i = 0; i < this.settingsItems.length; i++) {
3359
3567
  const item = this.settingsItems[i]
@@ -3362,6 +3570,9 @@ export class App {
3362
3570
  display.push({ header: item })
3363
3571
  } else {
3364
3572
  display.push({ item, index: i })
3573
+ if (stacked && !item.wide && (item.value || item.valueLabel || item.version || this.settingsEditing === i || this.settingsConfirm?.item === item)) {
3574
+ display.push({ item, index: i, detail: true })
3575
+ }
3365
3576
  }
3366
3577
  }
3367
3578
  const h = Math.max(9, Math.min(rows - 4, display.length + 6))
@@ -3384,7 +3595,7 @@ export class App {
3384
3595
  // Content area: everything right of the menu column.
3385
3596
  const cx = x0 + menuW
3386
3597
  const cw = w - menuW
3387
- if (hasMenu) {
3598
+ if (menuW > 0) {
3388
3599
  // Left menu column with a divider; the active entry is highlighted and
3389
3600
  // every entry is a click target.
3390
3601
  screen.set(x0 + menuW, y0, '┬', border)
@@ -3408,7 +3619,21 @@ export class App {
3408
3619
  screen.text(cx + 3, y0 + 1, truncateWidth(this.settingsTitle, cw - 6), makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
3409
3620
 
3410
3621
  const subtitleText = (this.settingsBusy ? this.animChar() + ' ' : '') + (this.settingsSubtitle || tr('settings.shared'))
3411
- screen.text(cx + 3, y0 + 2, truncateWidth(subtitleText, cw - 6), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
3622
+ if (hasMenu && menuW === 0) {
3623
+ let mx = cx + 3
3624
+ const order = [...menu.keys()].slice(this.settingsMenuIndex).concat([...menu.keys()].slice(0, this.settingsMenuIndex))
3625
+ for (const i of order) {
3626
+ const label = truncateWidth(menu[i].label, cw - 6)
3627
+ const mw = displayWidth(label) + 2
3628
+ if (mx + mw > cx + cw - 2) break
3629
+ const active = i === this.settingsMenuIndex
3630
+ screen.text(mx, y0 + 2, label, makeStyle({ fg: active ? t.secondary : t.textMuted, bold: active, bg: t.backgroundElement }))
3631
+ this.addHitRegion('settings-menu', mx, y0 + 2, mw, 1, { menuIndex: i })
3632
+ mx += mw
3633
+ }
3634
+ } else {
3635
+ screen.text(cx + 3, y0 + 2, truncateWidth(subtitleText, cw - 6), makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
3636
+ }
3412
3637
  // Scroll window with explicit scroll offset (wheel scrolling support).
3413
3638
  // The scroll offset is clamped to ensure the window always shows content.
3414
3639
  const maxScroll = Math.max(0, display.length - contentH)
@@ -3418,7 +3643,8 @@ export class App {
3418
3643
  let first = scrollOffset
3419
3644
  if (selRow >= 0) {
3420
3645
  if (selRow < first) first = selRow
3421
- if (selRow >= first + contentH) first = selRow - contentH + 1
3646
+ const selEnd = display[selRow + 1]?.detail && contentH > 1 ? selRow + 1 : selRow
3647
+ if (selEnd >= first + contentH) first = selEnd - contentH + 1
3422
3648
  }
3423
3649
  first = Math.max(0, Math.min(first, maxScroll))
3424
3650
  this.settingsScrollOffset = first
@@ -3438,7 +3664,7 @@ export class App {
3438
3664
  })
3439
3665
  if (selected) screen.fill(cx + 2, y, cw - 4, ' ', style)
3440
3666
  this.addHitRegion('settings-item', cx + 2, y, cw - 4, 1, { settingsIndex: i })
3441
- screen.text(cx + 3, y, selected ? '› ' : ' ', makeStyle({ fg: item.disabled ? t.textMuted : t.primary, bg: style.bg }))
3667
+ screen.text(cx + 3, y, selected ? '┃ ' : ' ', makeStyle({ fg: item.disabled ? t.textMuted : t.primary, bg: style.bg }))
3442
3668
  const draft = this.settingsSecret ? '•'.repeat(Array.from(this.settingsDraft).length) : this.settingsDraft
3443
3669
  const confirming = this.settingsConfirm?.item === item
3444
3670
  const confirmHint = confirming
@@ -3455,7 +3681,11 @@ export class App {
3455
3681
  const valueStyle = makeStyle({ fg: confirming ? t.warning : valueTone ? valueTone : selected && !item.disabled ? t.secondary : t.textMuted, bg: style.bg })
3456
3682
  // Nested rows (e.g. a provider's models) indent one step per level.
3457
3683
  const indent = (item.indent ?? 0) * 3
3458
- if (item.kind === 'session') {
3684
+ if (stacked) {
3685
+ const version = item.version ? [item.version, item.installed ? tr('common.installed') : '', ...(item.tags ?? []).map((tag) => '[' + tag + ']')].filter(Boolean).join(' · ') : ''
3686
+ const text = entry.detail ? value || version : item.label
3687
+ screen.text(cx + 6 + indent, y, truncateWidth(String(text ?? ''), cw - 9 - indent), entry.detail ? valueStyle : style)
3688
+ } else if (item.kind === 'session') {
3459
3689
  // Session rows right-align the timestamp and clamp the title so the
3460
3690
  // name and time columns stay clearly separated instead of running
3461
3691
  // together at a fixed 36-column boundary.
@@ -3479,9 +3709,10 @@ export class App {
3479
3709
  bg: style.bg,
3480
3710
  })
3481
3711
  let vx = cx + 6 + indent
3482
- vx = screen.text(vx, y, item.version, item.installed ? installedStyle : baseStyle)
3483
- if (item.installed) vx = screen.text(vx, y, ' ' + tr('common.installed'), installedStyle)
3484
- for (const tag of item.tags ?? []) vx = screen.text(vx, y, ' [' + tag + ']', tagStyle(tag))
3712
+ const piece = (text, style) => { vx = screen.text(vx, y, truncateWidth(text, Math.max(0, cx + cw - 3 - vx)), style) }
3713
+ piece(item.version, item.installed ? installedStyle : baseStyle)
3714
+ if (item.installed) piece(' ' + tr('common.installed'), installedStyle)
3715
+ for (const tag of item.tags ?? []) piece(' [' + tag + ']', tagStyle(tag))
3485
3716
  }
3486
3717
  } else if (item.kind === 'update-progress') {
3487
3718
  // Live download bar: label, ten cells of █/░, then the byte progress
@@ -3507,6 +3738,12 @@ export class App {
3507
3738
  }
3508
3739
  y++
3509
3740
  }
3741
+ if (display.length > contentH) {
3742
+ const thumbH = Math.max(1, Math.round(contentH * contentH / display.length))
3743
+ const thumbY = y0 + 4 + Math.round(first * (contentH - thumbH) / Math.max(1, maxScroll))
3744
+ for (let i = 0; i < contentH; i++) screen.set(x0 + w - 2, y0 + 4 + i, '│', border)
3745
+ for (let i = 0; i < thumbH; i++) screen.set(x0 + w - 2, thumbY + i, '┃', makeStyle({ fg: t.textMuted, bg: t.backgroundElement }))
3746
+ }
3510
3747
  const footer = this.settingsEditing !== null
3511
3748
  ? tr('settings.footerEdit')
3512
3749
  : hasMenu
@@ -3516,17 +3753,17 @@ export class App {
3516
3753
  // key token leads each pair in the accent color so it reads apart from
3517
3754
  // its description.
3518
3755
  if (this.settingsConfirm) {
3519
- screen.text(cx + 3, y0 + h - 2, footer, makeStyle({ fg: t.warning, bg: t.backgroundElement }))
3756
+ screen.text(cx + 3, y0 + h - 2, truncateWidth(footer, cw - 6), makeStyle({ fg: t.warning, bg: t.backgroundElement }))
3520
3757
  } else {
3521
3758
  paintKeyHints(screen, cx + 3, y0 + h - 2, footer, cw - 6, t)
3522
3759
  }
3523
3760
  }
3524
3761
 
3525
3762
  _paintHelp(screen, cols, rows, railW = 0) {
3526
- const t = THEME
3763
+ const t = this.theme
3527
3764
  const region = overlayRegion(cols, railW)
3528
3765
  const w = Math.min(64, region - 4)
3529
- const h = 25
3766
+ const h = Math.min(25, rows - 2)
3530
3767
  const x0 = Math.max(0, Math.floor((region - w) / 2))
3531
3768
  const y0 = Math.max(0, Math.floor((rows - h) / 2))
3532
3769
  const box = makeStyle({ fg: t.text, bg: t.backgroundElement })
@@ -3542,12 +3779,13 @@ export class App {
3542
3779
  }
3543
3780
  screen.set(x0, y0, '╭', border); screen.set(x0 + w - 1, y0, '╮', border)
3544
3781
  screen.set(x0, y0 + h - 1, '╰', border); screen.set(x0 + w - 1, y0 + h - 1, '╯', border)
3545
- screen.text(x0 + 2, y0 + 1, tr('help.title'), makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
3782
+ screen.text(x0 + 2, y0 + 1, truncateWidth(tr('help.title'), w - 4), makeStyle({ fg: t.primary, bold: true, bg: t.backgroundElement }))
3546
3783
  const rows2 = [
3547
3784
  [tr('help.enter'), tr('help.enterDesc')],
3548
3785
  [tr('help.ctrlEnter'), tr('help.ctrlEnterDesc')],
3549
3786
  [tr('help.ctrlC'), tr('help.ctrlCDesc')],
3550
3787
  [tr('help.ctrlP'), tr('help.ctrlPDesc')],
3788
+ ['Ctrl+S', tr('screenshot.shortcut')],
3551
3789
  [tr('help.ctrlE'), tr('help.ctrlEDesc')],
3552
3790
  [tr('help.tab'), tr('help.tabDesc')],
3553
3791
  [tr('help.ctrlN'), tr('help.ctrlNDesc')],
@@ -3564,19 +3802,23 @@ export class App {
3564
3802
  // The key column is measured, not assumed: a translated key label ("Hoch /
3565
3803
  // Runter") must not push its description off the box, and every description
3566
3804
  // is truncated to what is left of the row.
3567
- const keyCol = Math.max(14, ...rows2.map(([key]) => displayWidth(key))) + 2
3568
- const descRoom = Math.max(8, w - 3 - keyCol - 2)
3805
+ const keyCol = Math.min(Math.floor((w - 6) / 2), Math.max(14, ...rows2.map(([key]) => displayWidth(key))) + 2)
3806
+ const descRoom = Math.max(1, w - 5 - keyCol)
3807
+ const keyRows = h < 22 ? [...rows2.slice(0, Math.max(0, h - 7)), rows2.at(-1)] : rows2
3569
3808
  let yy = y0 + 3
3570
- for (const [key, desc] of rows2) {
3571
- screen.text(x0 + 3, yy, key, makeStyle({ fg: t.success, bg: t.backgroundElement }))
3809
+ for (const [key, desc] of keyRows) {
3810
+ screen.text(x0 + 3, yy, truncateWidth(key, keyCol - 2), makeStyle({ fg: t.success, bg: t.backgroundElement }))
3572
3811
  screen.text(x0 + 3 + keyCol, yy, truncateWidth(desc, descRoom), makeStyle({ fg: t.text, bg: t.backgroundElement }))
3573
3812
  yy++
3574
3813
  }
3575
- yy++
3576
- screen.text(x0 + 3, yy, tr('help.commands'), makeStyle({ fg: t.accent, bold: true, bg: t.backgroundElement }))
3577
- yy++
3814
+ if (h === 25) {
3815
+ yy++
3816
+ screen.text(x0 + 3, yy, truncateWidth(tr('help.commands'), w - 6), makeStyle({ fg: t.accent, bold: true, bg: t.backgroundElement }))
3817
+ yy++
3818
+ }
3578
3819
  for (const cmd of ['/help /settings /new /resume <id>', '/branch /model <id> /provider <route> /rewind', '/stats /clear /cancel /quit']) {
3579
- screen.text(x0 + 3, yy, cmd, makeStyle({ fg: t.text, bg: t.backgroundElement }))
3820
+ if (yy >= y0 + h - 1) break
3821
+ screen.text(x0 + 3, yy, truncateWidth(cmd, w - 6), makeStyle({ fg: t.text, bg: t.backgroundElement }))
3580
3822
  yy++
3581
3823
  }
3582
3824
  }
@@ -3584,7 +3826,7 @@ export class App {
3584
3826
  _paintRewind(screen, cols, rows, railW = 0) {
3585
3827
  const picker = this.rewind
3586
3828
  if (!picker) return
3587
- const t = THEME
3829
+ const t = this.theme
3588
3830
  const listing = picker.step === 'restore' ? picker.restoreOptions : picker.items
3589
3831
  const sel = picker.step === 'restore' ? picker.modeIndex : picker.selected
3590
3832
  const region = overlayRegion(cols, railW)
@@ -3664,7 +3906,7 @@ export class App {
3664
3906
  _paintGitBranches(screen, cols, rows, railW = 0) {
3665
3907
  const picker = this.gitBranches
3666
3908
  if (!picker) return
3667
- const t = THEME
3909
+ const t = this.theme
3668
3910
  const region = overlayRegion(cols, railW)
3669
3911
  const w = Math.min(72, region - 4)
3670
3912
  const h = Math.min(rows - 2, 8 + Math.min(7, Math.max(1, picker.items.length)))
@@ -3742,7 +3984,7 @@ export class App {
3742
3984
  // every footer catalog follows by design — so a translated key word
3743
3985
  // ("Entrée") is highlighted exactly like "Enter".
3744
3986
  function paintKeyHints(screen, x, y, text, maxWidth, theme, base = null) {
3745
- const keyStyle = makeStyle({ fg: theme.accent, bold: true, bg: theme.backgroundElement })
3987
+ const keyStyle = makeStyle({ fg: theme.accent, bold: true, bg: base?.bg ?? theme.backgroundElement })
3746
3988
  const restStyle = base ?? makeStyle({ fg: theme.textMuted, bg: theme.backgroundElement })
3747
3989
  let cx = x
3748
3990
  const put = (chunk, style) => {
@@ -3771,4 +4013,4 @@ function rewindAgo(ms) {
3771
4013
  if (sec < 3600) return Math.round(sec / 60) + 'm'
3772
4014
  if (sec < 86400) return Math.round(sec / 3600) + 'h'
3773
4015
  return Math.round(sec / 86400) + 'd'
3774
- }
4016
+ }