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/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
|
+
}
|