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