dsh-oc-tui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1771 @@
1
+ // The dsh-oc-tui app plugin: boots a terminal UI inside a dsh profile, drives
2
+ // agents through ctx.agents, renders the durable session/event feed, and
3
+ // routes human input back via followup/steer.
4
+ import { homedir } from 'node:os'
5
+ import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
6
+ import { readFile, readdir, rm } from 'node:fs/promises'
7
+ import Schema from '@deepseek-ai/schemastery'
8
+ import { contentHasImage, createUserMessage } from '@deepseek-ai/dsh-llm'
9
+ import { SessionId } from '@deepseek-ai/dsh-session'
10
+ import { parseCommand } from '@deepseek-ai/dsh-commands'
11
+ import { Terminal } from './term.js'
12
+ import { App, noteFromContext, cursorAtVisual } from './ui.js'
13
+ import { SessionMetrics } from './metrics.js'
14
+ import { InterruptState } from './interrupt.js'
15
+ import { installWebSettingSchemas, loadModelSettings, loadProviderModels, loadWebSettings, saveWebSetting } from './web-settings.js'
16
+ import { contentText, decodeDataUrl, detectImageMediaType, formatError, localImagePath, timeString } from './util.js'
17
+
18
+ async function deleteSessionDirectory(sessionId) {
19
+ const sessionsRoot = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'sessions')
20
+ const roots = await readdir(sessionsRoot, { withFileTypes: true })
21
+ for (const root of roots) {
22
+ if (!root.isDirectory()) continue
23
+ const rootPath = join(sessionsRoot, root.name)
24
+ const entries = await readdir(rootPath, { withFileTypes: true })
25
+ const match = entries.find((entry) => entry.isDirectory() && entry.name === sessionId)
26
+ if (!match) continue
27
+ await rm(join(rootPath, match.name), { recursive: true, force: false })
28
+ return true
29
+ }
30
+ return false
31
+ }
32
+
33
+ async function gitBranchAt(startPath) {
34
+ let directory = resolve(startPath)
35
+ const root = parse(directory).root
36
+ while (true) {
37
+ const marker = join(directory, '.git')
38
+ try {
39
+ let gitDirectory = marker
40
+ const markerText = await readFile(marker, 'utf8')
41
+ const match = /^gitdir:\s*(.+)$/im.exec(markerText)
42
+ if (match) gitDirectory = isAbsolute(match[1].trim()) ? match[1].trim() : resolve(directory, match[1].trim())
43
+ const head = (await readFile(join(gitDirectory, 'HEAD'), 'utf8')).trim()
44
+ return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : head.slice(0, 12)
45
+ } catch {
46
+ try {
47
+ const head = (await readFile(join(marker, 'HEAD'), 'utf8')).trim()
48
+ return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : head.slice(0, 12)
49
+ } catch { /* continue to parent */ }
50
+ }
51
+ if (directory === root) return ''
52
+ directory = dirname(directory)
53
+ }
54
+ }
55
+
56
+ export const name = 'dsh-oc-tui'
57
+
58
+ export const inject = ['agents', 'commands', 'sessionPersistence', 'tuiStartup']
59
+
60
+ export const Config = Schema.object({
61
+ sidebar: Schema.boolean().default(true),
62
+ showReasoning: Schema.boolean().default(true),
63
+ defaultModel: Schema.string(),
64
+ defaultProvider: Schema.string(),
65
+ })
66
+
67
+ export function apply(ctx, config) {
68
+ const startup = ctx.tuiStartup ?? {}
69
+ installWebSettingSchemas(ctx)
70
+ const term = new Terminal()
71
+ if (!term.isTTY()) {
72
+ console.error('dsh-oc-tui: stdin/stdout are not a TTY; run in an interactive terminal (dsh --profile tui)')
73
+ const exit = ctx.get('appExit')
74
+ if (exit) exit(1)
75
+ return
76
+ }
77
+ term.start()
78
+ const app = new App(term)
79
+ // Session history is available from Settings -> Manage sessions.
80
+ app.sidebarVisible = false
81
+
82
+ // Live session state.
83
+ let handle = null
84
+ let currentAgent = null
85
+ let openingSession = null
86
+ let openingVersion = 0
87
+ let lifecycleVersion = 0
88
+ let lastUserText = null
89
+ let lastUserTime = 0
90
+ const metrics = new SessionMetrics()
91
+ const interrupt = new InterruptState()
92
+ let showReasoning = config.showReasoning !== false
93
+ const modelPreference = {
94
+ model: startup.model ?? config.defaultModel ?? undefined,
95
+ provider: startup.provider ?? config.defaultProvider ?? undefined,
96
+ }
97
+ // Effective default model/provider. The persisted `agent-default-model`
98
+ // settings (what the Settings panel shows and saves) win over the harness
99
+ // composition entry, which `agentDefaultModel.currentSelection()` may still
100
+ // report before the settings scope has been attached. TUI reads the settings
101
+ // service live so the title bar and new sessions agree with the Settings UI.
102
+ function defaultModelSelection() {
103
+ const settings = ctx.get('settings')
104
+ if (settings) {
105
+ try {
106
+ const descriptor = settings.describe({ redactSecrets: true }).find((entry) => String(entry.ns) === 'agent-default-model')
107
+ const value = descriptor?.value
108
+ if (value && typeof value === 'object') {
109
+ return { provider: value.provider, model: value.model, reasoningEffort: value.reasoningEffort }
110
+ }
111
+ } catch { /* settings unavailable; fall back to composition selection */ }
112
+ }
113
+ const selection = ctx.get('agentDefaultModel')?.currentSelection?.()
114
+ return selection ?? {}
115
+ }
116
+ // The effective reasoning effort for the current model's requests: the
117
+ // slider/settings selection, applied through agent/request. `undefined`
118
+ // keeps the provider's own default behavior.
119
+ let effortPreference = undefined
120
+ let effortLoadVersion = 0
121
+
122
+ const paint = () => term.paint(app.render())
123
+ // High-frequency paints (streaming chunks, mouse motion) are coalesced so a
124
+ // busy AI turn never starves the event loop — the wheel and keyboard stay
125
+ // responsive while tokens stream in. Interactive actions paint immediately.
126
+ let paintTimer = null
127
+ const paintSoon = () => {
128
+ if (paintTimer) return
129
+ paintTimer = setTimeout(() => {
130
+ paintTimer = null
131
+ if (term.started) paint()
132
+ }, 40)
133
+ }
134
+ const paintNow = () => {
135
+ if (paintTimer) {
136
+ clearTimeout(paintTimer)
137
+ paintTimer = null
138
+ }
139
+ paint()
140
+ }
141
+ term.on('resize', () => paintNow())
142
+ paint()
143
+
144
+ // ---- context meter (web ContextMeter port) -----------------------------
145
+ // Current context length / context-length limit, read from the token-meter
146
+ // `contextPressure` projection plus the heuristic `contextBreakdown`
147
+ // composition via the sessionProjections registry that dsh-base mounts
148
+ // alongside token-meter. The meter simply stays hidden when either service
149
+ // is absent from the profile.
150
+ function refreshContextMeter() {
151
+ const projections = ctx.get('sessionProjections')
152
+ if (!currentAgent || !projections) {
153
+ app.setContextMeter(null)
154
+ return
155
+ }
156
+ try {
157
+ const values = projections.snapshot(currentAgent.session).values
158
+ app.setContextMeter({
159
+ pressure: values.contextPressure,
160
+ breakdown: values.contextBreakdown,
161
+ })
162
+ } catch {
163
+ app.setContextMeter(null)
164
+ }
165
+ }
166
+ {
167
+ const projections = ctx.get('sessionProjections')
168
+ if (projections) {
169
+ projections.onChanged((session, key) => {
170
+ if (!currentAgent || session.id !== currentAgent.session.id) return
171
+ if (key !== 'contextPressure' && key !== 'contextBreakdown') return
172
+ refreshContextMeter()
173
+ paintSoon()
174
+ })
175
+ }
176
+ }
177
+
178
+ // Drive the activity animations (flowing spinners for thinking / running
179
+ // tools like read/write). Paints are cheap (bounded render), and the loop
180
+ // only repaints while something is actually animating.
181
+ const animationTimer = setInterval(() => {
182
+ if (term.started && app.hasAnimation()) paint()
183
+ }, 80)
184
+
185
+ // ---- session lifecycle -------------------------------------------------
186
+
187
+ function newSessionId() {
188
+ return 'tui-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5)
189
+ }
190
+
191
+
192
+ async function showTitleScreen() {
193
+ const version = ++lifecycleVersion
194
+ if (handle) {
195
+ const old = handle
196
+ handle = null
197
+ currentAgent = null
198
+ try { await old.dispose() } catch { /* already settling */ }
199
+ }
200
+ if (version !== lifecycleVersion) return
201
+ const workingDirectory = process.cwd()
202
+ app.overlay = null
203
+ app.inputText = ''
204
+ app.inputCursor = 0
205
+ app.inputImages = []
206
+ app.resetView()
207
+ // Show the effective default model/provider on the title page (the same
208
+ // resolution used when a session is opened) instead of a bare placeholder.
209
+ const defaultSelection = defaultModelSelection()
210
+ app.setWelcome({
211
+ workingDirectory,
212
+ model: modelPreference.model ?? defaultSelection?.model ?? '',
213
+ provider: modelPreference.provider ?? defaultSelection?.provider ?? '',
214
+ })
215
+ app.setStatus('idle')
216
+ refreshContextMeter()
217
+ paint()
218
+ let gitBranch = ''
219
+ try { gitBranch = await gitBranchAt(workingDirectory) } catch (error) {
220
+ app.showToast('git: ' + formatError(error), 'warn')
221
+ }
222
+ if (version === lifecycleVersion && !currentAgent) {
223
+ // Re-resolve the default model/provider after the async gap: the settings
224
+ // scope may not have been attached when the title screen first painted.
225
+ const defaultSelection = defaultModelSelection()
226
+ app.setWelcome({
227
+ workingDirectory,
228
+ gitBranch,
229
+ model: modelPreference.model ?? defaultSelection?.model ?? '',
230
+ provider: modelPreference.provider ?? defaultSelection?.provider ?? '',
231
+ })
232
+ paint()
233
+ }
234
+ }
235
+
236
+ async function openSession({ resume = null, announce = true } = {}) {
237
+ const version = ++lifecycleVersion
238
+ app.overlay = null
239
+ // Tear down the previous agent to quiescence.
240
+ if (handle) {
241
+ const old = handle
242
+ handle = null
243
+ currentAgent = null
244
+ try { await old.dispose() } catch { /* already settling */ }
245
+ }
246
+ if (version !== lifecycleVersion) return false
247
+ lastUserText = null
248
+ app.inputText = ''
249
+ app.inputCursor = 0
250
+ app.inputImages = []
251
+ app.sidebarSelection = -1
252
+ app.resetView()
253
+ app.setSession({ title: 'New session' })
254
+ // Resolve the model selection: explicit preference (flag/config//model)
255
+ // wins, then the deployment's agent-default-model service (settings).
256
+ const agentOptions = {}
257
+ const defaultSelection = defaultModelSelection()
258
+ if (modelPreference.provider ?? defaultSelection?.provider) {
259
+ agentOptions.provider = modelPreference.provider ?? defaultSelection.provider
260
+ }
261
+ if (modelPreference.model ?? defaultSelection?.model) {
262
+ agentOptions.model = modelPreference.model ?? defaultSelection.model
263
+ }
264
+ try {
265
+ const openedHandle = resume
266
+ ? await ctx.agents.resume({
267
+ resumeSessionId: SessionId(resume),
268
+ agentOptions,
269
+ })
270
+ : await ctx.agents.create({
271
+ sessionId: SessionId(newSessionId()),
272
+ meta: { cwd: process.cwd() },
273
+ agentOptions,
274
+ })
275
+ if (version !== lifecycleVersion) {
276
+ try { await openedHandle.dispose() } catch { /* already settling */ }
277
+ return false
278
+ }
279
+ handle = openedHandle
280
+ currentAgent = openedHandle.agent
281
+ app.setSession({
282
+ id: String(currentAgent.session.id),
283
+ model: currentAgent.options.model ?? '',
284
+ provider: currentAgent.options.provider ?? '',
285
+ })
286
+ void loadEffortSlider()
287
+ replay(currentAgent.session)
288
+ refreshContextMeter()
289
+ app.addSystem('session ' + currentAgent.session.id + (resume ? ' resumed' : ' started') + ' · ' + process.cwd(), 'info')
290
+ app.setStatus('idle')
291
+ void refreshRecentSessions()
292
+ paint()
293
+ return true
294
+ } catch (error) {
295
+ if (version !== lifecycleVersion) return false
296
+ handle = null
297
+ currentAgent = null
298
+ app.addSystem('failed to open session: ' + formatError(error), 'error')
299
+ app.setStatus('idle')
300
+ paint()
301
+ return false
302
+ }
303
+ }
304
+
305
+ // A user/message the human never typed: labeled context (system-reminder /
306
+ // compaction) becomes a collapsible note box; anything else stays a dim
307
+ // system line.
308
+ function addContextMessage(src, text) {
309
+ const note = noteFromContext(src, text)
310
+ if (note) app.addNote(note.text, note.label)
311
+ else app.addSystem('[' + (src?.kind ?? 'context') + '] ' + text, 'dim')
312
+ }
313
+
314
+ // Build the transcript from a session's durable log (resume/replay path).
315
+ function replay(session) {
316
+ metrics.reset()
317
+ for (const event of session.events) {
318
+ metrics.consume(event)
319
+ switch (event.type) {
320
+ case 'user/message': {
321
+ const src = event.data.source
322
+ const text = contentText(event.data.content)
323
+ if (src?.kind === 'user') app.addUser(text)
324
+ else addContextMessage(src, text)
325
+ break
326
+ }
327
+ case 'assistant/message': {
328
+ const msg = event.data.message
329
+ // Reasoning lives in its own box; never mix it into the visible text.
330
+ const text = contentText(msg.content, { skipReasoning: true })
331
+ const reasoning = msg.content
332
+ .filter((b) => b.type === 'reasoning')
333
+ .map((b) => b.text)
334
+ .join('')
335
+ const asst = app.ensureAssistantBlock(event.time)
336
+ asst.text = text
337
+ if (reasoning) asst.reasoning = showReasoning ? reasoning : ''
338
+ // Otherwise keep whatever reasoning streamed into this block already.
339
+ asst.streaming = false
340
+ asst.time = event.time
341
+ asst.rev = (asst.rev ?? 0) + 1
342
+ if (asst.reasoning && asst.thinkingCollapsed === undefined) asst.thinkingCollapsed = true
343
+ for (const block of msg.content) {
344
+ if (block.type === 'tool-call') {
345
+ app.startTool({ callId: block.id, name: block.name, args: block.arguments })
346
+ }
347
+ }
348
+ break
349
+ }
350
+ case 'tool/result': {
351
+ const msg = event.data.message
352
+ const text = contentText(msg.content)
353
+ const isError = event.data.error !== undefined
354
+ || msg.content[0]?.isError === true
355
+ app.updateTool(msg.source.callId, { status: isError ? 'error' : 'ok', result: text })
356
+ break
357
+ }
358
+ case 'todo/write': {
359
+ app.setTodo(event.data.todos)
360
+ break
361
+ }
362
+ default:
363
+ break
364
+ }
365
+ }
366
+ app.setMetrics(metrics.snapshot())
367
+ const titleEvent = [...session.events].reverse().find((e) => e.type === 'session/title')
368
+ if (titleEvent) app.setSession({ title: titleEvent.data.title })
369
+ }
370
+
371
+ // ---- durable event stream ----------------------------------------------
372
+
373
+ ctx.on('session/event', (session, event) => {
374
+ if (event.type === 'session/title' || event.type === 'user/message') {
375
+ void Promise.resolve().then(() => refreshRecentSessions())
376
+ }
377
+ if (!currentAgent || session.id !== currentAgent.session.id) return
378
+ metrics.consume(event)
379
+ app.setMetrics(metrics.snapshot())
380
+ handleSessionEvent(event)
381
+ paintSoon()
382
+ }, { global: true })
383
+
384
+ ctx.on('session/created', () => {
385
+ void Promise.resolve().then(() => refreshRecentSessions())
386
+ }, { global: true })
387
+
388
+ function handleSessionEvent(event) {
389
+ switch (event.type) {
390
+ case 'user/message': {
391
+ const src = event.data.source
392
+ const text = contentText(event.data.content)
393
+ if (src?.kind === 'user') {
394
+ const now = Date.now()
395
+ if (text === lastUserText && now - lastUserTime < 8000) return
396
+ app.addUser(text)
397
+ } else {
398
+ addContextMessage(src, text)
399
+ }
400
+ break
401
+ }
402
+ case 'assistant/chunk': {
403
+ const chunk = event.data.chunk
404
+ if (chunk.type === 'text-delta') app.streamChunk(chunk)
405
+ else if (chunk.type === 'reasoning-delta' && showReasoning) {
406
+ app.streamChunk(chunk)
407
+ }
408
+ break
409
+ }
410
+ case 'assistant/message': {
411
+ const msg = event.data.message
412
+ // Reasoning lives in its own box; never mix it into the visible text.
413
+ const text = contentText(msg.content, { skipReasoning: true })
414
+ const reasoning = msg.content
415
+ .filter((b) => b.type === 'reasoning')
416
+ .map((b) => b.text)
417
+ .join('')
418
+ const asst = app.ensureAssistantBlock(event.time)
419
+ asst.text = text
420
+ if (reasoning) asst.reasoning = showReasoning ? reasoning : ''
421
+ // Otherwise keep whatever reasoning streamed into this block already.
422
+ asst.streaming = false
423
+ asst.time = event.time
424
+ asst.rev = (asst.rev ?? 0) + 1
425
+ if (asst.reasoning && asst.thinkingCollapsed === undefined) asst.thinkingCollapsed = true
426
+ for (const block of msg.content) {
427
+ if (block.type === 'tool-call') {
428
+ app.startTool({ callId: block.id, name: block.name, args: block.arguments })
429
+ }
430
+ }
431
+ break
432
+ }
433
+ case 'tool/call': {
434
+ const d = event.data
435
+ const existing = app.updateTool(d.callId, { name: d.name, args: d.arguments })
436
+ if (!existing) app.startTool({ callId: d.callId, name: d.name, args: d.arguments })
437
+ break
438
+ }
439
+ case 'tool/result': {
440
+ const msg = event.data.message
441
+ const text = contentText(msg.content)
442
+ const isError = event.data.error !== undefined || msg.content[0]?.isError === true
443
+ app.updateTool(msg.source.callId, { status: isError ? 'error' : 'ok', result: text })
444
+ break
445
+ }
446
+ case 'todo/write': {
447
+ app.setTodo(event.data.todos)
448
+ break
449
+ }
450
+ case 'session/title': {
451
+ app.setSession({ title: event.data.title })
452
+ break
453
+ }
454
+ case 'turn/end': {
455
+ app.setStatus('idle')
456
+ break
457
+ }
458
+ default:
459
+ break
460
+ }
461
+ }
462
+
463
+ // ---- agent lifecycle / sidebar -----------------------------------------
464
+
465
+ ctx.on('agent/status', ({ agent, status }) => {
466
+ if (!currentAgent || agent.id !== currentAgent.id) return
467
+ app.setStatus(status)
468
+ paint()
469
+ })
470
+ ctx.on('agent/error', ({ agent, error }) => {
471
+ if (!currentAgent || agent.id !== currentAgent.id) return
472
+ app.addSystem('error: ' + formatError(error), 'error')
473
+ app.setStatus('idle')
474
+ paint()
475
+ })
476
+
477
+ async function refreshRecentSessions() {
478
+ const sq = ctx.get('sessionQuery')
479
+ if (!sq) return
480
+ try {
481
+ const records = await sq.listSessions()
482
+ const top = records.filter((r) => r.header.origin !== 'subagent').slice(0, 25)
483
+ const results = await sq.readTitleSnapshots(top.map((r) => r.header.id))
484
+ const rows = top.map((r, i) => {
485
+ const res = results[i]
486
+ const title = res?.status === 'fulfilled' && res.value.title
487
+ ? res.value.title.title
488
+ : String(r.header.id)
489
+ return { id: String(r.header.id), label: title, time: r.header.createdAt }
490
+ })
491
+ rows.sort((a, b) => b.time - a.time)
492
+ app.sidebarSessions = rows
493
+ paint()
494
+ } catch {
495
+ // Session listing is best-effort; leave the sidebar as-is.
496
+ }
497
+ }
498
+
499
+ // ---- model override via agent/request ----------------------------------
500
+
501
+ // Load the current model's real reasoning levels into the slider. Levels
502
+ // come from the adapter (resolveModelInfo), so a boolean-thinking model
503
+ // shows exactly its two ends, a full-range one shows every advertised level,
504
+ // and a partial one shows only what the provider exposes — never a blanket
505
+ // none..max mapping.
506
+ async function loadEffortSlider() {
507
+ const version = ++effortLoadVersion
508
+ const clear = () => {
509
+ if (version !== effortLoadVersion) return
510
+ effortPreference = undefined
511
+ app.setEffortSlider(null)
512
+ }
513
+ const llm = ctx.get('llm')
514
+ const defaultSelection = defaultModelSelection()
515
+ const provider = modelPreference.provider ?? defaultSelection?.provider ?? app.provider
516
+ const model = modelPreference.model ?? defaultSelection?.model ?? app.model
517
+ if (!provider || !model || !llm?.resolveModelInfo) {
518
+ clear()
519
+ return
520
+ }
521
+ try {
522
+ const info = await llm.resolveModelInfo(provider, model)
523
+ if (version !== effortLoadVersion) return
524
+ const reasoning = info.reasoning
525
+ if (!reasoning || !Array.isArray(reasoning.efforts) || reasoning.efforts.length === 0) {
526
+ clear()
527
+ return
528
+ }
529
+ const levels = reasoning.efforts.map((effort) => ({ id: String(effort.id), name: effort.name ?? String(effort.id) }))
530
+ const configuredEffort = defaultSelection?.reasoningEffort === undefined ? undefined : String(defaultSelection.reasoningEffort)
531
+ // A stored value can belong to the model selected before this one. Only
532
+ // apply it when the current adapter explicitly advertises that id.
533
+ const settingsEffort = levels.some((level) => level.id === configuredEffort) ? configuredEffort : undefined
534
+ effortPreference = settingsEffort
535
+ app.setEffortSlider({
536
+ levels,
537
+ current: settingsEffort ?? (reasoning.defaultEffort === undefined ? undefined : String(reasoning.defaultEffort)),
538
+ })
539
+ } catch {
540
+ clear()
541
+ }
542
+ }
543
+
544
+ function toggleEffortSlider() {
545
+ if (app.effortSliderVisible) {
546
+ app.effortSliderVisible = false
547
+ paint()
548
+ return
549
+ }
550
+ void loadEffortSlider().then(() => {
551
+ if (!app.effortSlider) {
552
+ app.showToast('current model exposes no reasoning levels', 'warn')
553
+ paint()
554
+ return
555
+ }
556
+ app.effortSliderVisible = true
557
+ paint()
558
+ })
559
+ }
560
+
561
+ function moveEffort(delta) {
562
+ const slider = app.effortSlider
563
+ if (!slider || slider.levels.length === 0) return
564
+ const index = Math.max(0, slider.levels.findIndex((level) => level.id === slider.current))
565
+ const next = slider.levels[Math.min(Math.max(0, index + delta), slider.levels.length - 1)]
566
+ if (!next || next.id === slider.current) return
567
+ slider.current = next.id
568
+ effortPreference = next.id
569
+ paint()
570
+ void commitEffort(next.id)
571
+ }
572
+
573
+ // Tab on the session page cycles the thinking intensity through the
574
+ // model's levels, wrapping from the strongest back to the weakest.
575
+ // Returns false when there is nothing to cycle.
576
+ function cycleEffort(direction) {
577
+ const slider = app.effortSlider
578
+ if (!slider || slider.levels.length <= 1) return false
579
+ const index = Math.max(0, slider.levels.findIndex((level) => level.id === slider.current))
580
+ const next = slider.levels[((index + direction) % slider.levels.length + slider.levels.length) % slider.levels.length]
581
+ if (!next || next.id === slider.current) return false
582
+ slider.current = next.id
583
+ effortPreference = next.id
584
+ paint()
585
+ void commitEffort(next.id)
586
+ return true
587
+ }
588
+
589
+ async function commitEffort(id) {
590
+ const settings = ctx.get('settings')
591
+ if (!settings) {
592
+ app.showToast('settings unavailable', 'warn')
593
+ paint()
594
+ return
595
+ }
596
+ try {
597
+ await settings.update('agent-default-model', { reasoningEffort: id })
598
+ app.showToast('thinking: ' + id, 'info')
599
+ } catch (error) {
600
+ app.showToast('settings: ' + formatError(error), 'error')
601
+ await loadEffortSlider()
602
+ }
603
+ paint()
604
+ }
605
+
606
+ ctx.on('agent/request', async ({ agent }, next) => {
607
+ const config = await next()
608
+ if (!currentAgent || agent.id !== currentAgent.id) return config
609
+ const overrides = {}
610
+ if (modelPreference.provider) overrides.provider = modelPreference.provider
611
+ if (modelPreference.model) overrides.model = modelPreference.model
612
+ if (effortPreference) overrides.reasoningEffort = effortPreference
613
+ if (Object.keys(overrides).length === 0) return config
614
+ return { ...config, ...overrides }
615
+ })
616
+
617
+ // ---- approval -----------------------------------------------------------
618
+
619
+ ctx.on('approval/request', (req, next) => {
620
+ if (!currentAgent || req.agent.id !== currentAgent.id) return next()
621
+ if (autoApprovalEnabled(req)) return 'allowed-once'
622
+ return askApproval(req)
623
+ })
624
+
625
+ // Keep this live: settings can be changed while the TUI is running.
626
+ function autoApprovalEnabled(req) {
627
+ const settings = ctx.get('settings')
628
+ const descriptor = settings?.describe?.({ redactSecrets: true })?.find((entry) => String(entry.ns) === 'permission')
629
+ const permission = descriptor?.value ?? settings?.get?.('permission')
630
+ const preset = permission?.defaultPreset ?? permission?.preset
631
+ const mode = permission?.approvalMode ?? permission?.autoApproval ?? permission?.approval
632
+ const autoField = permission && Object.entries(permission).some(([key, value]) =>
633
+ /auto|approval/i.test(key) && (value === true || value === 'auto' || value === 'always' || value === 'enabled'))
634
+ return preset === 'danger-full-access'
635
+ || preset === 'auto'
636
+ || preset === 'always'
637
+ || mode === 'auto'
638
+ || mode === 'always'
639
+ || mode === 'enabled'
640
+ || mode === true
641
+ || autoField
642
+ || req?.autoApprove === true
643
+ || req?.approvalMode === 'auto'
644
+ }
645
+
646
+ function askApproval(req) {
647
+ return new Promise((resolve) => {
648
+ let settled = false
649
+ const settle = (outcome) => {
650
+ if (settled) return
651
+ settled = true
652
+ if (req.signal) req.signal.removeEventListener('abort', onAbort)
653
+ if (app.pendingApproval?.id === state.id) app.pendingApproval = null
654
+ resolve(outcome)
655
+ }
656
+ const onAbort = () => settle('cancelled')
657
+ const state = {
658
+ id: Math.random().toString(36).slice(2, 8),
659
+ toolName: req.toolName,
660
+ reason: req.reason,
661
+ settle,
662
+ }
663
+ if (req.signal) {
664
+ if (req.signal.aborted) {
665
+ resolve('cancelled')
666
+ return
667
+ }
668
+ req.signal.addEventListener('abort', onAbort, { once: true })
669
+ }
670
+ app.pendingApproval = state
671
+ paint()
672
+ })
673
+ }
674
+
675
+ // ---- input handling -----------------------------------------------------
676
+
677
+ function insert(ch) {
678
+ const { inputText, inputCursor } = app
679
+ app.inputText = inputText.slice(0, inputCursor) + ch + inputText.slice(inputCursor)
680
+ app.inputCursor += ch.length
681
+ paint()
682
+ }
683
+ function backspace() {
684
+ const { inputText, inputCursor } = app
685
+ if (inputCursor <= 0) return
686
+ // Delete one code point.
687
+ const before = inputText.slice(0, inputCursor)
688
+ const removed = [...before].pop()
689
+ app.inputText = before.slice(0, before.length - removed.length) + inputText.slice(inputCursor)
690
+ app.inputCursor -= removed.length
691
+ paint()
692
+ }
693
+ function del() {
694
+ const { inputText, inputCursor } = app
695
+ if (inputCursor >= inputText.length) return
696
+ const after = inputText.slice(inputCursor)
697
+ const removed = [...after].shift()
698
+ app.inputText = inputText.slice(0, inputCursor) + after.slice(removed.length)
699
+ paint()
700
+ }
701
+
702
+ // A bracketed paste arrived. Raw image bytes become a numbered attachment;
703
+ // text is checked for a data URL / local image path / image URL before it
704
+ // falls back to ordinary text. An empty paste asks the terminal for its
705
+ // clipboard (OSC 52 read) so images that have no text form can still arrive.
706
+ async function handlePaste(data) {
707
+ if (data && data.length > 0) {
708
+ const mediaType = detectImageMediaType(data)
709
+ if (mediaType) {
710
+ attachImage(data, mediaType)
711
+ return
712
+ }
713
+ const text = data.toString('utf8')
714
+ if (text.trim() === '') {
715
+ requestClipboardImage()
716
+ return
717
+ }
718
+ if (await attachFromText(text)) return
719
+ insert(text)
720
+ return
721
+ }
722
+ requestClipboardImage()
723
+ }
724
+
725
+ async function attachFromText(text) {
726
+ const trimmed = text.trim()
727
+ const dataUrl = decodeDataUrl(trimmed)
728
+ if (dataUrl) {
729
+ const detected = detectImageMediaType(dataUrl.data)
730
+ if (detected) {
731
+ attachImage(dataUrl.data, dataUrl.mediaType || detected)
732
+ return true
733
+ }
734
+ }
735
+ const path = localImagePath(trimmed)
736
+ if (path) {
737
+ try {
738
+ const bytes = await readFile(path)
739
+ const detected = detectImageMediaType(bytes)
740
+ if (detected) {
741
+ attachImage(bytes, detected)
742
+ return true
743
+ }
744
+ } catch { /* unreadable path; treat as plain text */ }
745
+ }
746
+ if (/^https?:\/\/\S+$/i.test(trimmed)) {
747
+ return await attachFromUrl(trimmed)
748
+ }
749
+ return false
750
+ }
751
+
752
+ async function attachFromUrl(url) {
753
+ if (typeof fetch !== 'function') return false
754
+ try {
755
+ const res = await fetch(url)
756
+ if (!res.ok) return false
757
+ const bytes = Buffer.from(await res.arrayBuffer())
758
+ const detected = detectImageMediaType(bytes)
759
+ if (!detected) return false
760
+ attachImage(bytes, detected)
761
+ return true
762
+ } catch {
763
+ return false
764
+ }
765
+ }
766
+
767
+ // OSC 52 clipboard read, best-effort for terminals that support it.
768
+ let clipboardRequested = false
769
+ function requestClipboardImage() {
770
+ clipboardRequested = true
771
+ term.requestClipboard()
772
+ }
773
+ function handleClipboard(data) {
774
+ if (!clipboardRequested) return
775
+ clipboardRequested = false
776
+ if (!data || data.length === 0) return
777
+ const mediaType = detectImageMediaType(data)
778
+ if (mediaType) {
779
+ attachImage(data, mediaType)
780
+ return
781
+ }
782
+ const text = data.toString('utf8').trim()
783
+ if (text) insert(text)
784
+ }
785
+
786
+ function attachImage(data, mediaType) {
787
+ const n = app.inputImages.length + 1
788
+ const entry = { status: 'pending', ref: null, mediaType, label: 'Image ' + n, promise: null }
789
+ app.inputImages.push(entry)
790
+ insert('[Image ' + n + ']')
791
+ const attachments = ctx.get('attachments')
792
+ if (!attachments || typeof attachments.saveImage !== 'function') {
793
+ entry.status = 'error'
794
+ app.showToast('attachments service unavailable; image cannot be read', 'warn')
795
+ paint()
796
+ return
797
+ }
798
+ entry.promise = (async () => {
799
+ try {
800
+ const ref = await attachments.saveImage({ data, mediaType, name: 'image-' + n })
801
+ entry.ref = ref
802
+ entry.status = 'ready'
803
+ } catch (error) {
804
+ entry.status = 'error'
805
+ app.showToast('image attach failed: ' + formatError(error), 'error')
806
+ }
807
+ paint()
808
+ return entry.ref
809
+ })()
810
+ }
811
+
812
+ function handleMouse(mouse) {
813
+ if (mouse.action === 'move') {
814
+ // Drag-select: while the left button is held, extend the selection.
815
+ // A motion event without the left button means the release was lost
816
+ // (e.g. the button came up outside the window) — drop the drag state.
817
+ if (app.textSelectionDragging) {
818
+ if (mouse.button === 'left') {
819
+ if (app.updateTextSelection(mouse.x, mouse.y)) paint()
820
+ } else {
821
+ app.textSelectionDragging = false
822
+ }
823
+ return
824
+ }
825
+ // Focus follows the cursor: the row under the pointer owns the
826
+ // selection. The App keeps the hover cache and re-applies the focus
827
+ // when the selection moved away while the pointer rested on a row.
828
+ if (app.hoverFocus(mouse.x, mouse.y)) paint()
829
+ return
830
+ }
831
+ if (mouse.action === 'wheel-up' || mouse.action === 'wheel-down') {
832
+ const direction = mouse.action === 'wheel-up' ? -1 : 1
833
+ if (app.overlay === 'settings') {
834
+ // Scroll the settings content window instead of moving focus
835
+ app.scrollSettingsWindow(direction * 3)
836
+ } else {
837
+ app.scrollTranscript(-direction * 3)
838
+ }
839
+ paint()
840
+ return
841
+ }
842
+ if (mouse.action === 'up') {
843
+ // Left button released: end the drag. A click without movement is not a
844
+ // selection — drop it so no stale highlight lingers.
845
+ if (app.textSelectionDragging) {
846
+ app.textSelectionDragging = false
847
+ const sel = app.textSelection
848
+ if (sel && sel.startX === sel.endX && sel.startY === sel.endY) {
849
+ app.textSelection = null
850
+ }
851
+ paint()
852
+ }
853
+ return
854
+ }
855
+ if (mouse.action === 'down' && mouse.button === 'right') {
856
+ // Right click copies the current selection (OSC 52 / clip.exe fallback).
857
+ const text = app.textSelection ? app.selectionText() : ''
858
+ if (text) {
859
+ app.textSelection.text = text
860
+ term.copyToClipboard(text)
861
+ app.showToast('copied ' + text.split('\n').length + ' line(s) to clipboard')
862
+ paint()
863
+ }
864
+ return
865
+ }
866
+ if (mouse.action !== 'down' || mouse.button !== 'left') return
867
+ if (app.pendingApproval) return
868
+ if (app.overlay === 'settings') {
869
+ const menuRegion = app.hitTest(mouse.x, mouse.y, ['settings-menu'])
870
+ if (menuRegion) {
871
+ switchSettingsTab(menuRegion.menuIndex)
872
+ return
873
+ }
874
+ const region = app.hitTest(mouse.x, mouse.y, ['settings-item'])
875
+ if (!region) return
876
+ app.settingsSelection = region.settingsIndex
877
+ handleSettingsKey({ name: 'return' })
878
+ return
879
+ }
880
+ if (app.overlay === 'help') {
881
+ app.overlay = null
882
+ paint()
883
+ return
884
+ }
885
+ const region = app.hitTest(mouse.x, mouse.y)
886
+ // The context meter toggles its breakdown panel; any other click while it
887
+ // is open closes it (outside-click close, like the web ContextMeter).
888
+ if (region?.kind === 'context-meter') {
889
+ app.contextMeterOpen = !app.contextMeterOpen
890
+ paint()
891
+ return
892
+ }
893
+ if (app.contextMeterOpen) {
894
+ app.contextMeterOpen = false
895
+ paint()
896
+ return
897
+ }
898
+ if (region?.kind === 'thinking') {
899
+ app.toggleThinking(region.thinkingBlock)
900
+ paint()
901
+ return
902
+ }
903
+ if (region?.kind === 'note') {
904
+ app.toggleNote(region.noteBlock)
905
+ paint()
906
+ return
907
+ }
908
+ // Left-drag inside the transcript selects text (right-click copies it).
909
+ if (region?.kind === 'transcript') {
910
+ app.startTextSelection(mouse.x, mouse.y)
911
+ paint()
912
+ return
913
+ }
914
+ // A click anywhere else in the main view drops a stale selection.
915
+ const hadSelection = app.clearTextSelection()
916
+ const placed = region?.kind === 'composer' && app.placeInputCursor(mouse.x, mouse.y)
917
+ if (placed || hadSelection) paint()
918
+ }
919
+
920
+ term.on('key', (key) => {
921
+ if (key.name === 'mouse') {
922
+ handleMouse(key.mouse)
923
+ return
924
+ }
925
+ if (key.name === 'paste') {
926
+ void handlePaste(key.data)
927
+ return
928
+ }
929
+ if (key.name === 'clipboard') {
930
+ handleClipboard(key.data)
931
+ return
932
+ }
933
+ app.focusedRegion = 'keyboard'
934
+ // Approval prompt has its own mini-mode.
935
+ if (false) {
936
+ if (key.name === 'y' || key.name === 'Y' || key.name === 'return' || key.name === 'enter') {
937
+
938
+ } else if (key.name === 'n' || key.name === 'N' || key.name === 'escape') {
939
+
940
+ app.showToast('delete cancelled')
941
+ paint()
942
+ }
943
+ return
944
+ }
945
+ // Approval prompt has its own mini-mode.
946
+ if (app.pendingApproval) {
947
+ if (key.name === 'y' || key.name === 'Y' || key.name === 'return') {
948
+ app.pendingApproval.settle('allowed-once')
949
+ } else if (key.name === 'n' || key.name === 'N') {
950
+ app.pendingApproval.settle('rejected')
951
+ } else if (key.name === 'escape') {
952
+ app.pendingApproval.settle('cancelled')
953
+ }
954
+ paint()
955
+ return
956
+ }
957
+ if (app.overlay === 'settings') {
958
+ handleSettingsKey(key)
959
+ return
960
+ }
961
+ if (app.overlay === 'help') {
962
+ if (key.name === 'escape' || key.name === 'return' || key.name === 'h') {
963
+ app.overlay = null
964
+ paint()
965
+ }
966
+ return
967
+ }
968
+ if (key.name === 'return' || key.name === 'enter') {
969
+ if (key.shift || key.ctrl) insert('\n')
970
+ else void submit()
971
+ return
972
+ }
973
+ if (key.ctrl) {
974
+ handleCtrlKey(key.name)
975
+ return
976
+ }
977
+ switch (key.name) {
978
+ case 'backspace': backspace(); break
979
+ case 'left':
980
+ if (app.effortSliderVisible && app.effortSlider) moveEffort(-1)
981
+ else if (app.inputCursor > 0) app.inputCursor--
982
+ paint()
983
+ break
984
+ case 'right':
985
+ if (app.effortSliderVisible && app.effortSlider) moveEffort(1)
986
+ else if (app.inputCursor < app.inputText.length) app.inputCursor++
987
+ paint()
988
+ break
989
+ case 'home': app.inputCursor = 0; paint(); break
990
+ case 'end': app.inputCursor = app.inputText.length; paint(); break
991
+ case 'up': {
992
+ // Multi-line input: Up moves the caret one visual row up (keeping the
993
+ // column), and only the first row falls back to input history. The
994
+ // width comes from the renderer's own layout so caret math matches
995
+ // what is on screen.
996
+ const layout = app._layout()
997
+ if (layout.visual.cursorRow > 0) {
998
+ app.inputCursor = cursorAtVisual(app.inputText, layout.composerWidth, layout.visual.cursorRow - 1, layout.visual.cursorCol)
999
+ paint()
1000
+ } else {
1001
+ historyBack()
1002
+ }
1003
+ break
1004
+ }
1005
+ case 'down': {
1006
+ // Down moves the caret one visual row down; only the last row falls
1007
+ // back to input history.
1008
+ const layout = app._layout()
1009
+ if (layout.visual.cursorRow < layout.visual.rows.length - 1) {
1010
+ app.inputCursor = cursorAtVisual(app.inputText, layout.composerWidth, layout.visual.cursorRow + 1, layout.visual.cursorCol)
1011
+ paint()
1012
+ } else {
1013
+ historyForward()
1014
+ }
1015
+ break
1016
+ }
1017
+ case 'pageup': app.scroll = Math.min(app.scroll + 10, 100000); paint(); break
1018
+ case 'pagedown': app.scroll = Math.max(0, app.scroll - 10); paint(); break
1019
+ case 'tab': {
1020
+ // Tab changes the thinking intensity; a model with no selectable
1021
+ // levels keeps the old two-space insertion.
1022
+ if (!cycleEffort(1)) insert(' ')
1023
+ break
1024
+ }
1025
+ case 'shift-tab': cycleEffort(-1); break
1026
+ case 'escape':
1027
+ if (app.contextMeterOpen) {
1028
+ app.contextMeterOpen = false
1029
+ paint()
1030
+ } else if (app.effortSliderVisible) {
1031
+ app.effortSliderVisible = false
1032
+ paint()
1033
+ }
1034
+ break
1035
+ case 'unknown': break
1036
+ default:
1037
+ if (key.text !== undefined && !key.alt) insert(key.text)
1038
+ break
1039
+ }
1040
+ })
1041
+
1042
+ let sharedSettings = null
1043
+ // Settings views: 'main' and 'model' are the two left-menu tabs of the
1044
+ // settings dialog; 'choice', 'sessions', and 'provider-models' are
1045
+ // menu-less sub-views rendered in the same overlay.
1046
+ let settingsView = { kind: 'main' }
1047
+ // The last selection per menu tab, restored when Tab switches back.
1048
+ const menuSelections = { main: 0, model: 0 }
1049
+ let settingsLoadVersion = 0
1050
+
1051
+ function showSettings(loaded, selection = 0) {
1052
+ sharedSettings = loaded.settings
1053
+ app.openSettings(loaded.items, {
1054
+ title: loaded.title,
1055
+ subtitle: loaded.subtitle,
1056
+ menu: loaded.menu ?? [],
1057
+ menuIndex: loaded.menuIndex ?? 0,
1058
+ })
1059
+ app.setSettingsSelection(selection)
1060
+ }
1061
+
1062
+ // Open one left-menu tab of the settings dialog. A version guard keeps a
1063
+ // slow loader from overwriting a tab the user already switched away from.
1064
+ async function openSettingsTab(tab, selection) {
1065
+ const version = ++settingsLoadVersion
1066
+ settingsView = { kind: tab }
1067
+ let loaded
1068
+ try {
1069
+ loaded = tab === 'model' ? await loadModelSettings(ctx) : await loadWebSettings(ctx)
1070
+ } catch (error) {
1071
+ if (version !== settingsLoadVersion) return
1072
+ sharedSettings = null
1073
+ app.openSettings([{ label: 'DSH settings', value: formatError(error), disabled: true }])
1074
+ paint()
1075
+ return
1076
+ }
1077
+ if (version !== settingsLoadVersion || settingsView.kind !== tab) return
1078
+ showSettings(loaded, selection ?? menuSelections[tab] ?? 0)
1079
+ paint()
1080
+ }
1081
+
1082
+ // Switch the settings dialog's left menu (Tab / click).
1083
+ function switchSettingsTab(index) {
1084
+ const menu = app.settingsMenu
1085
+ if (!Array.isArray(menu) || menu.length === 0) return
1086
+ const target = menu[((index % menu.length) + menu.length) % menu.length]
1087
+ if (!target || settingsView.kind === target.id) return
1088
+ if (settingsView.kind === 'main' || settingsView.kind === 'model') {
1089
+ menuSelections[settingsView.kind] = app.settingsSelection
1090
+ }
1091
+ void openSettingsTab(target.id)
1092
+ }
1093
+
1094
+ async function openSettings(selection = 0) {
1095
+ app.contextMeterOpen = false
1096
+ await openSettingsTab('main', selection)
1097
+ }
1098
+
1099
+ function renderSessionManager(selection = 0) {
1100
+ const items = app.sidebarSessions.map((session) => ({
1101
+ kind: 'session',
1102
+ label: session.label,
1103
+ value: timeString(session.time),
1104
+ sessionId: session.id,
1105
+ disabled: false,
1106
+ }))
1107
+ app.openSettings(items.length > 0 ? items : [{ kind: 'session-empty', label: 'No saved sessions', value: '', disabled: true }], {
1108
+ title: 'Manage sessions',
1109
+ subtitle: 'Enter open · Ctrl+D twice to delete · Esc back',
1110
+ })
1111
+ app.setSettingsSelection(selection)
1112
+ paint()
1113
+ }
1114
+
1115
+ async function openSessionManager(selection = 0) {
1116
+ settingsView = { kind: 'sessions' }
1117
+ // Show loading state immediately while fetching sessions
1118
+ app.openSettings([{ kind: 'session-loading', label: 'Loading sessions...', value: '', disabled: true }], {
1119
+ title: 'Manage sessions',
1120
+ subtitle: 'Enter open · Ctrl+D twice to delete · Esc back',
1121
+ })
1122
+ paint()
1123
+ // A fresh listing can take seconds (reading every session's title snapshot)
1124
+ await refreshRecentSessions()
1125
+ if (settingsView.kind === 'sessions') renderSessionManager(app.settingsSelection)
1126
+ }
1127
+
1128
+ async function deleteManagedSession(item) {
1129
+ const services = [
1130
+ ctx.get('sessionPersistence'), ctx.get('sessionQuery'),
1131
+ ctx.get('sessionStore'), ctx.get('sessionStorage'),
1132
+ ].filter(Boolean)
1133
+ const candidates = []
1134
+ for (const service of services) {
1135
+ const names = new Set([
1136
+ ...Object.keys(service),
1137
+ ...Object.getOwnPropertyNames(Object.getPrototypeOf(service) ?? {}),
1138
+ ])
1139
+ for (const name of names) {
1140
+ if (!/^(delete|remove|unlink|purge|drop|destroy)(Session|ById)?$/i.test(name)) continue
1141
+ if (typeof service[name] === 'function') candidates.push([service, service[name]])
1142
+ }
1143
+ }
1144
+ const [owner, remover] = candidates[0] ?? []
1145
+ const deletingCurrent = item.sessionId === app.sessionId
1146
+ try {
1147
+ if (!remover && deletingCurrent && handle) {
1148
+ const currentHandle = handle
1149
+ handle = null
1150
+ currentAgent = null
1151
+ await currentHandle.dispose()
1152
+ }
1153
+ if (remover) {
1154
+ await remover.call(owner, SessionId(item.sessionId))
1155
+ } else if (!await deleteSessionDirectory(item.sessionId)) {
1156
+ throw new Error('persisted session directory was not found')
1157
+ }
1158
+ app.settingsConfirm = null
1159
+ app.showToast('deleted session ' + item.label)
1160
+ if (item.sessionId === app.sessionId) await showTitleScreen()
1161
+ else await openSessionManager(Math.max(0, app.settingsSelection - 1))
1162
+ } catch (error) {
1163
+ if (deletingCurrent && !handle) await showTitleScreen()
1164
+ app.settingsConfirm = null
1165
+ app.showToast('delete session: ' + formatError(error), 'error')
1166
+ paint()
1167
+ }
1168
+ }
1169
+
1170
+ // The auto-fetched model selection window for one provider. Renders a
1171
+ // placeholder at once (the fetch can take a while) and swaps in the
1172
+ // checkbox list when it lands.
1173
+ async function openProviderModels(providerId, selection = 0) {
1174
+ const version = ++settingsLoadVersion
1175
+ const parentSelection = settingsView.kind === 'provider-models' && settingsView.provider === providerId
1176
+ ? settingsView.parentSelection
1177
+ : app.settingsSelection
1178
+ settingsView = { kind: 'provider-models', provider: providerId, parentSelection }
1179
+ app.openSettings([{ label: 'Fetching models…', value: '', disabled: true }], { title: 'Models', subtitle: 'auto-fetching…' })
1180
+ paint()
1181
+ let loaded
1182
+ try {
1183
+ loaded = await loadProviderModels(ctx, providerId)
1184
+ } catch (error) {
1185
+ if (version !== settingsLoadVersion) return
1186
+ app.showToast('settings: ' + formatError(error), 'error')
1187
+ await openSettingsTab('model', parentSelection)
1188
+ return
1189
+ }
1190
+ if (version !== settingsLoadVersion || settingsView.kind !== 'provider-models' || settingsView.provider !== providerId) return
1191
+ showSettings(loaded, selection)
1192
+ paint()
1193
+ }
1194
+
1195
+ async function refreshSettings(selection = 0) {
1196
+ if (settingsView.kind === 'provider-models') await openProviderModels(settingsView.provider, selection)
1197
+ else if (settingsView.kind === 'sessions') await openSessionManager(selection)
1198
+ else if (settingsView.kind === 'model') await openSettingsTab('model', selection)
1199
+ else await openSettingsTab('main', selection)
1200
+ }
1201
+
1202
+ function openSettingChoices(item, parentSelection) {
1203
+ if (!Array.isArray(item.options) || item.options.length === 0) {
1204
+ app.showToast('no available options for ' + item.label, 'warn')
1205
+ paint()
1206
+ return
1207
+ }
1208
+ settingsView = { kind: 'choice', parentSelection, parent: settingsView.kind === 'model' ? 'model' : 'main' }
1209
+ const settingItem = { ...item, returnSelection: parentSelection }
1210
+ // Options are either plain strings (label and value are the same id) or
1211
+ // { label, value } objects when the label carries capability markers that
1212
+ // must not leak into the committed value.
1213
+ const options = item.options.map((option) =>
1214
+ typeof option === 'object' && option !== null && 'value' in option
1215
+ ? { label: option.label, value: option.value }
1216
+ : { label: option, value: option })
1217
+ const items = options.map(({ label, value }) => ({
1218
+ kind: 'setting-option',
1219
+ label,
1220
+ value: value === item.value ? 'selected' : '',
1221
+ optionValue: value,
1222
+ settingItem,
1223
+ // Sensitive options (e.g. unrestricted access) confirm in place before
1224
+ // the choice is committed.
1225
+ confirm: item.confirmValue === value ? (item.confirmText ?? 'Confirm this change?') : undefined,
1226
+ disabled: false,
1227
+ }))
1228
+ app.openSettings(items, { title: item.label, subtitle: 'Enter select · Esc back' })
1229
+ app.setSettingsSelection(Math.max(0, options.findIndex(({ value }) => value === item.value)))
1230
+ paint()
1231
+ }
1232
+
1233
+ function requestSettingCommit(item, value) {
1234
+ if (item.confirmValue === value || item.kind === 'remove-provider') {
1235
+ app.settingsConfirm = { item, value, text: item.confirmText ?? 'Confirm this change?' }
1236
+ paint()
1237
+ return
1238
+ }
1239
+ void commitSetting(value, item)
1240
+ }
1241
+
1242
+ function handleSettingsKey(key) {
1243
+ if (settingsView.kind === 'sessions' && key.ctrl && key.name === 'd') {
1244
+ const item = app.settingsItems[app.settingsSelection]
1245
+ if (item?.kind === 'session') {
1246
+ if (app.settingsConfirm?.item === item) {
1247
+ // Second Ctrl+D confirms deletion of the selected session.
1248
+ app.settingsConfirm = null
1249
+ void deleteManagedSession(item)
1250
+ } else {
1251
+ app.settingsConfirm = { item, value: '', text: 'Press Ctrl+D again to delete ' + item.label }
1252
+ }
1253
+ } else {
1254
+ app.showToast('no session selected', 'warn')
1255
+ }
1256
+ paint()
1257
+ return
1258
+ }
1259
+ const count = app.settingsItems.length
1260
+ if (count === 0) {
1261
+ app.showToast('no sessions available', 'warn')
1262
+ paint()
1263
+ return
1264
+ }
1265
+ if (app.settingsConfirm) {
1266
+ const pending = app.settingsConfirm
1267
+ if (pending.item.kind === 'session') {
1268
+ // Session deletion is confirmed by a second Ctrl+D; only Esc cancels.
1269
+ if (key.name === 'escape') {
1270
+ app.settingsConfirm = null
1271
+ paint()
1272
+ }
1273
+ } else if (key.name === 'y' || key.text?.toLowerCase() === 'y' || key.name === 'return' || key.name === 'enter') {
1274
+ app.settingsConfirm = null
1275
+ void commitSetting(pending.value, pending.commitItem ?? pending.item)
1276
+ } else if (key.name === 'n' || key.text?.toLowerCase() === 'n' || key.name === 'escape') {
1277
+ app.settingsConfirm = null
1278
+ paint()
1279
+ }
1280
+ return
1281
+ }
1282
+ if (app.settingsEditing !== null) {
1283
+ if (key.name === 'escape') {
1284
+ app.settingsEditing = null
1285
+ app.settingsDraft = ''
1286
+ app.settingsSecret = false
1287
+ } else if (key.name === 'return' || key.name === 'enter') {
1288
+ const item = app.settingsItems[app.settingsEditing]
1289
+ requestSettingCommit(item, app.settingsDraft)
1290
+ return
1291
+ } else if (key.name === 'backspace') {
1292
+ app.settingsDraft = Array.from(app.settingsDraft).slice(0, -1).join('')
1293
+ } else if (key.text !== undefined && !key.ctrl && !key.alt) {
1294
+ app.settingsDraft += key.text
1295
+ }
1296
+ paint()
1297
+ return
1298
+ }
1299
+
1300
+ if (key.name === 'escape') {
1301
+ if (settingsView.kind === 'choice') void openSettingsTab(settingsView.parent === 'model' ? 'model' : 'main', settingsView.parentSelection)
1302
+ else if (settingsView.kind === 'provider-models') void openSettingsTab('model', settingsView.parentSelection ?? 0)
1303
+ else if (settingsView.kind === 'sessions') void openSettingsTab('main')
1304
+ else app.overlay = null
1305
+ } else if (key.name === 'up') {
1306
+ app.moveSettingsSelection(-1)
1307
+ } else if (key.name === 'down') {
1308
+ app.moveSettingsSelection(1)
1309
+ } else if (key.name === 'tab' || key.name === 'shift-tab') {
1310
+ // On the two menu tabs, Tab switches the left menu; everywhere else it
1311
+ // keeps moving the item list like Down.
1312
+ if ((settingsView.kind === 'main' || settingsView.kind === 'model') && app.settingsMenu.length > 0) {
1313
+ switchSettingsTab(app.settingsMenuIndex + (key.name === 'shift-tab' ? -1 : 1))
1314
+ return
1315
+ }
1316
+ app.moveSettingsSelection(key.name === 'shift-tab' ? -1 : 1)
1317
+ } else if (key.name === 'return' || key.name === 'enter') {
1318
+ const item = app.settingsItems[app.settingsSelection]
1319
+ if (item.kind === 'new-session') {
1320
+ void showTitleScreen()
1321
+ return
1322
+ }
1323
+ if (item.kind === 'manage-sessions') {
1324
+ void openSessionManager()
1325
+ return
1326
+ }
1327
+ if (item.kind === 'session') {
1328
+ void openSession({ resume: item.sessionId })
1329
+ return
1330
+ }
1331
+ if (item.kind === 'setting-option') {
1332
+ if (item.confirm) {
1333
+ // Sensitive option: confirm in place before committing the choice.
1334
+ app.settingsConfirm = { item, value: item.optionValue, text: item.confirm, commitItem: item.settingItem }
1335
+ paint()
1336
+ return
1337
+ }
1338
+ settingsView = { kind: settingsView.parent === 'model' ? 'model' : 'main' }
1339
+ void commitSetting(item.optionValue, item.settingItem)
1340
+ return
1341
+ }
1342
+ if (item.kind === 'provider-models') {
1343
+ // Auto-fetch the provider's model catalog and open the selection
1344
+ // window.
1345
+ void openProviderModels(item.providerId)
1346
+ return
1347
+ }
1348
+ if (item.kind === 'provider-model') {
1349
+ // A listed model becomes the default route for its provider.
1350
+ void commitSetting(item.modelId, item)
1351
+ return
1352
+ }
1353
+ if (item.kind === 'model-toggle') {
1354
+ void commitSetting(item.checked ? 'off' : 'on', item)
1355
+ return
1356
+ }
1357
+ // Every choice item opens its option list - there is no inline
1358
+ // left/right value cycling.
1359
+ if (!item.disabled && item.options?.length > 0) {
1360
+ openSettingChoices(item, app.settingsSelection)
1361
+ return
1362
+ }
1363
+ if (!item.disabled && (item.kind === 'enable-provider' || item.kind === 'remove-provider')) {
1364
+ requestSettingCommit(item, '')
1365
+ return
1366
+ }
1367
+ if (!item.disabled) {
1368
+ app.settingsEditing = app.settingsSelection
1369
+ app.settingsSecret = item.kind === 'secret'
1370
+ app.settingsDraft = item.kind === 'secret' || item.value === 'system' || item.value === 'default' ? '' : item.value
1371
+ }
1372
+ }
1373
+ paint()
1374
+ }
1375
+
1376
+ async function commitSetting(value, item = app.settingsItems[app.settingsSelection]) {
1377
+ const selection = item?.returnSelection ?? app.settingsSelection
1378
+ app.settingsEditing = null
1379
+ app.settingsDraft = ''
1380
+ app.settingsSecret = false
1381
+ if (!sharedSettings || !item || (value.trim() === '' && !['path', 'enable-provider', 'remove-provider'].includes(item.kind))) {
1382
+ paint()
1383
+ return
1384
+ }
1385
+ try {
1386
+ await saveWebSetting(ctx, sharedSettings, item, value)
1387
+ if (item.kind === 'provider-model') {
1388
+ modelPreference.provider = item.providerId
1389
+ modelPreference.model = item.modelId
1390
+ app.setSession({ provider: item.providerId, model: item.modelId })
1391
+ }
1392
+ if (item.ns === 'agent-default-model' && item.field === 'model') {
1393
+ modelPreference.model = value
1394
+ app.setSession({ model: value })
1395
+ }
1396
+ if (item.ns === 'agent-default-model' && item.field === 'provider') {
1397
+ const selection = defaultModelSelection()
1398
+ modelPreference.provider = selection?.provider ?? value
1399
+ modelPreference.model = selection?.model
1400
+ app.setSession({ provider: modelPreference.provider, model: modelPreference.model ?? '' })
1401
+ }
1402
+ if (item.ns === 'agent-default-model' || item.kind === 'provider-model') void loadEffortSlider()
1403
+ app.showToast(item.kind === 'remove-provider' ? 'provider removed' : 'saved to DSH settings')
1404
+ if (item.kind === 'remove-provider') await openSettingsTab('model')
1405
+ else await refreshSettings(selection)
1406
+ } catch (error) {
1407
+ const conflict = error?.code === 'SETTINGS_CONFLICT' ? 'settings changed elsewhere; reloaded' : formatError(error)
1408
+ app.showToast('settings: ' + conflict, 'error')
1409
+ await refreshSettings(selection)
1410
+ }
1411
+ }
1412
+
1413
+ function handleCtrlKey(name) {
1414
+ switch (name) {
1415
+ case 'c': {
1416
+ const action = interrupt.interrupt({
1417
+ running: app.status === 'running',
1418
+ hasInput: app.inputText.length > 0,
1419
+ })
1420
+ if (action === 'clear') {
1421
+ app.inputText = ''
1422
+ app.inputCursor = 0
1423
+ app.inputImages = []
1424
+ app.showToast('prompt cleared')
1425
+ } else if (action === 'cancel') {
1426
+ if (currentAgent) currentAgent.cancel({ kind: 'user' })
1427
+ app.showToast('interrupt requested')
1428
+ } else if (action === 'arm-exit') {
1429
+ app.showToast('press Ctrl+C again to exit')
1430
+ } else if (action === 'exit') {
1431
+ quit(true)
1432
+ return
1433
+ }
1434
+ paint()
1435
+ break
1436
+ }
1437
+ case 'p': openSettings(); break
1438
+ case 'd': quit(); break
1439
+ case 'n': {
1440
+ void showTitleScreen()
1441
+ break
1442
+ }
1443
+ case 's': break
1444
+ case 'l': app.resetView(); paint(); break
1445
+ case 'u': app.inputText = ''; app.inputCursor = 0; app.inputImages = []; paint(); break
1446
+ case 'a': app.inputCursor = 0; paint(); break
1447
+ case 'e': toggleEffortSlider(); break
1448
+ case 'j': insert('\n'); break
1449
+ default: break
1450
+ }
1451
+ }
1452
+
1453
+ function historyBack() {
1454
+ if (app.history.length === 0) return
1455
+ if (app.historyIndex < 0) {
1456
+ app.historyIndex = app.history.length - 1
1457
+ app.inputText = app.history[app.historyIndex]
1458
+ } else if (app.historyIndex > 0) {
1459
+ app.historyIndex--
1460
+ app.inputText = app.history[app.historyIndex]
1461
+ }
1462
+ app.inputImages = []
1463
+ app.inputCursor = app.inputText.length
1464
+ paint()
1465
+ }
1466
+ function historyForward() {
1467
+ if (app.historyIndex < 0) return
1468
+ app.historyIndex++
1469
+ if (app.historyIndex >= app.history.length) {
1470
+ app.historyIndex = -1
1471
+ app.inputText = ''
1472
+ } else {
1473
+ app.inputText = app.history[app.historyIndex]
1474
+ }
1475
+ app.inputImages = []
1476
+ app.inputCursor = app.inputText.length
1477
+ paint()
1478
+ }
1479
+
1480
+ // ---- submit / commands --------------------------------------------------
1481
+
1482
+ // The provider/model pair the next request actually routes to: an explicit
1483
+ // /model or /provider override wins, then the persisted agent-default-model
1484
+ // selection, then the session header shown in the status bar. This mirrors
1485
+ // the agent/request waterfall that applies the same preference below.
1486
+ function effectiveRoute() {
1487
+ const selection = defaultModelSelection()
1488
+ return {
1489
+ provider: modelPreference.provider ?? selection.provider ?? app.provider,
1490
+ model: modelPreference.model ?? selection.model ?? app.model,
1491
+ }
1492
+ }
1493
+
1494
+ // The current route's model name when it explicitly refuses image input, or
1495
+ // null when the route is unknown, image-capable, or its capability cannot be
1496
+ // resolved — the adapter stays the authority for those cases.
1497
+ async function currentModelRejectsImages() {
1498
+ const llm = ctx.get('llm')
1499
+ const { provider, model } = effectiveRoute()
1500
+ if (!llm?.resolveModelInfo || !provider || !model) return null
1501
+ try {
1502
+ const info = await llm.resolveModelInfo(provider, model)
1503
+ return info.inputModalities !== undefined && !info.inputModalities.includes('image') ? model : null
1504
+ } catch {
1505
+ return null
1506
+ }
1507
+ }
1508
+
1509
+ // Reconstruct ordered user content from the submitted text. `[Image N]`
1510
+ // markers resolve to saved attachments; markers whose attachment failed to
1511
+ // save are preserved as literal text so no user input silently disappears.
1512
+ async function buildUserContent(text, images) {
1513
+ const resolved = await Promise.all(images.map((img) => {
1514
+ if (img.status === 'ready') return Promise.resolve(img.ref)
1515
+ if (img.status === 'pending' && img.promise) return img.promise.catch(() => null)
1516
+ return Promise.resolve(null)
1517
+ }))
1518
+ const blocks = []
1519
+ const parts = text.split(/\[Image (\d+)\]/g)
1520
+ for (let i = 0; i < parts.length; i++) {
1521
+ if (i % 2 === 0) {
1522
+ if (parts[i].length > 0) blocks.push({ type: 'text', text: parts[i] })
1523
+ } else {
1524
+ const ref = resolved[Number(parts[i]) - 1]
1525
+ if (ref) blocks.push({ type: 'image', attachment: { attachmentId: ref.attachmentId } })
1526
+ else if (parts[i]) blocks.push({ type: 'text', text: '[Image ' + parts[i] + ']' })
1527
+ }
1528
+ }
1529
+ if (blocks.length === 0 && text.length > 0) blocks.push({ type: 'text', text })
1530
+ return blocks
1531
+ }
1532
+
1533
+ async function sendMessage(text, images = []) {
1534
+ if (!currentAgent) return
1535
+ const wasRunning = app.status === 'running'
1536
+ lastUserText = text
1537
+ lastUserTime = Date.now()
1538
+ app.addUser(text)
1539
+ app.setStatus('running')
1540
+ const content = await buildUserContent(text, images)
1541
+ const message = createUserMessage({
1542
+ content: content.length > 0 ? content : [{ type: 'text', text }],
1543
+ source: { kind: 'user' },
1544
+ })
1545
+ const busyEnter = ctx.get('settings')?.get('ui-conversation')?.busyEnter ?? 'queue'
1546
+ if (wasRunning && busyEnter === 'steer') currentAgent.steer(message)
1547
+ else currentAgent.followup(message)
1548
+ paint()
1549
+ }
1550
+
1551
+ async function createSessionAndSend(text, images = []) {
1552
+ app.setStatus('running')
1553
+ paint()
1554
+ let pending = openingSession
1555
+ let pendingVersion = openingVersion
1556
+ if (!pending) {
1557
+ pending = openSession()
1558
+ pendingVersion = lifecycleVersion
1559
+ openingSession = pending
1560
+ openingVersion = pendingVersion
1561
+ void pending.finally(() => {
1562
+ if (openingSession === pending) {
1563
+ openingSession = null
1564
+ openingVersion = 0
1565
+ }
1566
+ })
1567
+ }
1568
+ const intendedVersion = lifecycleVersion
1569
+ if (await pending) {
1570
+ if (intendedVersion === lifecycleVersion) await sendMessage(text, images)
1571
+ return
1572
+ }
1573
+ if (intendedVersion === lifecycleVersion && intendedVersion !== pendingVersion && !currentAgent) {
1574
+ if (openingSession === pending) {
1575
+ openingSession = null
1576
+ openingVersion = 0
1577
+ }
1578
+ await createSessionAndSend(text, images)
1579
+ }
1580
+ }
1581
+
1582
+ async function submit() {
1583
+ const text = app.inputText
1584
+ const images = app.inputImages
1585
+ if (text.trim() === '' && images.length === 0) return
1586
+ // A message the current route cannot serve is discarded outright before it
1587
+ // is committed: it is dropped from the composer and never reaches the
1588
+ // durable session, so a failed image send cannot poison later resends or
1589
+ // model switches with the same replayable adapter error.
1590
+ if (!text.startsWith('/') && images.length > 0) {
1591
+ const content = await buildUserContent(text, images)
1592
+ if (contentHasImage(content)) {
1593
+ const refusing = await currentModelRejectsImages()
1594
+ if (refusing !== null) {
1595
+ app.inputText = ''
1596
+ app.inputCursor = 0
1597
+ app.inputImages = []
1598
+ app.showToast('model "' + refusing + '" does not support image input', 'error')
1599
+ paint()
1600
+ return
1601
+ }
1602
+ }
1603
+ }
1604
+ app.history.push(text)
1605
+ if (app.history.length > 200) app.history.shift()
1606
+ app.historyIndex = -1
1607
+ app.inputText = ''
1608
+ app.inputCursor = 0
1609
+ app.inputImages = []
1610
+ if (text.startsWith('/')) {
1611
+ if (!currentAgent) {
1612
+ const parsed = parseCommand(text)
1613
+ if (parsed?.name === 'settings') openSettings()
1614
+ else if (parsed?.name === 'help') { app.overlay = 'help'; app.contextMeterOpen = false; paint() }
1615
+ else if (parsed?.name === 'new') void showTitleScreen()
1616
+ else { app.showToast('send a message to start a session', 'warn'); paint() }
1617
+ } else {
1618
+ void runCommand(text)
1619
+ }
1620
+ return
1621
+ }
1622
+ if (!currentAgent) {
1623
+ await createSessionAndSend(text, images)
1624
+ return
1625
+ }
1626
+ await sendMessage(text, images)
1627
+ }
1628
+
1629
+ async function runCommand(line) {
1630
+ if (!currentAgent) return
1631
+ const parsed = parseCommand(line)
1632
+ if (!parsed) return
1633
+ // Let the harness own its human commands (/compact, /goal, /model…).
1634
+ if (ctx.commands.find(currentAgent, parsed.name)) {
1635
+ try {
1636
+ const res = await ctx.commands.execute(currentAgent, line, new AbortController().signal)
1637
+ if (res?.result?.kind === 'error') {
1638
+ app.addSystem('/' + parsed.name + ': ' + res.result.text, 'error')
1639
+ } else if (res?.result?.text) {
1640
+ app.addSystem('/' + parsed.name + ': ' + res.result.text, 'info')
1641
+ }
1642
+ } catch (error) {
1643
+ app.addSystem('/' + parsed.name + ': ' + formatError(error), 'error')
1644
+ }
1645
+ paint()
1646
+ return
1647
+ }
1648
+ switch (parsed.name) {
1649
+ case 'settings':
1650
+ openSettings()
1651
+ break
1652
+ case 'help':
1653
+ app.overlay = 'help'
1654
+ app.contextMeterOpen = false
1655
+ paint()
1656
+ break
1657
+ case 'new': void showTitleScreen(); break
1658
+ case 'resume': {
1659
+ const id = parsed.rawInput.trim()
1660
+ if (!id) {
1661
+ app.addSystem('usage: /resume <sessionId>', 'warn')
1662
+ break
1663
+ }
1664
+ void openSession({ resume: id })
1665
+ break
1666
+ }
1667
+ case 'model': {
1668
+ const id = parsed.rawInput.trim()
1669
+ if (!id) {
1670
+ app.addSystem('current model: ' + (modelPreference.model || app.model), 'info')
1671
+ break
1672
+ }
1673
+ modelPreference.model = id
1674
+ app.setSession({ model: id })
1675
+ app.addSystem('model set to ' + id + ' (applies to the next request)', 'info')
1676
+ void loadEffortSlider()
1677
+ paint()
1678
+ break
1679
+ }
1680
+ case 'provider': {
1681
+ const id = parsed.rawInput.trim()
1682
+ if (!id) {
1683
+ app.addSystem('current provider: ' + (modelPreference.provider || app.provider), 'info')
1684
+ break
1685
+ }
1686
+ modelPreference.provider = id
1687
+ app.setSession({ provider: id })
1688
+ app.addSystem('provider set to ' + id + ' (applies to the next request)', 'info')
1689
+ void loadEffortSlider()
1690
+ paint()
1691
+ break
1692
+ }
1693
+ case 'clear': app.resetView(); paint(); break
1694
+ case 'cancel': {
1695
+ if (currentAgent && app.status === 'running') currentAgent.cancel({ kind: 'user' })
1696
+ app.showToast('interrupt requested')
1697
+ paint()
1698
+ break
1699
+ }
1700
+ case 'quit':
1701
+ case 'exit': quit(); break
1702
+ default:
1703
+ app.addSystem('unknown command /' + parsed.name + ' — try /help', 'warn')
1704
+ paint()
1705
+ }
1706
+ }
1707
+
1708
+ async function listSessionsCommand() {
1709
+ const sq = ctx.get('sessionQuery')
1710
+ if (!sq) {
1711
+ app.addSystem('session query is not mounted in this profile', 'warn')
1712
+ paint()
1713
+ return
1714
+ }
1715
+ try {
1716
+ const records = await sq.listSessions()
1717
+ const top = records.filter((r) => r.header.origin !== 'subagent').slice(0, 40)
1718
+ const results = await sq.readTitleSnapshots(top.map((r) => r.header.id))
1719
+ const lines = top.map((r, i) => {
1720
+ const res = results[i]
1721
+ const title = res?.status === 'fulfilled' && res.value.title
1722
+ ? res.value.title.title
1723
+ : String(r.header.id)
1724
+ return timeString(r.header.createdAt) + ' ' + String(r.header.id).slice(0, 20).padEnd(20, ' ')
1725
+ + ' ' + title
1726
+ })
1727
+ app.addSystem(lines.length > 0 ? 'sessions:\n' + lines.join('\n') : 'no persisted sessions', 'info')
1728
+ } catch (error) {
1729
+ app.addSystem('failed to list sessions: ' + formatError(error), 'error')
1730
+ }
1731
+ paint()
1732
+ }
1733
+
1734
+ function quit(alreadyRequested = false) {
1735
+ if (!alreadyRequested && !interrupt.requestExit()) return
1736
+ term.stop()
1737
+ const exit = ctx.get('appExit')
1738
+ const cleanup = handle ? handle.dispose().catch(() => undefined) : Promise.resolve()
1739
+ cleanup.finally(() => {
1740
+ if (exit) exit(0)
1741
+ else process.exit(0)
1742
+ })
1743
+ }
1744
+
1745
+ // ---- startup -----------------------------------------------------------
1746
+
1747
+ if (startup.resume) {
1748
+ void openSession({ resume: startup.resume }).then(() => refreshRecentSessions())
1749
+ } else {
1750
+ void showTitleScreen()
1751
+ void refreshRecentSessions()
1752
+ }
1753
+ void loadEffortSlider()
1754
+
1755
+ // Restore the terminal whenever this fiber unloads (exit, HMR, fail-loud).
1756
+ ctx.effect(() => {
1757
+ const restore = () => {
1758
+ term.stop()
1759
+ }
1760
+ const onExit = () => restore()
1761
+ const onSigint = () => handleCtrlKey('c')
1762
+ process.on('exit', onExit)
1763
+ process.on('SIGINT', onSigint)
1764
+ return () => {
1765
+ clearInterval(animationTimer)
1766
+ process.off('exit', onExit)
1767
+ process.off('SIGINT', onSigint)
1768
+ restore()
1769
+ }
1770
+ })
1771
+ }