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.
- package/README.md +21 -563
- package/bin/mcp-sse-bridge.mjs +499 -499
- package/docs/assets/conversation.png +0 -0
- package/docs/assets/cover.png +0 -0
- package/docs/assets/home.png +0 -0
- package/docs/assets/settings.png +0 -0
- package/docs/dsh-std-/346/216/245/345/205/245/350/257/264/346/230/216.md +566 -0
- package/docs/user-guide.md +616 -0
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +607 -593
- package/dsh-plugin.json +69 -69
- package/lib/boot-block.js +129 -0
- package/lib/diagnostics.js +162 -120
- package/lib/dsh-version.js +161 -0
- package/lib/git-branch.js +157 -157
- package/lib/i18n.js +283 -283
- package/lib/image.js +2 -1
- package/lib/index.js +5183 -4812
- package/lib/locales/ar.js +670 -635
- package/lib/locales/de.js +639 -604
- package/lib/locales/en.js +35 -0
- package/lib/locales/es.js +639 -604
- package/lib/locales/fr.js +639 -604
- package/lib/locales/id.js +641 -606
- package/lib/locales/it.js +639 -604
- package/lib/locales/ja.js +639 -604
- package/lib/locales/ko.js +639 -604
- package/lib/locales/nl.js +639 -604
- package/lib/locales/pl.js +653 -618
- package/lib/locales/pt-BR.js +639 -604
- package/lib/locales/ru.js +653 -618
- package/lib/locales/tr.js +639 -604
- package/lib/locales/vi.js +641 -606
- package/lib/locales/zh-CN.js +640 -605
- package/lib/locales/zh-TW.js +640 -605
- package/lib/markdown.js +101 -27
- package/lib/mcp-monitor.js +189 -0
- package/lib/mcp-skill.js +93 -93
- package/lib/mcp.js +865 -838
- package/lib/model-route.js +66 -0
- package/lib/providers.js +712 -712
- package/lib/screenshot.js +140 -0
- package/lib/splash-opencode.js +29 -26
- package/lib/startup.js +66 -48
- package/lib/term.js +53 -11
- package/lib/themes.js +101 -0
- package/lib/ui.js +685 -443
- package/lib/updates.js +5 -2
- package/lib/web-settings.js +52 -21
- package/package.json +160 -146
- 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
|
-
|
|
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
|
|
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
|
|
123
|
-
const
|
|
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
|
|
126
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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.
|
|
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:
|
|
644
|
-
: makeStyle({ bg:
|
|
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)
|
|
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.
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
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```',
|
|
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:
|
|
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,
|
|
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:
|
|
1262
|
-
for (const line of renderMarkdown('```mermaid\n' + code + '\n```',
|
|
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```',
|
|
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:
|
|
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```',
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 =
|
|
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 '
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
const
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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
|
-
|
|
1786
|
-
|
|
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 ? '
|
|
1983
|
+
: { text: collapsed ? '+' : '−', style: makeStyle({ fg: t.thinking }) }
|
|
1818
1984
|
const body = []
|
|
1819
1985
|
if (!collapsed) {
|
|
1820
|
-
|
|
1821
|
-
|
|
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
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
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: '
|
|
1995
|
+
{ text: ' ', style: null },
|
|
1830
1996
|
marker,
|
|
1831
|
-
{ text: tr('transcript.thinking'), style: makeStyle({ fg: t.thinking
|
|
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:
|
|
2001
|
+
{ text: preview, style: makeStyle({ fg: t.thinking, italic: true }) },
|
|
1836
2002
|
]
|
|
1837
2003
|
: []),
|
|
1838
|
-
{ text: hint, style: makeStyle({ fg: t.
|
|
1839
|
-
], body,
|
|
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(
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
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' ? '✗' :
|
|
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
|
-
|
|
1894
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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: '
|
|
1920
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
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
|
|
1959
|
-
// naming the label, the body hanging under it in the
|
|
1960
|
-
// color, collapsed by default. Whole block clickable to
|
|
1961
|
-
|
|
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) => (
|
|
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
|
|
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) => (
|
|
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) => (
|
|
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
|
-
|
|
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 =
|
|
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',
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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, {
|
|
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 <=
|
|
2351
|
-
const budget =
|
|
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, '
|
|
2362
|
-
if (hintSeat) screen.text(
|
|
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 =
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
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
|
|
2386
|
-
const
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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([
|
|
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
|
-
|
|
3028
|
+
const sessionStats = this._statsGroups({ billing: !meter })
|
|
3029
|
+
if (sessionStats.length > 0) {
|
|
2803
3030
|
section(tr('rail.session'))
|
|
2804
|
-
for (const group of
|
|
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 =
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 ? '
|
|
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 (
|
|
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,
|
|
3483
|
-
|
|
3484
|
-
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
+
}
|