moqi-tui 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
package/lib/index.js ADDED
@@ -0,0 +1,3999 @@
1
+ /**
2
+ * moqi — an interactive terminal app for DeepSeek Harness.
3
+ *
4
+ * The bundle patch rides over `dsh-base` without a Host, HTTP server, or
5
+ * browser plugin: the terminal is the only surface. This module owns the
6
+ * Harness wiring — creating or resuming an Agent, projecting its assistant
7
+ * stream into the transcript, and dispatching slash commands through
8
+ * `ctx.commands` — while `./tui/*` owns everything drawn on screen.
9
+ *
10
+ * @module moqi
11
+ */
12
+ import { randomUUID } from 'node:crypto';
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import { homedir, tmpdir } from 'node:os';
15
+ import { readFile, unlink, writeFile } from 'node:fs/promises';
16
+ import { isAbsolute, join, resolve } from 'node:path';
17
+ import z from '@deepseek-ai/schemastery';
18
+ import { brandString } from '@deepseek-ai/dsh-brand';
19
+ import { installModelSelection } from '@deepseek-ai/dsh-agent';
20
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
21
+ import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session';
22
+ import { Screen } from "./tui/screen.js";
23
+ import { Composer, InputHistory, Palette, Picker, findTool, messageText, messageTools, moveSelection, ownerOfDelegated, queueShouldDrain, segmentTools, textMessage, } from "./tui/state.js";
24
+ import { AtMenu, activeAtToken, acceptToken, extractImageTokens, filterFiles, isImagePath, isPathShaped, } from "./tui/atfile.js";
25
+ import { FileIndex } from "./file-index.js";
26
+ import { TuiHost } from "./tui-host.js";
27
+ import { Vim } from "./tui/vim.js";
28
+ import { forkCut, lineage, projectUserTurns, rewindTarget } from "./rewind.js";
29
+ import { renderJobs } from "./tui/jobs.js";
30
+ import { groupMcpTools, renderMcp } from "./tui/mcp.js";
31
+ import { LANGS, currentLanguage, isLang, setLanguage } from "./tui/i18n.js";
32
+ import { decodeLogBytes, parseLogMessages, searchSessions } from "./cross-find.js";
33
+ import { encodeSegment, projectKey, sessionsRoot } from "./sessions-store.js";
34
+ import { ApprovalPanel, QuestionsPanel, interpretApproval, } from "./tui/panels.js";
35
+ import { UserQuestionError } from '@deepseek-ai/dsh-user-questions';
36
+ import { keyReference, findMatches, hostLabel, layout, maxScrollBack, render, } from "./tui/view.js";
37
+ import { activeTheme, applyTheme, listThemes } from "./tui/theme.js";
38
+ import { projectStreamChunk } from "./tui/stream.js";
39
+ import { applyToolEvent } from "./tui/tooldetail.js";
40
+ import { transcriptMarkdown } from "./tui/export.js";
41
+ import { deleteStoredSessionDir, findStoredSessionDir } from "./sessions-store.js";
42
+ import { planRename, snapshotTitle } from "./rename.js";
43
+ import { activeProfileName, forgetPlugin, isPluginEnabled, listPlugins, parsePackageRequest, readProfileManifest, resolveProfileDir, runPackageManager, setPluginEnabled, writeProfileManifest, } from "./plugins.js";
44
+ import { loadState, MAX_RESTORED_SESSIONS, restorePlan, saveStateSync, } from "./persist.js";
45
+ import { VERSION } from "./version.js";
46
+ import { FleetView, dispatchArgv, isValidPeer, jumpCommand, mergeFleet } from "./tui/fleet.js";
47
+ import { PresencePublisher } from "./presence.js";
48
+ import { collectFleet, localDshHome } from "./fleet-sources.js";
49
+ import { insertionFor, resolveVoiceSetup, startRecording, stopRecording, systemProbe, transcribe, voiceGapMessage, voiceOptionsFromEnv, } from "./voice.js";
50
+ /** Stable Cordis plugin name. */
51
+ export const name = 'moqi';
52
+ /** Core services required before the terminal can open. */
53
+ export const inject = ['agentDefaultModel', 'agents', 'sessions'];
54
+ export const Config = z.object({
55
+ resumeSessionId: z.string(),
56
+ model: z.string(),
57
+ thinking: z.boolean(),
58
+ contextLimit: z.number(),
59
+ mouse: z.boolean(),
60
+ bell: z.boolean(),
61
+ peers: z.array(z.string()),
62
+ restore: z.boolean(),
63
+ voiceModel: z.string(),
64
+ voiceBin: z.string(),
65
+ dispatchProfile: z.string(),
66
+ });
67
+ /** Spinner frames for the streaming indicator. */
68
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
69
+ /** Sentinel id for the picker row that opens a new session. */
70
+ const NEW_SESSION_ROW = 'new-session';
71
+ /** Context budget assumed when the provider publishes no capacity. */
72
+ const DEFAULT_CONTEXT_LIMIT = 65536;
73
+ /** How often the spinner advances while a reply streams, in milliseconds. */
74
+ const SPINNER_INTERVAL = 80;
75
+ /** A second ctrl+c within this window quits; outside it the timer resets. */
76
+ const QUIT_CONFIRM_MS = 1500;
77
+ /** Commands this app implements itself, on top of whatever the Harness adds. */
78
+ const BUILTIN_COMMANDS = [
79
+ { name: 'new', args: '', description: 'Open another session alongside this one' },
80
+ { name: 'sessions', args: '', description: 'Switch between open sessions' },
81
+ { name: 'close', args: '', description: 'Close this session' },
82
+ { name: 'resume', args: '', description: 'Pick up an earlier session' },
83
+ { name: 'delete', args: '', description: 'Delete a stored session for good' },
84
+ {
85
+ name: 'rename',
86
+ args: '[title]',
87
+ description: 'Name this session; no argument regenerates the automatic title',
88
+ },
89
+ { name: 'model', args: '[name]', description: 'Switch model; no argument lists them' },
90
+ { name: 'theme', args: '[name]', description: 'Switch the color palette; no argument lists them' },
91
+ {
92
+ name: 'plugins',
93
+ args: '[add <pkg> | remove <pkg>]',
94
+ description: 'The packages this profile composes; no argument lists them',
95
+ },
96
+ { name: 'thinking', args: '', description: "Toggle display of the reasoner's chain-of-thought" },
97
+ { name: 'tools', args: '', description: 'List the tools this agent can call' },
98
+ { name: 'export', args: '[file]', description: 'Write this transcript to a markdown file' },
99
+ {
100
+ name: 'find',
101
+ args: '<text> | --sessions <text>',
102
+ description: 'Search this transcript, or every stored session, for text',
103
+ },
104
+ { name: 'unqueue', args: '', description: 'Discard prompts queued while a reply was streaming' },
105
+ {
106
+ name: 'interrupt',
107
+ args: '',
108
+ description: 'Stop the streaming reply and run the queued prompts now',
109
+ },
110
+ { name: 'copy', args: '', description: 'Copy the last reply to the system clipboard' },
111
+ {
112
+ name: 'rewind',
113
+ args: '',
114
+ description: 'Redo an earlier prompt in a forked session that keeps the history before it',
115
+ },
116
+ { name: 'fork', args: '', description: 'Copy this session into a resumable twin' },
117
+ { name: 'tree', args: '', description: 'Show this session’s family tree of forks' },
118
+ { name: 'jobs', args: '[kill <id>]', description: 'Background jobs: what is running and what finished' },
119
+ { name: 'mcp', args: '', description: 'MCP servers whose tools are mounted here' },
120
+ { name: 'lang', args: '[en|zh-CN]', description: 'Interface language' },
121
+ { name: 'vim', args: '', description: 'Toggle vim modal editing in the composer' },
122
+ {
123
+ name: 'dispatch',
124
+ args: '<device> <task>',
125
+ description: 'Run a task on a peer through its headless profile',
126
+ },
127
+ { name: 'fleet', args: '', description: 'Sessions across every device (ctrl+f)' },
128
+ { name: 'peer', args: '[add|rm <host>]', description: 'Devices the fleet overview reads' },
129
+ { name: 'about', args: '', description: 'Show version and connection information' },
130
+ { name: 'update', args: '', description: 'Update this package from npm, if a newer one exists' },
131
+ { name: 'help', args: '', description: 'Show keys and commands' },
132
+ { name: 'exit', args: '', description: 'Quit dsh' },
133
+ ];
134
+ /** Create an empty session record. */
135
+ function newTab(id) {
136
+ return {
137
+ id,
138
+ agent: undefined,
139
+ title: '',
140
+ messages: [],
141
+ streaming: false,
142
+ streamingSegments: [],
143
+ streamingReasoning: '',
144
+ streamStartedAt: 0,
145
+ promptTokens: 0,
146
+ completionTokens: 0,
147
+ totalTokens: 0,
148
+ haveUsage: false,
149
+ cacheReadTokens: 0,
150
+ cacheWriteTokens: 0,
151
+ turnStartTokens: 0,
152
+ tps: 0,
153
+ scrollBack: 0,
154
+ queued: [],
155
+ drainQueue: false,
156
+ status: 'idle',
157
+ logSyncedSeq: 0,
158
+ selection: { current: undefined, assembled: undefined },
159
+ modelName: '',
160
+ contextLimit: 0,
161
+ background: new Map(),
162
+ };
163
+ }
164
+ /** The app: owns the screen, the sessions, and every piece of mutable state. */
165
+ class TuiApp {
166
+ /** Open sessions, in creation order. */
167
+ tabs = [newTab('pending')];
168
+ /** Index into {@link tabs} of the session being drawn. */
169
+ active = 0;
170
+ /** The session currently on screen. */
171
+ get tab() {
172
+ const tab = this.tabs[this.active];
173
+ if (tab !== undefined)
174
+ return tab;
175
+ // Unreachable in practice; keeps every accessor total rather than optional.
176
+ const replacement = newTab('pending');
177
+ this.tabs = [replacement];
178
+ this.active = 0;
179
+ return replacement;
180
+ }
181
+ screen;
182
+ composer = new Composer();
183
+ palette = new Palette();
184
+ picker = new Picker();
185
+ /** The `@` file-completion menu, driven by the composer like the palette. */
186
+ atMenu = new AtMenu();
187
+ /** Index of the transcript turn under selection, if any. */
188
+ selectedTurn;
189
+ /** The trust-surface panel on screen, if any: it owns the keyboard. */
190
+ panel;
191
+ pendingApprovals = [];
192
+ pendingQuestions = [];
193
+ /** Modal vim editing for the composer, off until `/vim` asks for it. */
194
+ vim = new Vim();
195
+ /** The extension seam other plugins register shortcuts and a status line into. */
196
+ tuiHost;
197
+ /** Hits from the last `/find --sessions`, indexed by picker row. */
198
+ storedHits = [];
199
+ /** Workspace file listing for `@` completion, rebuilt lazily. */
200
+ fileIndex;
201
+ /** The token esc dismissed, so typing more reopens the menu but esc stays. */
202
+ atDismissed;
203
+ /** Images staged for the next send, numbered as they were picked. */
204
+ stagedImages = [];
205
+ nextImageNo = 1;
206
+ spinnerIndex = 0;
207
+ spinnerTimer;
208
+ status = '';
209
+ statusIsError = false;
210
+ overlay = '';
211
+ showThinking;
212
+ confirming = false;
213
+ confirmPrompt = '';
214
+ confirmAction;
215
+ /** Expanded by default: the transcript lists every tool call as it happens. */
216
+ expandTools = true;
217
+ expandBackground = false;
218
+ /** Live agents other than the foreground one, keyed by session id. */
219
+ /** Sent prompts, recalled with ↑/↓ on the composer's outer rows. */
220
+ history = new InputHistory();
221
+ /** The active transcript search, if `/find` has been run and not cleared. */
222
+ search;
223
+ /** Persisted state as last loaded or saved, and a write debounce. */
224
+ persisted = {
225
+ inputHistory: [],
226
+ thinking: false,
227
+ peers: [],
228
+ sessions: [],
229
+ activeSession: 0,
230
+ };
231
+ persistTimer;
232
+ /** The cross-device overview, and what this device publishes to it. */
233
+ fleet = new FleetView();
234
+ /**
235
+ * Devices the overview reads, from `--peer` and from what was saved.
236
+ *
237
+ * Held here rather than read from config each time because the pane can add
238
+ * to it: needing a relaunch to see a machine you just remembered is the
239
+ * whole reason this is editable.
240
+ */
241
+ peers = [];
242
+ presence;
243
+ /** Signature of the last published set, so an unchanged paint writes nothing. */
244
+ presenceKey = '';
245
+ /** Working directory the sessions were created in, reported in presence. */
246
+ cwd = process.cwd();
247
+ /**
248
+ * Push-to-talk state.
249
+ *
250
+ * `voicePhase` is what the footer draws and `voiceRecording` is the process
251
+ * behind it. They are separate because transcription outlives the recorder:
252
+ * the phase is still `transcribing` long after the child has exited.
253
+ */
254
+ voicePhase;
255
+ voiceRecording;
256
+ /** Resolved once per take, so the transcriber uses what the recorder used. */
257
+ voiceSetup;
258
+ abort;
259
+ disposers = [];
260
+ stopped = false;
261
+ /** Epoch millis of the last ctrl+c that armed the quit confirmation. */
262
+ lastQuitRequest = 0;
263
+ ctx;
264
+ config;
265
+ exit;
266
+ constructor(ctx, config, exit) {
267
+ this.ctx = ctx;
268
+ this.config = config;
269
+ this.exit = exit;
270
+ this.showThinking = config.thinking === true;
271
+ this.tuiHost = new TuiHost(ctx);
272
+ this.presence = new PresencePublisher(localDshHome());
273
+ this.screen = new Screen({
274
+ onKey: (key) => {
275
+ this.handleKey(key);
276
+ },
277
+ onResize: () => {
278
+ this.paint();
279
+ },
280
+ }, { mouse: config.mouse === true });
281
+ }
282
+ /** Boot the app: resolve the agent, open the screen, and paint. */
283
+ async start() {
284
+ await this.ctx.get('loader')?.await();
285
+ const agents = this.ctx.get('agents');
286
+ const defaultModel = this.ctx.get('agentDefaultModel');
287
+ if (agents === undefined || defaultModel === undefined)
288
+ return;
289
+ // Adopt whatever survived the last run before deciding what to show: the
290
+ // composer history and the thinking preference, both best-effort.
291
+ this.persisted = await loadState();
292
+ this.history.load(this.persisted.inputHistory);
293
+ if (this.config.thinking === undefined)
294
+ this.showThinking = this.persisted.thinking;
295
+ if (this.persisted.theme !== undefined)
296
+ applyTheme(this.persisted.theme);
297
+ if (this.persisted.lang !== undefined && isLang(this.persisted.lang))
298
+ setLanguage(this.persisted.lang);
299
+ if (this.persisted.expandTools !== undefined)
300
+ this.expandTools = this.persisted.expandTools;
301
+ // Flags and remembered devices are one list from here on; a duplicate
302
+ // between them should not make a peer appear twice in the overview.
303
+ this.peers = [...new Set([...(this.config.peers ?? []), ...this.persisted.peers])];
304
+ const selection = defaultModel.currentSelection();
305
+ this.tab.selection.current =
306
+ this.config.model === undefined ? selection : { ...selection, model: this.config.model };
307
+ const current = this.tab.selection.current;
308
+ const agentOptions = { provider: current.provider, model: current.model };
309
+ this.tab.modelName = String(current.model);
310
+ const setup = this.selectionSetupFor(this.tab);
311
+ const fs = this.ctx.get('fs');
312
+ const cwd = fs === undefined ? process.cwd() : fs.processPath(await fs.resolve('.'));
313
+ this.cwd = cwd;
314
+ this.fileIndex = new FileIndex(cwd);
315
+ // An explicit --resume names exactly one session, so it outranks whatever
316
+ // happened to be open last time: the user has already said what they want.
317
+ if (this.config.resumeSessionId !== undefined) {
318
+ const sessionId = brandString(this.config.resumeSessionId);
319
+ const resumed = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup });
320
+ this.tab.agent = resumed.agent;
321
+ this.adoptForeground(this.tab);
322
+ this.tab.id = String(sessionId);
323
+ this.tab.title = this.config.resumeSessionId;
324
+ this.tab.messages = readHistory(this.tab.agent.session);
325
+ await this.tab.agent.whenIdle();
326
+ }
327
+ else if (!(await this.restoreSessions(current))) {
328
+ const sessionId = brandString(`session-${randomUUID()}`);
329
+ const created = await agents.create({ sessionId, meta: { cwd }, agentOptions, setup });
330
+ this.tab.agent = created.agent;
331
+ this.adoptForeground(this.tab);
332
+ this.tab.id = String(sessionId);
333
+ await this.tab.agent.whenIdle();
334
+ }
335
+ this.subscribeToStream();
336
+ this.subscribeToAgents();
337
+ // Publishing is what makes this device visible to every other one.
338
+ this.presence.start();
339
+ this.publishPresence();
340
+ for (const tab of this.tabs)
341
+ void this.refreshContextLimit(tab);
342
+ if (!Screen.isInteractive()) {
343
+ throw new Error('the tui profile needs an interactive terminal; use --profile headless for scripted runs');
344
+ }
345
+ this.screen.start();
346
+ this.installSignalHandlers();
347
+ this.registerTrustSurfaces();
348
+ this.paint();
349
+ }
350
+ /**
351
+ * Answer the agent's own questions: tool approvals and `ask_user_question`.
352
+ *
353
+ * Both seams are Cordis waterfalls. Claiming the request means returning an
354
+ * outcome and showing a panel; delegating with `next()` means some other
355
+ * UI — or the fail-closed default — decides. The app claims only when it is
356
+ * actually drawing: a headless mount must never swallow a prompt it cannot
357
+ * show, so everything unmatched falls through.
358
+ */
359
+ registerTrustSurfaces() {
360
+ this.disposers.push(this.ctx.on('approval/request', async (request, next) => {
361
+ if (!this.ownsRequest(request.agent))
362
+ return await next();
363
+ return await new Promise((resolve) => {
364
+ const pending = { request, resolve };
365
+ this.pendingApprovals.push(pending);
366
+ if (request.signal !== undefined) {
367
+ request.signal.addEventListener('abort', () => {
368
+ this.dropApproval(pending);
369
+ resolve('cancelled');
370
+ }, { once: true });
371
+ }
372
+ if (this.panel === undefined)
373
+ this.activateNextPanel();
374
+ this.paint();
375
+ });
376
+ }));
377
+ this.disposers.push(this.ctx.on('user-questions/request', async (request, next) => {
378
+ if (request.agent !== undefined && !this.ownsRequest(request.agent))
379
+ return await next();
380
+ return await new Promise((resolve, reject) => {
381
+ const pending = { request, resolve, reject };
382
+ this.pendingQuestions.push(pending);
383
+ if (request.signal !== undefined) {
384
+ request.signal.addEventListener('abort', () => {
385
+ this.dropQuestion(pending);
386
+ reject(new UserQuestionError('the user closed the question', 'ASK_CANCELLED'));
387
+ }, { once: true });
388
+ }
389
+ if (this.panel === undefined)
390
+ this.activateNextPanel();
391
+ this.paint();
392
+ });
393
+ }));
394
+ }
395
+ /** Whether an ask belongs to an agent this terminal is driving. */
396
+ ownsRequest(agent) {
397
+ if (this.stopped)
398
+ return false;
399
+ if (agent === undefined)
400
+ return Screen.isInteractive();
401
+ // Subagents delegate from a tab's agent; the root of the lineage is ours.
402
+ if (this.tabs.some((tab) => tab.agent === agent))
403
+ return true;
404
+ return false;
405
+ }
406
+ /** Show the oldest waiting panel, if no panel is up. */
407
+ activateNextPanel() {
408
+ if (this.panel !== undefined)
409
+ return;
410
+ const approval = this.pendingApprovals[0];
411
+ if (approval !== undefined) {
412
+ this.panel = new ApprovalPanel(approval.request.toolName, approval.request.reason, this.commandForCall(approval.request.callId));
413
+ return;
414
+ }
415
+ const question = this.pendingQuestions[0];
416
+ if (question !== undefined) {
417
+ this.panel = new QuestionsPanel([...question.request.questions]);
418
+ }
419
+ }
420
+ /** The full command behind an approval, read from the tool call already streamed. */
421
+ commandForCall(callId) {
422
+ if (callId === undefined)
423
+ return undefined;
424
+ for (const message of this.tab.messages) {
425
+ for (const tool of messageTools(message)) {
426
+ if (tool.id === callId && tool.detail !== undefined)
427
+ return tool.detail;
428
+ }
429
+ }
430
+ const streaming = findTool(this.tab.streamingSegments, (tool) => tool.id === callId && tool.detail !== undefined);
431
+ return streaming?.detail;
432
+ }
433
+ dropApproval(pending) {
434
+ const at = this.pendingApprovals.indexOf(pending);
435
+ if (at !== -1)
436
+ this.pendingApprovals.splice(at, 1);
437
+ }
438
+ dropQuestion(pending) {
439
+ const at = this.pendingQuestions.indexOf(pending);
440
+ if (at !== -1)
441
+ this.pendingQuestions.splice(at, 1);
442
+ }
443
+ /**
444
+ * Answer the open approval and move on to whoever is next in line.
445
+ */
446
+ settleApproval(panel, decision) {
447
+ const pending = this.pendingApprovals.shift();
448
+ this.panel = undefined;
449
+ this.activateNextPanel();
450
+ if (pending !== undefined) {
451
+ this.setStatus(decision === 'allowed-once' ? `allowed ${panel.toolName} once` : `denied ${panel.toolName}`);
452
+ pending.resolve(decision);
453
+ }
454
+ this.paint();
455
+ }
456
+ settleQuestion(panel, cancel) {
457
+ const pending = this.pendingQuestions.shift();
458
+ this.panel = undefined;
459
+ this.activateNextPanel();
460
+ if (pending !== undefined) {
461
+ if (cancel)
462
+ pending.reject(new UserQuestionError('the user cancelled the question', 'ASK_CANCELLED'));
463
+ else
464
+ pending.resolve({ answers: panel.answers() });
465
+ }
466
+ this.paint();
467
+ }
468
+ /** Keys while a trust-surface panel owns the keyboard. */
469
+ handlePanelKey(key) {
470
+ const panel = this.panel;
471
+ if (panel === undefined)
472
+ return;
473
+ // The microphone outranks the panel: a live take is the most modal state
474
+ // the app has, so esc cancels it rather than deciding the request.
475
+ if (this.voicePhase !== undefined) {
476
+ if (key.name === 'esc') {
477
+ this.cancelVoice();
478
+ return;
479
+ }
480
+ if (key.name === 'ctrl+v') {
481
+ this.toggleVoice();
482
+ return;
483
+ }
484
+ }
485
+ if (panel instanceof ApprovalPanel) {
486
+ switch (key.name) {
487
+ case 'ctrl+v':
488
+ if (this.voicePhase === undefined)
489
+ this.toggleVoice();
490
+ return;
491
+ case 'up':
492
+ case 'ctrl+p':
493
+ panel.move(-1);
494
+ break;
495
+ case 'down':
496
+ case 'ctrl+n':
497
+ panel.move(1);
498
+ break;
499
+ case '1':
500
+ this.settleApproval(panel, 'allowed-once');
501
+ return;
502
+ case '2':
503
+ this.settleApproval(panel, 'rejected');
504
+ return;
505
+ case 'enter':
506
+ this.settleApproval(panel, panel.decision());
507
+ return;
508
+ case 'esc':
509
+ case 'ctrl+c':
510
+ // Fail closed: esc means no.
511
+ this.settleApproval(panel, 'rejected');
512
+ return;
513
+ default:
514
+ break;
515
+ }
516
+ this.paint();
517
+ return;
518
+ }
519
+ switch (key.name) {
520
+ case 'up':
521
+ case 'ctrl+p':
522
+ panel.move(-1);
523
+ break;
524
+ case 'down':
525
+ case 'ctrl+n':
526
+ panel.move(1);
527
+ break;
528
+ // The decoder reports space as printable text, not as a named key, so
529
+ // the multi-select toggle has to match what actually arrives.
530
+ case ' ':
531
+ panel.toggle();
532
+ break;
533
+ case 'tab':
534
+ panel.focusCustom();
535
+ break;
536
+ case 'enter': {
537
+ const state = panel.advance();
538
+ if (state === 'done') {
539
+ this.settleQuestion(panel, false);
540
+ return;
541
+ }
542
+ if (state === 'empty')
543
+ this.setStatus('choose an option or type an answer');
544
+ break;
545
+ }
546
+ case 'esc':
547
+ if (!panel.back()) {
548
+ this.settleQuestion(panel, true);
549
+ return;
550
+ }
551
+ break;
552
+ case 'ctrl+c':
553
+ this.settleQuestion(panel, true);
554
+ return;
555
+ case 'backspace':
556
+ panel.backspaceText();
557
+ break;
558
+ default:
559
+ if (key.text !== '')
560
+ panel.typeText(key.text);
561
+ break;
562
+ }
563
+ this.paint();
564
+ }
565
+ /**
566
+ * Bring back the sessions that were open at the last exit.
567
+ *
568
+ * Restoring must never be the reason the app fails to open, so every step
569
+ * degrades on its own: an id the store no longer holds is dropped before
570
+ * anything tries to adopt it, an adoption that throws costs only that tab,
571
+ * and coming back empty-handed returns `false` so the caller falls through
572
+ * to creating a fresh session. The worst outcome is the behaviour the app
573
+ * had before any of this existed.
574
+ *
575
+ * @param seed - selection a restored tab falls back to when it has no model
576
+ * of its own recorded.
577
+ * @returns whether at least one session came back.
578
+ */
579
+ async restoreSessions(seed) {
580
+ if (this.config.restore === false)
581
+ return false;
582
+ if (this.persisted.sessions.length === 0)
583
+ return false;
584
+ // Probe the store first so the plan only ever names sessions that exist.
585
+ // `/delete` prunes it, and so does anything else that touched $DSH_HOME
586
+ // between two runs, which makes a dangling id ordinary rather than an
587
+ // error. Stopping once the cap is met bounds the scan for a state file
588
+ // that has been hand-edited into something far longer than a tab bar.
589
+ const present = new Set();
590
+ for (const session of this.persisted.sessions) {
591
+ if (present.size >= MAX_RESTORED_SESSIONS)
592
+ break;
593
+ try {
594
+ if ((await findStoredSessionDir(session.id)) !== undefined)
595
+ present.add(session.id);
596
+ }
597
+ catch {
598
+ // An unreadable store is indistinguishable from an absent session.
599
+ }
600
+ }
601
+ const plan = restorePlan(this.persisted, (id) => present.has(id));
602
+ // Track the active tab by id rather than by position, because the entries
603
+ // that fail to adopt close the gaps up underneath the index.
604
+ const wanted = plan.sessions[plan.active]?.id;
605
+ const restored = [];
606
+ for (const session of plan.sessions) {
607
+ const tab = await this.adoptSession(session, seed);
608
+ if (tab !== undefined)
609
+ restored.push(tab);
610
+ }
611
+ if (restored.length === 0)
612
+ return false;
613
+ this.tabs = restored;
614
+ const active = restored.findIndex((tab) => tab.id === wanted);
615
+ this.active = active === -1 ? 0 : active;
616
+ return true;
617
+ }
618
+ /**
619
+ * Re-adopt one remembered session as a tab, or give up on it quietly.
620
+ *
621
+ * This is {@link TuiApp.openSession} without the screen: the Agent is
622
+ * resumed against the tab's own selection ref and the transcript is read
623
+ * straight back out of the session log, because a restored conversation
624
+ * that came back blank would read as data loss rather than as a resume.
625
+ */
626
+ async adoptSession(session, seed) {
627
+ const agents = this.ctx.get('agents');
628
+ if (agents === undefined)
629
+ return undefined;
630
+ const tab = newTab(session.id);
631
+ tab.title = session.title;
632
+ // The model a conversation was switched to belongs to that conversation
633
+ // rather than to the profile, so it comes back per tab instead of from the
634
+ // shared default, which the user may have left pointing somewhere else.
635
+ const selection = session.model === '' ? seed : { ...seed, model: session.model };
636
+ tab.selection.current = selection;
637
+ tab.modelName = String(selection.model);
638
+ try {
639
+ const resumed = await agents.resume({
640
+ resumeSessionId: brandString(session.id),
641
+ agentOptions: { provider: selection.provider, model: selection.model },
642
+ setup: this.selectionSetupFor(tab),
643
+ });
644
+ tab.agent = resumed.agent;
645
+ await tab.agent.whenIdle();
646
+ tab.messages = readHistory(resumed.agent.session);
647
+ }
648
+ catch {
649
+ // A session the Harness declines to adopt is one we do not bring back.
650
+ return undefined;
651
+ }
652
+ return tab;
653
+ }
654
+ /**
655
+ * Agent setup for one session: couples that tab's own selection ref to the
656
+ * new Agent's context. `installModelSelection` binds this exact object to
657
+ * Agent-scoped prompt assembly and request routing, and the Agent re-reads
658
+ * `current` when each step enters assembly — so a model switch is a
659
+ * mutation of the tab's ref, and every tab mutates only its own. Handing
660
+ * every Agent the same ref (the previous design) is exactly how a switch in
661
+ * one session silently rerouted all the others.
662
+ */
663
+ selectionSetupFor(tab) {
664
+ return (agentCtx) => {
665
+ installModelSelection(agentCtx, tab.selection);
666
+ };
667
+ }
668
+ /** Tear the terminal down and release every subscription. */
669
+ stop() {
670
+ if (this.stopped)
671
+ return;
672
+ this.stopped = true;
673
+ this.stopSpinner();
674
+ // A recorder left running would keep the microphone open after the app
675
+ // it belonged to is gone, with nothing on screen to say so.
676
+ if (this.voiceRecording !== undefined)
677
+ this.cancelVoice();
678
+ this.persistNow();
679
+ // Drop this device's presence records so it does not linger as stale.
680
+ this.presence.stop();
681
+ for (const dispose of this.disposers) {
682
+ try {
683
+ dispose();
684
+ }
685
+ catch {
686
+ // Teardown is best-effort: one bad disposer must not strand the screen.
687
+ }
688
+ }
689
+ this.disposers = [];
690
+ this.screen.stop();
691
+ }
692
+ /** Restore the terminal even when the process is killed from outside. */
693
+ installSignalHandlers() {
694
+ const onSignal = () => {
695
+ this.stop();
696
+ this.exit(0);
697
+ };
698
+ process.once('SIGTERM', onSignal);
699
+ process.once('SIGHUP', onSignal);
700
+ this.disposers.push(() => {
701
+ process.off('SIGTERM', onSignal);
702
+ process.off('SIGHUP', onSignal);
703
+ });
704
+ }
705
+ /** Project the live assistant stream into the transcript. */
706
+ subscribeToStream() {
707
+ const dispose = this.ctx.on('agent/assistant-stream', (payload) => {
708
+ // Any open session may be streaming, not just the one on screen.
709
+ const tab = this.tabFor(payload.agent);
710
+ if (tab === undefined)
711
+ return;
712
+ this.onFrame(tab, payload.frame);
713
+ });
714
+ this.disposers.push(dispose);
715
+ }
716
+ /**
717
+ * Which session a delegated agent belongs to.
718
+ *
719
+ * The Harness does not hand us a delegation parent: `parentSession` on the
720
+ * header is fork lineage, which a subagent need not have. What is reliable
721
+ * is timing -- delegated work is spawned while its parent's turn runs -- so
722
+ * the streaming session claims it, and `parentSession` is honoured first on
723
+ * the occasions it does point at a session we have open. With neither, the
724
+ * session on screen is the only honest guess.
725
+ */
726
+ ownerOf(agent) {
727
+ const parent = agent.session.header.parentSession;
728
+ const index = ownerOfDelegated(this.tabs, parent === undefined ? undefined : String(parent), this.active);
729
+ return this.tabs[index] ?? this.tab;
730
+ }
731
+ /**
732
+ * Claim an agent as a session's foreground, retracting it from every
733
+ * delegated list.
734
+ *
735
+ * `agent/created` fires before the caller can store the agent on its tab, so
736
+ * a newly opened session is briefly indistinguishable from delegated work
737
+ * and gets banked as such. Nothing retracted it afterwards, which is exactly
738
+ * how a second session's own agent came to sit in the first session's strip.
739
+ */
740
+ adoptForeground(tab) {
741
+ const agent = tab.agent;
742
+ if (agent === undefined)
743
+ return;
744
+ const id = String(agent.session.header.id);
745
+ for (const other of this.tabs)
746
+ other.background.delete(id);
747
+ }
748
+ /**
749
+ * Track every other live agent, so delegated work is visible.
750
+ *
751
+ * The transcript only ever shows the foreground agent. A turn that spawns
752
+ * subagents would otherwise look idle while the machine is busy, so the
753
+ * lifecycle events feed a strip above the composer.
754
+ */
755
+ subscribeToAgents() {
756
+ const note = (agent, status) => {
757
+ const header = agent.session.header;
758
+ // Ask the session what it is rather than inferring it. A delegated child
759
+ // is marked as one; a session the user opened is not, whoever happens to
760
+ // be on screen. The old test -- "not the active tab's agent" -- called
761
+ // every other open session's foreground agent delegated work, and it
762
+ // also raced: agent/created fires before the caller can store the agent
763
+ // on its tab, so even the first session briefly qualified.
764
+ if (header.origin !== 'subagent' && (header.delegationDepth ?? 0) === 0)
765
+ return;
766
+ if (this.tabs.some((tab) => tab.agent === agent))
767
+ return;
768
+ const id = String(header.id);
769
+ const owner = this.ownerOf(agent);
770
+ // A refreshed status must not duplicate the row into a second session if
771
+ // the owner is resolved differently later in the turn.
772
+ const held = this.tabs.find((tab) => tab.background.has(id));
773
+ const target = held ?? owner;
774
+ const existing = target.background.get(id);
775
+ if (existing === undefined) {
776
+ target.background.set(id, {
777
+ id,
778
+ label: labelFor(agent),
779
+ status,
780
+ depth: header.delegationDepth ?? 1,
781
+ startedAt: Date.now(),
782
+ });
783
+ }
784
+ else {
785
+ existing.status = status;
786
+ }
787
+ this.paint();
788
+ };
789
+ this.disposers.push(this.ctx.on('agent/created', (payload) => {
790
+ note(payload.agent, 'idle');
791
+ return undefined;
792
+ }));
793
+ this.disposers.push(this.ctx.on('agent/status', (payload) => {
794
+ note(payload.agent, payload.status);
795
+ return undefined;
796
+ }));
797
+ this.disposers.push(this.ctx.on('agent/disposed', (payload) => {
798
+ if (this.tabs.some((tab) => tab.agent === payload.agent))
799
+ return;
800
+ const id = String(payload.agent.session.header.id);
801
+ for (const tab of this.tabs)
802
+ tab.background.delete(id);
803
+ this.paint();
804
+ }));
805
+ }
806
+ /**
807
+ * Apply one stream frame. `start` and `end` carry no chunk; the projection
808
+ * lives in `tui/stream.ts` so it is replayable without a Harness runtime.
809
+ * Unknown chunk kinds are ignored on purpose, because the union is
810
+ * merge-extensible and a plugin may add one this app has never heard of.
811
+ */
812
+ onFrame(tab, frame) {
813
+ if (frame.type !== 'chunk')
814
+ return;
815
+ projectStreamChunk(tab, frame.chunk);
816
+ this.syncToolLog(tab);
817
+ if (tab === this.tabs[this.active])
818
+ this.paint();
819
+ }
820
+ /**
821
+ * Fold session-log events the stream cannot see onto the turn's tool rows.
822
+ *
823
+ * `tool/call` says what a call does; `tool/result` settles it — ok or
824
+ * error — and attaches its outcome under that very row, so feedback rides
825
+ * in the transcript flow instead of a detached status line. Called from the
826
+ * frame handler and the spinner tick: results land while no chunk streams.
827
+ */
828
+ syncToolLog(tab) {
829
+ const session = tab.agent?.session;
830
+ if (session === undefined)
831
+ return;
832
+ const length = session.seq;
833
+ while (tab.logSyncedSeq < length) {
834
+ const event = session.eventAt(SessionSeq(tab.logSyncedSeq));
835
+ if (event !== undefined)
836
+ applyToolEvent(segmentTools(tab.streamingSegments), event);
837
+ tab.logSyncedSeq += 1;
838
+ }
839
+ }
840
+ /**
841
+ * Set the context budget the footer measures against, for one session.
842
+ *
843
+ * An explicit --context-limit wins; otherwise the provider's own capacity
844
+ * for that session's exact model is used, so the bar reflects the model
845
+ * actually answering (GLM-5.3 is 200K, not the 64K a hardcoded default
846
+ * would show). A provider that does not publish a capacity falls back to
847
+ * the flag's default.
848
+ */
849
+ async refreshContextLimit(tab = this.tab) {
850
+ if (this.config.contextLimit !== undefined) {
851
+ tab.contextLimit = this.config.contextLimit;
852
+ return;
853
+ }
854
+ const fallback = DEFAULT_CONTEXT_LIMIT;
855
+ const llm = this.ctx.get('llm');
856
+ const selection = tab.selection.current;
857
+ if (llm === undefined || selection === undefined) {
858
+ tab.contextLimit = tab.contextLimit === 0 ? fallback : tab.contextLimit;
859
+ return;
860
+ }
861
+ try {
862
+ const info = await llm.resolveModelInfo(selection.provider, selection.model);
863
+ const window = info.context?.contextWindow;
864
+ tab.contextLimit = window !== undefined && window > 0 ? window : fallback;
865
+ }
866
+ catch {
867
+ // An unreachable route must not blank the status bar.
868
+ tab.contextLimit = tab.contextLimit === 0 ? fallback : tab.contextLimit;
869
+ }
870
+ if (tab === this.tabs[this.active])
871
+ this.paint();
872
+ }
873
+ /** Every command the palette offers: this app's, plus the Harness registry's. */
874
+ commands() {
875
+ const registry = this.ctx.get('commands');
876
+ const fromHarness = [];
877
+ if (registry !== undefined && this.tab.agent !== undefined) {
878
+ try {
879
+ for (const descriptor of registry.list(this.tab.agent) ?? []) {
880
+ fromHarness.push({
881
+ name: String(descriptor.name),
882
+ args: String(descriptor.input?.hint ?? ''),
883
+ description: String(descriptor.description ?? ''),
884
+ });
885
+ }
886
+ }
887
+ catch {
888
+ // Discovery is best-effort; the built-ins still work without it.
889
+ }
890
+ }
891
+ const seen = new Set(fromHarness.map((command) => command.name));
892
+ const merged = [...fromHarness];
893
+ for (const command of BUILTIN_COMMANDS) {
894
+ if (!seen.has(command.name))
895
+ merged.push(command);
896
+ }
897
+ merged.sort((left, right) => left.name.localeCompare(right.name));
898
+ return merged;
899
+ }
900
+ // ----------------------------------------------------------------- sessions
901
+ /** The tab bar's view of the open sessions. */
902
+ sessionSummaries() {
903
+ return this.tabs.map((tab, index) => ({
904
+ id: tab.id,
905
+ title: tab.title,
906
+ status: tab.status,
907
+ active: index === this.active,
908
+ }));
909
+ }
910
+ /** Find the session that owns an agent, if any. */
911
+ tabFor(agent) {
912
+ return this.tabs.find((tab) => tab.agent === agent);
913
+ }
914
+ /**
915
+ * Move a session to a new status, ringing the bell when it becomes `ready`.
916
+ *
917
+ * `ready` is the only state worth a sound: a turn finished and its answer is
918
+ * waiting. A session you are already looking at is marked seen instead, so
919
+ * the bar does not nag about a reply on screen.
920
+ */
921
+ setSessionStatus(tab, status) {
922
+ const wasReady = tab.status === 'ready';
923
+ if (status === 'ready' && this.tabs[this.active] === tab) {
924
+ tab.status = 'idle';
925
+ if (!wasReady)
926
+ this.ring();
927
+ return;
928
+ }
929
+ tab.status = status;
930
+ if (status === 'ready' && !wasReady)
931
+ this.ring();
932
+ }
933
+ /** Sound the terminal bell, unless it has been turned off. */
934
+ ring() {
935
+ if (this.config.bell === false)
936
+ return;
937
+ try {
938
+ process.stdout.write('\u0007');
939
+ }
940
+ catch {
941
+ // A closed stdout is not a reason to fail a turn.
942
+ }
943
+ }
944
+ /** Switch the view to another session. */
945
+ selectSession(index) {
946
+ if (index < 0 || index >= this.tabs.length)
947
+ return;
948
+ this.active = index;
949
+ // Looking at it counts as reading it.
950
+ const tab = this.tabs[index];
951
+ if (tab !== undefined && tab.status === 'ready')
952
+ tab.status = 'idle';
953
+ // Which tab you were on is part of what a restart should bring back.
954
+ this.persistSoon();
955
+ this.picker.hide();
956
+ this.palette.close();
957
+ this.setStatus('');
958
+ this.screen.invalidate();
959
+ this.paint();
960
+ }
961
+ /** Close a session, keeping at least one open. */
962
+ closeSession(index) {
963
+ if (this.tabs.length <= 1) {
964
+ this.setStatus('the last session cannot be closed — /new opens another', true);
965
+ this.paint();
966
+ return;
967
+ }
968
+ const [closed] = this.tabs.splice(index, 1);
969
+ if (closed?.streaming === true)
970
+ this.setStatus('closed a session that was still replying');
971
+ if (this.active >= this.tabs.length)
972
+ this.active = this.tabs.length - 1;
973
+ else if (index < this.active)
974
+ this.active -= 1;
975
+ // A closed session must not come back on the next launch.
976
+ this.persistSoon();
977
+ this.screen.invalidate();
978
+ this.paint();
979
+ }
980
+ // ---------------------------------------------------------------- rendering
981
+ snapshot() {
982
+ const size = this.screen.size();
983
+ return {
984
+ columns: size.columns,
985
+ rows: size.rows,
986
+ title: this.tab.title,
987
+ host: hostLabel(process.env['DSH_HOST'] ?? 'local harness'),
988
+ modelName: this.tab.modelName,
989
+ messages: this.tab.messages,
990
+ streamingSegments: this.tab.streamingSegments,
991
+ streamingReasoning: this.tab.streamingReasoning,
992
+ streaming: this.tab.streaming,
993
+ spinner: SPINNER[this.spinnerIndex % SPINNER.length] ?? '',
994
+ status: this.status,
995
+ statusIsError: this.statusIsError,
996
+ overlay: this.overlay,
997
+ showThinking: this.showThinking,
998
+ composer: this.composer,
999
+ palette: this.palette,
1000
+ atMenu: this.atMenu,
1001
+ picker: this.picker,
1002
+ scrollBack: this.tab.scrollBack,
1003
+ queued: this.tab.queued.map((prompt) => prompt.text),
1004
+ sessions: this.sessionSummaries(),
1005
+ expandTools: this.expandTools,
1006
+ background: [...this.tab.background.values()],
1007
+ expandBackground: this.expandBackground,
1008
+ elapsedSeconds: this.tab.streamStartedAt === 0 ? 0 : Math.floor((Date.now() - this.tab.streamStartedAt) / 1000),
1009
+ promptTokens: this.tab.promptTokens,
1010
+ completionTokens: this.tab.completionTokens,
1011
+ totalTokens: this.tab.totalTokens,
1012
+ haveUsage: this.tab.haveUsage,
1013
+ tps: this.tab.tps,
1014
+ cacheReadTokens: this.tab.cacheReadTokens,
1015
+ contextLimit: this.tab.contextLimit,
1016
+ confirming: this.confirming,
1017
+ confirmText: this.confirming ? this.confirmPrompt : undefined,
1018
+ searchActive: this.search !== undefined,
1019
+ fleet: this.fleet,
1020
+ panel: this.panel?.view(),
1021
+ selectedTurn: this.selectedTurn,
1022
+ pluginLine: this.tuiHost.statusLine(),
1023
+ vimMode: this.vim.enabled ? this.vim.mode : undefined,
1024
+ voice: this.voicePhase,
1025
+ };
1026
+ }
1027
+ paint() {
1028
+ if (this.stopped)
1029
+ return;
1030
+ // Paint is the one funnel every state change already goes through, and
1031
+ // publishPresence is a no-op unless the published set actually changed.
1032
+ this.publishPresence();
1033
+ const frame = render(this.snapshot());
1034
+ this.screen.setCursor(frame.cursor);
1035
+ this.screen.paint(frame.lines);
1036
+ }
1037
+ setStatus(text, isError = false) {
1038
+ this.status = text;
1039
+ this.statusIsError = isError;
1040
+ }
1041
+ startSpinner() {
1042
+ if (this.spinnerTimer !== undefined)
1043
+ return;
1044
+ this.spinnerTimer = setInterval(() => {
1045
+ this.spinnerIndex += 1;
1046
+ // Tool results arrive between model streams, when no frame fires; the
1047
+ // tick is what keeps every streaming tab's rows settled.
1048
+ for (const open of this.tabs)
1049
+ if (open.streaming)
1050
+ this.syncToolLog(open);
1051
+ this.paint();
1052
+ }, SPINNER_INTERVAL);
1053
+ // The timer must not hold the process open on its own.
1054
+ this.spinnerTimer.unref?.();
1055
+ }
1056
+ stopSpinner() {
1057
+ if (this.spinnerTimer === undefined)
1058
+ return;
1059
+ clearInterval(this.spinnerTimer);
1060
+ this.spinnerTimer = undefined;
1061
+ }
1062
+ /**
1063
+ * Stop the spinner only once nothing on screen still needs it.
1064
+ *
1065
+ * Two independent things animate it now — a streaming reply and a live
1066
+ * microphone — and either one finishing used to be enough to freeze the
1067
+ * other's indicator mid-frame.
1068
+ */
1069
+ releaseSpinner() {
1070
+ if (this.voicePhase !== undefined)
1071
+ return;
1072
+ if (this.tabs.some((open) => open.streaming))
1073
+ return;
1074
+ this.stopSpinner();
1075
+ }
1076
+ // ---------------------------------------------------------------- voice
1077
+ /**
1078
+ * The push-to-talk key: one press arms the microphone, the next transcribes.
1079
+ *
1080
+ * Hold-to-talk would be the obvious shape but a terminal cannot see a key
1081
+ * being released, so the two-press toggle is the only honest version of it.
1082
+ */
1083
+ toggleVoice() {
1084
+ if (this.voicePhase === 'transcribing') {
1085
+ this.setStatus('still transcribing the last take');
1086
+ this.paint();
1087
+ return;
1088
+ }
1089
+ if (this.voiceRecording !== undefined) {
1090
+ void this.finishVoice();
1091
+ return;
1092
+ }
1093
+ this.beginVoice();
1094
+ }
1095
+ /**
1096
+ * Start capturing, or say in one line why this machine cannot.
1097
+ *
1098
+ * Every failure here is a missing optional dependency rather than a fault,
1099
+ * so none of it is allowed to throw: the key press turns into a footer
1100
+ * message and the app carries on exactly as if voice did not exist.
1101
+ */
1102
+ beginVoice() {
1103
+ const resolution = resolveVoiceSetup({
1104
+ ...voiceOptionsFromEnv(),
1105
+ model: this.config.voiceModel,
1106
+ binary: this.config.voiceBin,
1107
+ }, systemProbe());
1108
+ if (!resolution.ok) {
1109
+ this.setStatus(voiceGapMessage(resolution.gap), true);
1110
+ this.paint();
1111
+ return;
1112
+ }
1113
+ const wavPath = join(tmpdir(), `moqi-voice-${randomUUID()}.wav`);
1114
+ try {
1115
+ this.voiceRecording = startRecording(resolution.setup, wavPath);
1116
+ }
1117
+ catch (error) {
1118
+ this.setStatus(`recorder failed: ${describeError(error)}`, true);
1119
+ this.paint();
1120
+ return;
1121
+ }
1122
+ // A recorder that dies on its own — no such device, a busy card — must not
1123
+ // leave the footer claiming the microphone is live forever.
1124
+ this.voiceRecording.child.once('error', (error) => {
1125
+ if (this.voicePhase !== 'recording')
1126
+ return;
1127
+ this.voicePhase = undefined;
1128
+ this.voiceRecording = undefined;
1129
+ this.releaseSpinner();
1130
+ this.setStatus(`recorder failed: ${error.message}`, true);
1131
+ this.paint();
1132
+ });
1133
+ this.voiceSetup = resolution.setup;
1134
+ this.voicePhase = 'recording';
1135
+ this.setStatus('');
1136
+ this.startSpinner();
1137
+ this.paint();
1138
+ }
1139
+ /**
1140
+ * Stop the recorder, run whisper, and splice the result into the composer.
1141
+ *
1142
+ * Nothing is sent: a misheard prompt that submits itself is worse than no
1143
+ * dictation at all, so the transcript lands at the cursor and the person
1144
+ * reads it before pressing enter. Both halves are awaited off the keypress,
1145
+ * so the screen keeps repainting and the spinner keeps turning throughout.
1146
+ */
1147
+ async finishVoice() {
1148
+ const recording = this.voiceRecording;
1149
+ const setup = this.voiceSetup;
1150
+ if (recording === undefined || setup === undefined)
1151
+ return;
1152
+ this.voiceRecording = undefined;
1153
+ this.voicePhase = 'transcribing';
1154
+ this.setStatus('');
1155
+ this.paint();
1156
+ try {
1157
+ await stopRecording(recording);
1158
+ const text = await transcribe(setup, recording.wavPath);
1159
+ // The take may have been abandoned while whisper was still thinking.
1160
+ if (this.voicePhase !== 'transcribing')
1161
+ return;
1162
+ // A take spoken at an approval panel answers it instead of typing: the
1163
+ // whole point of the panel is that the keyboard is captured, so the
1164
+ // dictation would otherwise land somewhere nobody can act on.
1165
+ if (this.panel instanceof ApprovalPanel) {
1166
+ const decision = interpretApproval(text);
1167
+ if (decision === undefined) {
1168
+ this.setStatus('say "allow" or "deny" to answer the request');
1169
+ }
1170
+ else {
1171
+ this.settleApproval(this.panel, decision);
1172
+ }
1173
+ return;
1174
+ }
1175
+ const insertion = insertionFor(this.composer.value(), this.composer.position(), text);
1176
+ if (insertion === '') {
1177
+ this.setStatus('heard nothing');
1178
+ }
1179
+ else {
1180
+ this.history.reset();
1181
+ this.composer.insert(insertion);
1182
+ this.setStatus('transcribed — edit it, then enter to send');
1183
+ }
1184
+ }
1185
+ catch (error) {
1186
+ this.setStatus(describeError(error), true);
1187
+ }
1188
+ finally {
1189
+ if (this.voicePhase === 'transcribing')
1190
+ this.voicePhase = undefined;
1191
+ this.releaseSpinner();
1192
+ void unlink(recording.wavPath).catch(() => {
1193
+ // A stray temp wav is untidy, not a failure worth reporting.
1194
+ });
1195
+ this.paint();
1196
+ }
1197
+ }
1198
+ /**
1199
+ * Throw the take away.
1200
+ *
1201
+ * `esc` has to reach the microphone before it reaches anything else on
1202
+ * screen: a live recording is the most modal thing the app ever does, and
1203
+ * the one state a person most urgently wants out of.
1204
+ */
1205
+ cancelVoice() {
1206
+ const recording = this.voiceRecording;
1207
+ this.voiceRecording = undefined;
1208
+ this.voiceSetup = undefined;
1209
+ this.voicePhase = undefined;
1210
+ this.releaseSpinner();
1211
+ if (recording !== undefined) {
1212
+ try {
1213
+ recording.child.kill('SIGKILL');
1214
+ }
1215
+ catch {
1216
+ // Already gone.
1217
+ }
1218
+ void unlink(recording.wavPath).catch(() => {
1219
+ // Best-effort; the file is in the system temp directory either way.
1220
+ });
1221
+ }
1222
+ this.setStatus('recording discarded');
1223
+ this.paint();
1224
+ }
1225
+ // ---------------------------------------------------------------- fleet
1226
+ /**
1227
+ * Publish what this device is doing, when it has changed.
1228
+ *
1229
+ * Called from every paint, so it must be cheap: the set is reduced to a
1230
+ * signature and nothing is written unless that signature moved. The
1231
+ * publisher's own heartbeat refreshes the timestamps in between, which is
1232
+ * what keeps a quiet device from ageing out as stale.
1233
+ */
1234
+ publishPresence() {
1235
+ const sessions = this.tabs
1236
+ // A tab with no agent has no session id worth publishing yet.
1237
+ .filter((tab) => tab.agent !== undefined)
1238
+ .map((tab) => ({
1239
+ sessionId: tab.id,
1240
+ title: tab.title,
1241
+ status: tab.streaming ? 'running' : tab.status === 'ready' ? 'ready' : 'idle',
1242
+ model: tab.modelName === '' ? undefined : tab.modelName,
1243
+ cwd: this.cwd,
1244
+ }));
1245
+ const key = JSON.stringify(sessions.map((session) => [session.sessionId, session.title, session.status]));
1246
+ if (key === this.presenceKey)
1247
+ return;
1248
+ this.presenceKey = key;
1249
+ this.presence.publish(sessions);
1250
+ }
1251
+ /**
1252
+ * Adopt a device, remembering it for next time.
1253
+ *
1254
+ * Flags and saved peers are one list once the app is running, so a device
1255
+ * added here is indistinguishable from one passed on the command line.
1256
+ */
1257
+ addPeer(host) {
1258
+ const trimmed = host.trim();
1259
+ if (!isValidPeer(trimmed)) {
1260
+ this.setStatus(`not a usable host: ${trimmed}`, true);
1261
+ return false;
1262
+ }
1263
+ if (this.peers.includes(trimmed)) {
1264
+ this.setStatus(`${trimmed} is already in the fleet`);
1265
+ return false;
1266
+ }
1267
+ this.peers = [...this.peers, trimmed];
1268
+ this.persistSoon();
1269
+ return true;
1270
+ }
1271
+ removePeer(host) {
1272
+ if (!this.peers.includes(host))
1273
+ return false;
1274
+ this.peers = this.peers.filter((entry) => entry !== host);
1275
+ this.persistSoon();
1276
+ return true;
1277
+ }
1278
+ /** Open the overview and start a collection round. */
1279
+ openFleet() {
1280
+ this.fleet.show();
1281
+ this.picker.hide();
1282
+ this.palette.close();
1283
+ this.setStatus('');
1284
+ this.paint();
1285
+ void this.refreshFleet();
1286
+ }
1287
+ /**
1288
+ * Collect from this device and every configured peer.
1289
+ *
1290
+ * The pane is already on screen when this runs, so a slow or unreachable
1291
+ * peer appears as a named error under the list instead of blocking the UI.
1292
+ */
1293
+ async refreshFleet() {
1294
+ const peers = this.peers.map((host) => ({ host }));
1295
+ try {
1296
+ const sources = await collectFleet(peers);
1297
+ // The overview may have been closed while SSH was still running.
1298
+ if (!this.fleet.open)
1299
+ return;
1300
+ this.fleet.setResult(mergeFleet(sources, Date.now()), sources);
1301
+ }
1302
+ catch (error) {
1303
+ this.fleet.setResult([], []);
1304
+ this.setStatus(describeError(error), true);
1305
+ }
1306
+ this.paint();
1307
+ }
1308
+ closeFleet() {
1309
+ this.fleet.hide();
1310
+ this.setStatus('');
1311
+ this.screen.invalidate();
1312
+ this.paint();
1313
+ }
1314
+ /**
1315
+ * Act on the highlighted row.
1316
+ *
1317
+ * A session this app already owns is switched to, which is the point of the
1318
+ * list. Anything else lives in another process -- on this machine or another
1319
+ * one -- and this process has no terminal there. Rather than pretend, the
1320
+ * command that does reach it goes on the clipboard.
1321
+ */
1322
+ openFleetSelection() {
1323
+ const session = this.fleet.current();
1324
+ if (session === undefined)
1325
+ return;
1326
+ const open = this.tabs.findIndex((tab) => tab.id === session.sessionId);
1327
+ if (session.local && open !== -1) {
1328
+ this.fleet.hide();
1329
+ this.selectSession(open);
1330
+ return;
1331
+ }
1332
+ const command = jumpCommand(session);
1333
+ const result = this.writeClipboard(command);
1334
+ this.fleet.hide();
1335
+ this.setStatus(result.ok ? `copied: ${command}` : `run: ${command}`);
1336
+ this.screen.invalidate();
1337
+ this.paint();
1338
+ }
1339
+ handleFleetKey(key) {
1340
+ // While the prompt is up it owns the keyboard, or typing "r" into a host
1341
+ // name would refresh the list instead.
1342
+ if (this.fleet.adding) {
1343
+ this.handleFleetPromptKey(key);
1344
+ return;
1345
+ }
1346
+ switch (key.name) {
1347
+ case 'esc':
1348
+ case 'ctrl+c':
1349
+ case 'q':
1350
+ this.closeFleet();
1351
+ break;
1352
+ case 'up':
1353
+ case 'ctrl+p':
1354
+ case 'k':
1355
+ this.fleet.move(-1);
1356
+ this.paint();
1357
+ break;
1358
+ case 'down':
1359
+ case 'ctrl+n':
1360
+ case 'j':
1361
+ this.fleet.move(1);
1362
+ this.paint();
1363
+ break;
1364
+ case 'r':
1365
+ this.fleet.loading = true;
1366
+ this.paint();
1367
+ void this.refreshFleet();
1368
+ break;
1369
+ case 'a':
1370
+ this.fleet.beginAdd();
1371
+ this.setStatus('');
1372
+ this.paint();
1373
+ break;
1374
+ case 'x':
1375
+ case 'delete': {
1376
+ // Only a remote row names a peer; this device is not one of them.
1377
+ const row = this.fleet.current();
1378
+ if (row === undefined || row.local) {
1379
+ this.setStatus('select another device to remove it from the fleet');
1380
+ this.paint();
1381
+ break;
1382
+ }
1383
+ if (this.removePeer(row.host)) {
1384
+ this.setStatus(`removed ${row.host} from the fleet`);
1385
+ this.fleet.loading = true;
1386
+ this.paint();
1387
+ void this.refreshFleet();
1388
+ }
1389
+ else {
1390
+ this.setStatus(`${row.host} was not added here, so it cannot be removed`);
1391
+ this.paint();
1392
+ }
1393
+ break;
1394
+ }
1395
+ case 'enter':
1396
+ this.openFleetSelection();
1397
+ break;
1398
+ case 'p':
1399
+ void this.previewFleetSelection();
1400
+ break;
1401
+ case 'd': {
1402
+ const row = this.fleet.current();
1403
+ if (row === undefined || row.local) {
1404
+ this.setStatus('select another device to dispatch to it');
1405
+ this.paint();
1406
+ break;
1407
+ }
1408
+ const draft = this.composer.value().trim();
1409
+ if (draft === '') {
1410
+ this.setStatus('type the task in the composer, then press d here');
1411
+ this.paint();
1412
+ break;
1413
+ }
1414
+ void this.dispatchTo(row.host, draft);
1415
+ break;
1416
+ }
1417
+ default:
1418
+ break;
1419
+ }
1420
+ }
1421
+ /**
1422
+ * Run a task on a peer's headless profile over the SSH channel.
1423
+ *
1424
+ * The peer answers one task and exits, so this is dispatch rather than
1425
+ * attach: the result comes back as an overlay and the session it left behind
1426
+ * is the peer's to resume. The prompt is quoted for the remote shell by
1427
+ * {@link dispatchArgv}, and BatchMode forbids an interactive password prompt
1428
+ * from swallowing the terminal.
1429
+ */
1430
+ async dispatchTo(device, task) {
1431
+ if (!isValidPeer(device)) {
1432
+ this.setStatus(`${device} is not a usable host`, true);
1433
+ this.paint();
1434
+ return;
1435
+ }
1436
+ const profile = this.config.dispatchProfile ?? 'headless';
1437
+ this.setStatus(`dispatching to ${device}…`);
1438
+ this.paint();
1439
+ const result = await new Promise((resolve) => {
1440
+ const child = spawn('ssh', dispatchArgv(device, profile, task), {
1441
+ stdio: ['ignore', 'pipe', 'pipe'],
1442
+ });
1443
+ const out = [];
1444
+ const err = [];
1445
+ const timer = setTimeout(() => {
1446
+ child.kill();
1447
+ resolve({ ok: false, out: 'timed out after 10 minutes' });
1448
+ }, 600_000);
1449
+ child.stdout?.on('data', (chunk) => out.push(chunk));
1450
+ child.stderr?.on('data', (chunk) => err.push(chunk));
1451
+ child.on('error', (error) => {
1452
+ clearTimeout(timer);
1453
+ resolve({ ok: false, out: error.message });
1454
+ });
1455
+ child.on('exit', (code) => {
1456
+ clearTimeout(timer);
1457
+ const stdout = Buffer.concat(out).toString('utf8');
1458
+ const stderr = Buffer.concat(err).toString('utf8');
1459
+ resolve({
1460
+ ok: code === 0,
1461
+ out: stdout.trim() === '' ? stderr.trim() : stdout.trim(),
1462
+ });
1463
+ });
1464
+ });
1465
+ const body = result.out === '' ? '(no output)' : result.out.slice(-8000);
1466
+ this.showOverlay(`**${device}** · \`${profile}\`\n\n${body}`, result.ok ? 'esc to close' : 'the peer reported a failure · esc to close');
1467
+ }
1468
+ /**
1469
+ * Preview a peer session's transcript, read over the SSH channel that
1470
+ * already exists.
1471
+ *
1472
+ * The log is fetched read-only and decoded locally, so nothing on the peer
1473
+ * is written, nothing new listens, and a peer on an older format simply
1474
+ * shows the messages both formats share. The paths are built with the same
1475
+ * segment encoder the store uses, which is what makes a hostile presence
1476
+ * record unable to escape its own session directory.
1477
+ */
1478
+ async previewFleetSelection() {
1479
+ const row = this.fleet.current();
1480
+ if (row === undefined)
1481
+ return;
1482
+ if (row.local) {
1483
+ this.setStatus('this is this device — the session is on screen here');
1484
+ this.paint();
1485
+ return;
1486
+ }
1487
+ const sessionId = row.sessionId;
1488
+ if (sessionId === '') {
1489
+ this.setStatus('that row carries no session id to read');
1490
+ this.paint();
1491
+ return;
1492
+ }
1493
+ const cwd = row.cwd ?? '';
1494
+ const relative = join('sessions', cwd === '' ? '--' : projectKey(cwd), encodeSegment(sessionId));
1495
+ const remote = `sh -lc 'd="\${DSH_HOME:-$HOME/.dsh}"; cat "$d"/` +
1496
+ `${relative}/session.v*.jsonl* 2>/dev/null'`;
1497
+ this.setStatus(`reading ${sessionId} from ${row.host}…`);
1498
+ this.paint();
1499
+ const raw = await new Promise((resolve) => {
1500
+ const child = spawn('ssh', ['-o', 'BatchMode=yes', row.host, remote], {
1501
+ stdio: ['ignore', 'pipe', 'ignore'],
1502
+ });
1503
+ const chunks = [];
1504
+ const timer = setTimeout(() => {
1505
+ child.kill();
1506
+ resolve(undefined);
1507
+ }, 15_000);
1508
+ child.stdout?.on('data', (chunk) => chunks.push(chunk));
1509
+ child.on('error', () => {
1510
+ clearTimeout(timer);
1511
+ resolve(undefined);
1512
+ });
1513
+ child.on('exit', () => {
1514
+ clearTimeout(timer);
1515
+ resolve(Buffer.concat(chunks));
1516
+ });
1517
+ });
1518
+ if (raw === undefined || raw.byteLength === 0) {
1519
+ this.setStatus(`nothing readable came back from ${row.host}`, true);
1520
+ this.paint();
1521
+ return;
1522
+ }
1523
+ const decoded = decodeLogBytes(new Uint8Array(raw));
1524
+ if (decoded === undefined) {
1525
+ this.setStatus('that log is compressed and this Node cannot decode it', true);
1526
+ this.paint();
1527
+ return;
1528
+ }
1529
+ const messages = parseLogMessages(decoded);
1530
+ if (messages.length === 0) {
1531
+ this.setStatus('that session has no readable messages');
1532
+ this.paint();
1533
+ return;
1534
+ }
1535
+ const tail = messages.slice(-40);
1536
+ const body = tail
1537
+ .map((message) => message.role === 'user' ? `**you**\n\n${message.text}` : message.text)
1538
+ .join('\n\n---\n\n');
1539
+ this.showOverlay(`**${row.title === '' ? sessionId : row.title}** · ${row.host}\n\n${body}`, 'read-only preview · esc to close');
1540
+ }
1541
+ handleFleetPromptKey(key) {
1542
+ switch (key.name) {
1543
+ case 'esc':
1544
+ case 'ctrl+c':
1545
+ this.fleet.cancelAdd();
1546
+ this.setStatus('');
1547
+ this.paint();
1548
+ break;
1549
+ case 'backspace':
1550
+ this.fleet.backspaceAdd();
1551
+ this.paint();
1552
+ break;
1553
+ case 'enter': {
1554
+ const draft = this.fleet.draft.trim();
1555
+ const host = this.fleet.commitAdd();
1556
+ if (host === undefined) {
1557
+ this.setStatus(`not a usable host: ${draft}`, true);
1558
+ this.paint();
1559
+ break;
1560
+ }
1561
+ if (this.addPeer(host))
1562
+ this.setStatus(`added ${host}`);
1563
+ this.fleet.loading = true;
1564
+ this.paint();
1565
+ void this.refreshFleet();
1566
+ break;
1567
+ }
1568
+ default:
1569
+ if (key.text !== '' && !key.text.includes('\n')) {
1570
+ this.fleet.typeAdd(key.text);
1571
+ this.paint();
1572
+ }
1573
+ break;
1574
+ }
1575
+ }
1576
+ // ------------------------------------------------------------ key handling
1577
+ handleKey(key) {
1578
+ try {
1579
+ // Any other key disarms a pending quit: ctrl+c, then a moment of
1580
+ // navigation, then ctrl+c again should open the menu, not lose the
1581
+ // session to a stale confirmation.
1582
+ if (key.name !== 'ctrl+c')
1583
+ this.lastQuitRequest = 0;
1584
+ if (this.panel !== undefined) {
1585
+ this.handlePanelKey(key);
1586
+ return;
1587
+ }
1588
+ if (this.fleet.open) {
1589
+ this.handleFleetKey(key);
1590
+ return;
1591
+ }
1592
+ if (this.picker.kind !== 'none') {
1593
+ this.handlePickerKey(key);
1594
+ return;
1595
+ }
1596
+ if (this.confirming) {
1597
+ this.handleConfirmKey(key);
1598
+ return;
1599
+ }
1600
+ this.handleChatKey(key);
1601
+ }
1602
+ catch (error) {
1603
+ this.setStatus(describeError(error), true);
1604
+ this.paint();
1605
+ }
1606
+ }
1607
+ handleConfirmKey(key) {
1608
+ const action = this.confirmAction;
1609
+ this.confirming = false;
1610
+ this.confirmAction = undefined;
1611
+ this.confirmPrompt = '';
1612
+ if (key.name === 'y' || key.name === 'Y') {
1613
+ this.setStatus('confirmed');
1614
+ if (action !== undefined)
1615
+ action();
1616
+ }
1617
+ else {
1618
+ this.setStatus('cancelled');
1619
+ }
1620
+ this.paint();
1621
+ }
1622
+ /**
1623
+ * Ask a yes/no question in the composer before a destructive action. Any
1624
+ * key but `y` cancels; the prompt sits in the composer box, where the next
1625
+ * keystroke is guaranteed to land.
1626
+ */
1627
+ confirm(prompt, action) {
1628
+ this.confirming = true;
1629
+ this.confirmPrompt = prompt;
1630
+ this.confirmAction = action;
1631
+ this.picker.hide();
1632
+ this.palette.close();
1633
+ this.setStatus('y to confirm · anything else cancels');
1634
+ this.paint();
1635
+ }
1636
+ handlePickerKey(key) {
1637
+ switch (key.name) {
1638
+ case 'ctrl+c':
1639
+ this.requestQuit();
1640
+ return;
1641
+ case 'esc':
1642
+ this.picker.hide();
1643
+ break;
1644
+ case 'up':
1645
+ case 'ctrl+p':
1646
+ this.picker.move(-1);
1647
+ break;
1648
+ case 'down':
1649
+ case 'ctrl+n':
1650
+ this.picker.move(1);
1651
+ break;
1652
+ case 'pageup':
1653
+ this.picker.move(-10);
1654
+ break;
1655
+ case 'pagedown':
1656
+ this.picker.move(10);
1657
+ break;
1658
+ case 'home':
1659
+ this.picker.move(-this.picker.items.length);
1660
+ break;
1661
+ case 'end':
1662
+ this.picker.move(this.picker.items.length);
1663
+ break;
1664
+ case 'backspace':
1665
+ this.picker.setQuery(this.picker.query.slice(0, -1));
1666
+ break;
1667
+ case 'ctrl+u':
1668
+ this.picker.setQuery('');
1669
+ break;
1670
+ case 'enter': {
1671
+ const item = this.picker.current();
1672
+ const kind = this.picker.kind;
1673
+ this.picker.hide();
1674
+ if (item === undefined)
1675
+ break;
1676
+ if (kind === 'models')
1677
+ void this.switchModel(item);
1678
+ else if (kind === 'themes')
1679
+ this.selectTheme(item.id);
1680
+ else if (kind === 'open') {
1681
+ if (item.id === NEW_SESSION_ROW)
1682
+ void this.newSession();
1683
+ else
1684
+ this.selectSession(Number.parseInt(item.id, 10));
1685
+ }
1686
+ else if (kind === 'plugins')
1687
+ this.togglePlugin(item.id);
1688
+ else if (kind === 'delete')
1689
+ this.confirmDelete(item.id, item.title);
1690
+ else if (kind === 'rewind')
1691
+ void this.performRewind(Number.parseInt(item.id, 10));
1692
+ else if (kind === 'stored')
1693
+ void this.openStoredHit(item);
1694
+ else if (kind === 'lang')
1695
+ this.selectLanguage(isLang(item.id) ? item.id : 'en');
1696
+ else
1697
+ void this.openSession(item.id, item.title);
1698
+ break;
1699
+ }
1700
+ default:
1701
+ // Anything printable narrows the list, the way a command palette does.
1702
+ if (key.text !== '')
1703
+ this.picker.setQuery(this.picker.query + key.text);
1704
+ break;
1705
+ }
1706
+ this.paint();
1707
+ }
1708
+ handleChatKey(key) {
1709
+ // Vim mode owns the composer while it is on. A panel, picker, or fleet
1710
+ // screen never reaches here, so modal editing can never eat their keys.
1711
+ if (this.vim.enabled) {
1712
+ const outcome = this.vim.handle(key.name, key.text, this.composer);
1713
+ if (outcome === 'mode' || outcome === 'handled') {
1714
+ this.history.reset();
1715
+ if (outcome === 'handled' || this.vim.normal)
1716
+ this.setStatus('');
1717
+ this.updateAtMenu();
1718
+ this.paint();
1719
+ return;
1720
+ }
1721
+ }
1722
+ switch (key.name) {
1723
+ case 'ctrl+c':
1724
+ this.requestQuit();
1725
+ return;
1726
+ case 'esc':
1727
+ // A live microphone outranks everything else esc can dismiss: it is
1728
+ // the most modal state the app has, and the one to get out of first.
1729
+ if (this.voicePhase !== undefined)
1730
+ this.cancelVoice();
1731
+ else if (this.selectedTurn !== undefined) {
1732
+ this.selectedTurn = undefined;
1733
+ this.setStatus('');
1734
+ }
1735
+ else if (this.atMenu.open) {
1736
+ // Only the completion menu closes; the token stays for typing.
1737
+ this.atDismissed = this.atMenu.query;
1738
+ this.atMenu.close();
1739
+ }
1740
+ else if (this.palette.open)
1741
+ this.palette.close();
1742
+ else if (this.overlay !== '')
1743
+ this.overlay = '';
1744
+ else if (this.search !== undefined)
1745
+ this.clearSearch();
1746
+ else if (this.tab.streaming)
1747
+ this.interrupt();
1748
+ break;
1749
+ case 'enter': {
1750
+ const chosen = this.palette.current();
1751
+ if (chosen !== undefined) {
1752
+ const rawInput = this.composer.value().slice(1).split(' ').slice(1).join(' ');
1753
+ this.palette.close();
1754
+ this.composer.reset();
1755
+ void this.runCommand(chosen.name, rawInput);
1756
+ break;
1757
+ }
1758
+ if (this.atMenu.open) {
1759
+ // The completion menu is offering paths: enter picks one rather
1760
+ // than sending, the same contract the command palette has.
1761
+ this.acceptAtCompletion();
1762
+ break;
1763
+ }
1764
+ const text = this.composer.value().trim();
1765
+ if (text === '')
1766
+ break;
1767
+ if (text.startsWith('/')) {
1768
+ const [head, ...rest] = text.slice(1).split(' ');
1769
+ this.composer.reset();
1770
+ void this.runCommand((head ?? '').toLowerCase(), rest.join(' '));
1771
+ break;
1772
+ }
1773
+ if (this.tab.streaming) {
1774
+ // The turn is busy: steer the prompt into it at the next step
1775
+ // boundary rather than waiting — the queued form is `tab`.
1776
+ const prompt = this.materializePrompt();
1777
+ this.composer.reset();
1778
+ this.history.add(prompt.text);
1779
+ this.persistSoon();
1780
+ void this.steer(prompt);
1781
+ break;
1782
+ }
1783
+ // Materialize first: the draft has to be read out of the composer
1784
+ // before the composer is emptied, or the prompt is born blank — the
1785
+ // transcript commits an empty turn and the model is asked nothing.
1786
+ const prompt = this.materializePrompt();
1787
+ this.composer.reset();
1788
+ void this.sendPrompt(prompt);
1789
+ break;
1790
+ }
1791
+ case 'ctrl+enter': {
1792
+ // Interrupt-and-send: the redirection form of steering, the same
1793
+ // thing `/interrupt` does to whatever is already queued.
1794
+ const text = this.composer.value().trim();
1795
+ if (text === '' || text.startsWith('/') || !this.tab.streaming)
1796
+ break;
1797
+ const prompt = this.materializePrompt();
1798
+ this.composer.reset();
1799
+ this.history.add(prompt.text);
1800
+ this.persistSoon();
1801
+ this.tab.queued.push(prompt);
1802
+ this.tab.drainQueue = true;
1803
+ this.interrupt();
1804
+ break;
1805
+ }
1806
+ case 'ctrl+j':
1807
+ this.composer.insert('\n');
1808
+ break;
1809
+ case 'tab': {
1810
+ const chosen = this.palette.current();
1811
+ if (this.atMenu.open) {
1812
+ this.acceptAtCompletion();
1813
+ break;
1814
+ }
1815
+ if (chosen !== undefined) {
1816
+ this.composer.setValue(`/${chosen.name} `);
1817
+ this.palette.close();
1818
+ }
1819
+ else if (this.tab.streaming && this.composer.value().trim() !== '') {
1820
+ // Queue behind the running turn: enter steers, tab waits.
1821
+ const prompt = this.materializePrompt();
1822
+ this.composer.reset();
1823
+ this.history.add(prompt.text);
1824
+ this.persistSoon();
1825
+ this.tab.queued.push(prompt);
1826
+ this.setStatus(`${String(this.tab.queued.length)} queued — sends when the reply finishes`);
1827
+ }
1828
+ else if (this.composer.value() !== '') {
1829
+ // A draft plus muscle-memory tab must not switch sessions under it;
1830
+ // cycling needs an empty composer, like n/N in a search.
1831
+ this.setStatus('tab cycles sessions on an empty composer');
1832
+ }
1833
+ else if (this.tabs.length > 1) {
1834
+ // With no palette open and nothing typed, tab cycles sessions — the
1835
+ // one-key form of alt+n, for moving between conversations without a
1836
+ // chord.
1837
+ this.selectSession((this.active + 1) % this.tabs.length);
1838
+ }
1839
+ else {
1840
+ this.setStatus('only one session — ctrl+n opens another');
1841
+ }
1842
+ break;
1843
+ }
1844
+ case 'up':
1845
+ if (this.palette.open)
1846
+ this.palette.move(-1);
1847
+ else if (this.atMenu.open)
1848
+ this.atMenu.move(-1);
1849
+ else if (this.history.isRecalling() || this.composer.atFirstRow(this.innerWidth())) {
1850
+ const recalled = this.history.recall(-1, this.composer.value());
1851
+ if (recalled !== undefined)
1852
+ this.composer.setValue(recalled);
1853
+ }
1854
+ else
1855
+ this.composer.moveRow(-1, this.innerWidth());
1856
+ break;
1857
+ case 'down':
1858
+ if (this.palette.open)
1859
+ this.palette.move(1);
1860
+ else if (this.atMenu.open)
1861
+ this.atMenu.move(1);
1862
+ else if (this.history.isRecalling() || this.composer.atLastRow(this.innerWidth())) {
1863
+ const recalled = this.history.recall(1, this.composer.value());
1864
+ if (recalled !== undefined)
1865
+ this.composer.setValue(recalled);
1866
+ }
1867
+ else
1868
+ this.composer.moveRow(1, this.innerWidth());
1869
+ break;
1870
+ case 'ctrl+p':
1871
+ if (this.palette.open)
1872
+ this.palette.move(-1);
1873
+ else if (this.atMenu.open)
1874
+ this.atMenu.move(-1);
1875
+ break;
1876
+ case 'ctrl+n':
1877
+ if (this.palette.open)
1878
+ this.palette.move(1);
1879
+ else if (this.atMenu.open)
1880
+ this.atMenu.move(1);
1881
+ else
1882
+ void this.runCommand('new', '');
1883
+ break;
1884
+ case 'ctrl+r':
1885
+ void this.runCommand('resume', '');
1886
+ break;
1887
+ case 'ctrl+t':
1888
+ void this.runCommand('thinking', '');
1889
+ break;
1890
+ case 'ctrl+x':
1891
+ // Compaction is a Harness command, so this is the same path as typing
1892
+ // /compact — the binding just saves the typing on a long session.
1893
+ void this.runCommand('compact', '');
1894
+ break;
1895
+ case 'ctrl+y':
1896
+ this.copyLastReply();
1897
+ break;
1898
+ case 'ctrl+f':
1899
+ this.openFleet();
1900
+ break;
1901
+ case 'ctrl+v':
1902
+ this.toggleVoice();
1903
+ break;
1904
+ case 'left':
1905
+ this.composer.left();
1906
+ break;
1907
+ case 'right':
1908
+ this.composer.right();
1909
+ break;
1910
+ case 'ctrl+left':
1911
+ case 'alt+b':
1912
+ this.composer.wordLeft();
1913
+ break;
1914
+ case 'ctrl+right':
1915
+ case 'alt+f':
1916
+ this.composer.wordRight();
1917
+ break;
1918
+ case 'home':
1919
+ case 'ctrl+a':
1920
+ this.composer.home();
1921
+ break;
1922
+ case 'end':
1923
+ case 'ctrl+e':
1924
+ this.composer.end();
1925
+ break;
1926
+ case 'backspace':
1927
+ this.composer.backspace();
1928
+ this.history.reset();
1929
+ break;
1930
+ case 'delete':
1931
+ this.composer.deleteForward();
1932
+ this.history.reset();
1933
+ break;
1934
+ case 'ctrl+w':
1935
+ this.composer.deleteWord();
1936
+ this.history.reset();
1937
+ break;
1938
+ case 'ctrl+k':
1939
+ this.composer.killToEnd();
1940
+ this.history.reset();
1941
+ break;
1942
+ // Scrolling is a first-class keyboard surface: a page, a half page, a
1943
+ // single line, and a way straight back to the newest output.
1944
+ case 'pageup':
1945
+ this.scroll(-this.pageRows());
1946
+ break;
1947
+ case 'pagedown':
1948
+ this.scroll(this.pageRows());
1949
+ break;
1950
+ case 'ctrl+u':
1951
+ // Clear the line, as every shell does.
1952
+ this.composer.reset();
1953
+ this.history.reset();
1954
+ break;
1955
+ case 'ctrl+d':
1956
+ // Delete forward — the readline pair to backspace.
1957
+ this.composer.deleteForward();
1958
+ this.history.reset();
1959
+ break;
1960
+ case 'ctrl+up':
1961
+ this.scroll(-Math.max(Math.floor(this.pageRows() / 2), 1));
1962
+ break;
1963
+ case 'ctrl+down':
1964
+ this.scroll(Math.max(Math.floor(this.pageRows() / 2), 1));
1965
+ break;
1966
+ case 'shift+up':
1967
+ this.scroll(-1);
1968
+ break;
1969
+ case 'shift+down':
1970
+ this.scroll(1);
1971
+ break;
1972
+ case 'ctrl+g':
1973
+ this.scrollToBottom();
1974
+ break;
1975
+ case 'wheelup':
1976
+ this.scroll(-3);
1977
+ break;
1978
+ case 'wheeldown':
1979
+ this.scroll(3);
1980
+ break;
1981
+ case 'alt+e':
1982
+ void this.editDraft();
1983
+ break;
1984
+ case 'alt+up':
1985
+ this.moveSelection(-1);
1986
+ break;
1987
+ case 'alt+down':
1988
+ this.moveSelection(1);
1989
+ break;
1990
+ case 'alt+c':
1991
+ this.copySelectedTurn();
1992
+ break;
1993
+ case 'alt+n':
1994
+ this.selectSession((this.active + 1) % this.tabs.length);
1995
+ break;
1996
+ case 'alt+p':
1997
+ this.selectSession((this.active - 1 + this.tabs.length) % this.tabs.length);
1998
+ break;
1999
+ case 'ctrl+b':
2000
+ if (this.tab.background.size === 0) {
2001
+ this.setStatus('no background agents running');
2002
+ break;
2003
+ }
2004
+ this.expandBackground = !this.expandBackground;
2005
+ break;
2006
+ case 'ctrl+o':
2007
+ this.expandTools = !this.expandTools;
2008
+ this.setStatus(this.expandTools ? 'showing every tool call' : 'tool calls collapsed');
2009
+ break;
2010
+ default: {
2011
+ const jump = /^alt\+([1-9])$/.exec(key.name);
2012
+ if (jump !== null) {
2013
+ this.selectSession(Number.parseInt(jump[1] ?? '1', 10) - 1);
2014
+ break;
2015
+ }
2016
+ // A plugin may own this key; if it does, the key is never text.
2017
+ if (key.text !== '' && this.tuiHost.dispatch(key.name))
2018
+ break;
2019
+ if (key.text === '')
2020
+ break;
2021
+ // With a search active and nothing typed, `n`/`N` walk the matches —
2022
+ // the same letter a pager uses — instead of inserting into the buffer.
2023
+ if (this.search !== undefined && this.composer.value() === '' && (key.name === 'n' || key.name === 'N')) {
2024
+ this.jumpToMatch(key.name === 'n' ? 1 : -1);
2025
+ break;
2026
+ }
2027
+ // `?` on an empty composer opens the key reference, matching the hint
2028
+ // the footer prints; otherwise it is just a question mark.
2029
+ if (key.name === '?' && this.composer.value() === '') {
2030
+ void this.runCommand('help', '');
2031
+ break;
2032
+ }
2033
+ this.history.reset();
2034
+ this.composer.insert(key.text);
2035
+ break;
2036
+ }
2037
+ }
2038
+ this.palette.update(this.composer.value(), this.commands());
2039
+ this.updateAtMenu();
2040
+ this.paint();
2041
+ }
2042
+ /**
2043
+ * Recompute the `@` file menu from the composer and cursor.
2044
+ *
2045
+ * A plain query fuzzy-matches the workspace listing; one containing `/`
2046
+ * lists just that directory, the way a shell completes. The listing is
2047
+ * refreshed lazily by the index, so this stays cheap enough for a keystroke.
2048
+ */
2049
+ updateAtMenu() {
2050
+ const token = activeAtToken(this.composer.value(), this.composer.position());
2051
+ if (token === undefined) {
2052
+ this.atMenu.close();
2053
+ return;
2054
+ }
2055
+ let matches;
2056
+ if (isPathShaped(token.query)) {
2057
+ // `@src/tu` completes within `src/`; the query minus its last segment.
2058
+ const cut = token.query.lastIndexOf('/');
2059
+ const directory = cut === -1 ? '' : token.query.slice(0, cut);
2060
+ const fragment = cut === -1 ? token.query : token.query.slice(cut + 1);
2061
+ const listing = this.fileIndex?.listDir(directory) ?? { files: [] };
2062
+ matches = listing.files
2063
+ .filter((file) => file.path.toLowerCase().includes(fragment.toLowerCase()))
2064
+ .map((file) => ({ path: file.path, directory: file.directory }))
2065
+ .slice(0, 200);
2066
+ }
2067
+ else {
2068
+ matches = filterFiles(token.query, this.fileIndex?.list() ?? []);
2069
+ }
2070
+ this.atMenu.update(token, matches, this.atDismissed);
2071
+ }
2072
+ /** Accept the selected completion, replacing the `@` token with the path. */
2073
+ acceptAtCompletion() {
2074
+ const chosen = this.atMenu.current();
2075
+ const token = activeAtToken(this.composer.value(), this.composer.position());
2076
+ if (chosen === undefined || token === undefined) {
2077
+ this.atMenu.close();
2078
+ return;
2079
+ }
2080
+ this.atDismissed = undefined;
2081
+ this.atMenu.close();
2082
+ if (chosen.directory) {
2083
+ // Descend: keep the menu open on the directory's contents.
2084
+ const edit = acceptToken(this.composer.value(), this.composer.position(), token, `@${chosen.path}/`);
2085
+ this.composer.adopt(edit.text, edit.cursor);
2086
+ this.updateAtMenu();
2087
+ return;
2088
+ }
2089
+ const edit = acceptToken(this.composer.value(), this.composer.position(), token, chosen.path);
2090
+ if (isImagePath(chosen.path)) {
2091
+ void this.stageImage(chosen.path, token, edit);
2092
+ }
2093
+ else {
2094
+ this.composer.adopt(edit.text, edit.cursor);
2095
+ }
2096
+ }
2097
+ /**
2098
+ * Stage one picked image as a durable attachment and insert its token.
2099
+ *
2100
+ * The bytes are committed to the harness attachment store immediately, so
2101
+ * send only pairs tokens with references. Without the service — a minimal
2102
+ * profile — the path is inserted as plain text instead, which the model can
2103
+ * still read.
2104
+ */
2105
+ async stageImage(path, token, plainEdit) {
2106
+ const attachments = this.ctx.get('attachments');
2107
+ if (attachments === undefined) {
2108
+ this.composer.adopt(plainEdit.text, plainEdit.cursor);
2109
+ this.setStatus('attachment service unavailable — inserted the path instead');
2110
+ return;
2111
+ }
2112
+ const mediaType = mediaTypeOf(path);
2113
+ if (mediaType === undefined) {
2114
+ this.composer.adopt(plainEdit.text, plainEdit.cursor);
2115
+ return;
2116
+ }
2117
+ let data;
2118
+ try {
2119
+ data = new Uint8Array(await readFile(join(this.cwd, path)));
2120
+ }
2121
+ catch (error) {
2122
+ this.composer.adopt(plainEdit.text, plainEdit.cursor);
2123
+ this.setStatus(describeError(error), true);
2124
+ this.paint();
2125
+ return;
2126
+ }
2127
+ try {
2128
+ const ref = await attachments.saveImage({ data, mediaType, name: path });
2129
+ const n = this.nextImageNo;
2130
+ this.nextImageNo += 1;
2131
+ this.stagedImages.push({ n, ref });
2132
+ const edit = acceptToken(plainEdit.text.slice(0, plainEdit.cursor), plainEdit.cursor, { start: token.start }, `[Image #${String(n)} ${path}]`);
2133
+ this.composer.adopt(edit.text + plainEdit.text.slice(plainEdit.cursor), edit.cursor);
2134
+ this.setStatus(`attached ${path} ${String(ref.width)}×${String(ref.height)}`);
2135
+ }
2136
+ catch (error) {
2137
+ this.composer.adopt(plainEdit.text, plainEdit.cursor);
2138
+ this.setStatus(describeError(error), true);
2139
+ }
2140
+ this.paint();
2141
+ }
2142
+ innerWidth() {
2143
+ return Math.max(layout(this.snapshot()).contentWidth - 4, 10);
2144
+ }
2145
+ pageRows() {
2146
+ return Math.max(layout(this.snapshot()).viewportRows - 1, 1);
2147
+ }
2148
+ /**
2149
+ * Scroll the transcript. Positive `delta` moves towards the newest output.
2150
+ * Clamped to the rendered body, so holding pageup cannot drift the view into
2151
+ * empty space above the first line and then need as many presses to come back.
2152
+ */
2153
+ scroll(delta) {
2154
+ const limit = maxScrollBack(this.snapshot());
2155
+ this.tab.scrollBack = Math.min(Math.max(this.tab.scrollBack - delta, 0), limit);
2156
+ }
2157
+ /**
2158
+ * Show a document in the transcript pane, positioned at its first line.
2159
+ *
2160
+ * The transcript is anchored to the bottom because that is where new output
2161
+ * lands, but a document is read from the top — opening help on its last
2162
+ * paragraph would look like the page had already scrolled.
2163
+ */
2164
+ showOverlay(markdown, status) {
2165
+ this.overlay = markdown;
2166
+ this.tab.scrollBack = 0;
2167
+ this.paint();
2168
+ this.tab.scrollBack = maxScrollBack(this.snapshot());
2169
+ this.setStatus(status);
2170
+ this.paint();
2171
+ }
2172
+ /** Jump back to the newest output. */
2173
+ scrollToBottom() {
2174
+ this.tab.scrollBack = 0;
2175
+ }
2176
+ // ------------------------------------------------------------ persistence
2177
+ /** Write the durable state, coalescing a burst of edits into one save. */
2178
+ persistSoon() {
2179
+ if (this.persistTimer !== undefined)
2180
+ clearTimeout(this.persistTimer);
2181
+ this.persistTimer = setTimeout(() => {
2182
+ this.persistTimer = undefined;
2183
+ this.persistNow();
2184
+ }, 500);
2185
+ this.persistTimer.unref?.();
2186
+ }
2187
+ /** Write the durable state immediately, ignoring a failing backend. */
2188
+ persistNow() {
2189
+ this.persisted = {
2190
+ inputHistory: [...this.history.snapshot()],
2191
+ thinking: this.showThinking,
2192
+ theme: activeTheme(),
2193
+ expandTools: this.expandTools,
2194
+ peers: this.peers,
2195
+ sessions: this.openSessions(),
2196
+ activeSession: this.activeSessionIndex(),
2197
+ };
2198
+ // Synchronous on purpose: this runs on the quit path, where an async write
2199
+ // would be abandoned the moment `exit(0)` tears the process down.
2200
+ saveStateSync(this.persisted);
2201
+ }
2202
+ /**
2203
+ * The open sessions in tab order, as a restore wants them back.
2204
+ *
2205
+ * A tab whose Agent has not been adopted yet is left out: its id is the
2206
+ * `pending` placeholder or a session that was never written to the store, so
2207
+ * remembering it would only produce an entry the next launch has to discard.
2208
+ */
2209
+ openSessions() {
2210
+ return this.tabs
2211
+ .filter((tab) => tab.agent !== undefined)
2212
+ .map((tab) => ({ id: tab.id, model: tab.modelName, title: tab.title }));
2213
+ }
2214
+ /**
2215
+ * Where the tab on screen lands in {@link TuiApp.openSessions}, which is not
2216
+ * `this.active` whenever an un-adopted tab sits in front of it.
2217
+ */
2218
+ activeSessionIndex() {
2219
+ const current = this.tabs[this.active];
2220
+ if (current?.agent === undefined)
2221
+ return 0;
2222
+ const index = this.openSessions().findIndex((session) => session.id === current.id);
2223
+ return index === -1 ? 0 : index;
2224
+ }
2225
+ // ---------------------------------------------------------------- search
2226
+ /** Start (or replace) a transcript search and jump to its first match. */
2227
+ startSearch(query) {
2228
+ const trimmed = query.trim();
2229
+ if (trimmed === '') {
2230
+ this.setStatus('usage: /find <text>', true);
2231
+ this.paint();
2232
+ return;
2233
+ }
2234
+ // Searching means the conversation, not whatever help page is open.
2235
+ this.overlay = '';
2236
+ this.search = { query: trimmed, cursor: 0 };
2237
+ this.jumpToMatch(0);
2238
+ }
2239
+ /** Clear the search and return the status bar to its idle hint. */
2240
+ clearSearch() {
2241
+ if (this.search === undefined)
2242
+ return;
2243
+ this.search = undefined;
2244
+ this.setStatus('');
2245
+ }
2246
+ /**
2247
+ * Move to another match. The hits are recomputed each jump, so a reply still
2248
+ * streaming in simply adds lines to search rather than staleness. `delta`
2249
+ * wraps around the list; `0` lands on the current match.
2250
+ */
2251
+ jumpToMatch(delta) {
2252
+ const search = this.search;
2253
+ if (search === undefined)
2254
+ return;
2255
+ const snapshot = this.snapshot();
2256
+ const hits = findMatches(snapshot, search.query);
2257
+ if (hits.length === 0) {
2258
+ this.setStatus(`no matches for “${search.query}”`, true);
2259
+ this.paint();
2260
+ return;
2261
+ }
2262
+ search.cursor = ((search.cursor + delta) % hits.length + hits.length) % hits.length;
2263
+ const line = hits[search.cursor] ?? 0;
2264
+ const geometry = layout(snapshot);
2265
+ const limit = maxScrollBack(snapshot);
2266
+ // Center the hit vertically, clamped so the view cannot drift off the body.
2267
+ const start = Math.min(Math.max(line - Math.floor(geometry.viewportRows / 2), 0), limit);
2268
+ this.tab.scrollBack = limit - start;
2269
+ this.setStatus(`match ${String(search.cursor + 1)}/${String(hits.length)} · n next · N prev · esc clear`);
2270
+ }
2271
+ // -------------------------------------------------------------- clipboard
2272
+ /** The last assistant answer worth copying, or undefined when none exists. */
2273
+ lastReply() {
2274
+ for (let index = this.tab.messages.length - 1; index >= 0; index -= 1) {
2275
+ const message = this.tab.messages[index];
2276
+ if (message !== undefined && message.role === 'assistant' && messageText(message).trim() !== '') {
2277
+ return message;
2278
+ }
2279
+ }
2280
+ return undefined;
2281
+ }
2282
+ /**
2283
+ * Move the transcript selection, starting at the newest turn.
2284
+ *
2285
+ * The selection is a marker, not a scroll: the turn it marks copies with
2286
+ * `alt+c`, and `esc` clears it before anything else esc can dismiss.
2287
+ */
2288
+ moveSelection(delta) {
2289
+ const next = moveSelection(this.selectedTurn, delta, this.tab.messages.length);
2290
+ if (next === undefined) {
2291
+ this.setStatus('nothing to select yet');
2292
+ this.paint();
2293
+ return;
2294
+ }
2295
+ this.selectedTurn = next;
2296
+ const message = this.tab.messages[next];
2297
+ const what = message?.role === 'user' ? 'your prompt' : 'the reply';
2298
+ this.setStatus(`${String(next + 1)}/${String(this.tab.messages.length)}: ${what} selected · alt+c copies · esc clears`);
2299
+ this.paint();
2300
+ }
2301
+ /** Copy the selected turn's text over the same OSC 52 path as `/copy`. */
2302
+ copySelectedTurn() {
2303
+ const message = this.selectedTurn === undefined ? undefined : this.tab.messages[this.selectedTurn];
2304
+ if (message === undefined) {
2305
+ this.setStatus('nothing selected — alt+↑ or alt+↓ picks a turn');
2306
+ this.paint();
2307
+ return;
2308
+ }
2309
+ const result = this.writeClipboard(messageText(message));
2310
+ if (result.ok) {
2311
+ this.setStatus(`copied the selected turn${result.truncated ? ' (truncated)' : ''}`);
2312
+ }
2313
+ else {
2314
+ this.setStatus(`copy failed: ${result.error}`, true);
2315
+ }
2316
+ this.paint();
2317
+ }
2318
+ /**
2319
+ * Copy the last reply to the system clipboard over the OSC 52 escape, the
2320
+ * only clipboard channel a terminal owns. It needs no dependency and no
2321
+ * external process, and it works over SSH and inside tmux. The terminal
2322
+ * decides the payload ceiling, so very long answers are truncated.
2323
+ */
2324
+ copyLastReply() {
2325
+ const message = this.lastReply();
2326
+ if (message === undefined) {
2327
+ this.setStatus('nothing to copy yet — ask the harness something first', true);
2328
+ this.paint();
2329
+ return;
2330
+ }
2331
+ const result = this.writeClipboard(messageText(message));
2332
+ if (result.ok) {
2333
+ this.setStatus(`copied the last reply to the clipboard${result.truncated ? ' (truncated)' : ''}`);
2334
+ }
2335
+ else {
2336
+ this.setStatus(`copy failed: ${result.error}`, true);
2337
+ }
2338
+ this.paint();
2339
+ }
2340
+ /**
2341
+ * Put text on the system clipboard over OSC 52.
2342
+ *
2343
+ * Shared by `/copy` and the fleet overview, because the escape and its
2344
+ * payload ceiling are the same problem in both places.
2345
+ */
2346
+ writeClipboard(text) {
2347
+ const encoded = Buffer.from(text, 'utf8').toString('base64');
2348
+ // Cap the payload on the encoded form, kept a multiple of four so it stays
2349
+ // decodable; slicing the content instead would split a surrogate pair and
2350
+ // still overshoot the ceiling by base64's 33% expansion.
2351
+ const cap = 100_000 - (100_000 % 4);
2352
+ const clipped = encoded.length <= cap ? encoded : encoded.slice(0, cap);
2353
+ try {
2354
+ // `ESC ] 52 ; c ; <base64> ST` — the standard form every mainstream
2355
+ // terminal accepts. Written directly, then the next paint redraws.
2356
+ process.stdout.write(`\u001b]52;c;${clipped}\u001b\\`);
2357
+ // OSC 52 is the only channel that survives SSH and tmux, so it is always
2358
+ // written. It is not sufficient on its own: a Wayland compositor only
2359
+ // lets a window own the clipboard while it holds an input-focus serial,
2360
+ // so the escape can be well-formed, accepted by the terminal, and still
2361
+ // leave the clipboard untouched. Where a local helper exists it is what
2362
+ // actually lands, so it is used as well — same text, last writer wins.
2363
+ copyWithLocalHelper(text);
2364
+ return { ok: true, truncated: encoded.length > cap };
2365
+ }
2366
+ catch (error) {
2367
+ return { ok: false, truncated: false, error: describeError(error) };
2368
+ }
2369
+ }
2370
+ /**
2371
+ * Write the transcript of the active session to a markdown file.
2372
+ *
2373
+ * Without an argument the file lands in the working directory the session
2374
+ * started in, named after the moment: `dsh-transcript-20250101-120000.md`.
2375
+ */
2376
+ async exportTranscript(requested) {
2377
+ const fs = this.ctx.get('fs');
2378
+ const base = fs === undefined ? process.cwd() : fs.processPath(await fs.resolve('.'));
2379
+ const file = requested === ''
2380
+ ? join(base, `dsh-transcript-${timestampForFile()}.md`)
2381
+ : isAbsolute(requested)
2382
+ ? requested
2383
+ : resolve(base, requested);
2384
+ const markdown = transcriptMarkdown(this.tab.messages, this.tab.title);
2385
+ try {
2386
+ await writeFile(file, markdown, 'utf8');
2387
+ this.setStatus(`exported to ${file}`);
2388
+ }
2389
+ catch (error) {
2390
+ this.setStatus(`export failed: ${describeError(error)}`, true);
2391
+ }
2392
+ this.paint();
2393
+ }
2394
+ /**
2395
+ * `/update`: check npm for a newer release and install it globally.
2396
+ *
2397
+ * The check follows the configured registry (`NPM_CONFIG_REGISTRY`), so
2398
+ * mirror users see the versions their package manager actually installs.
2399
+ * Restarting is left to the user — sessions reopen on launch — because a
2400
+ * live self-restart would abandon the terminal the app is drawing in.
2401
+ */
2402
+ async selfUpdate() {
2403
+ const packageName = 'moqi';
2404
+ const registry = process.env['NPM_CONFIG_REGISTRY'] ?? 'https://registry.npmjs.org';
2405
+ this.setStatus('checking npm for a newer version…');
2406
+ this.paint();
2407
+ let latest;
2408
+ try {
2409
+ const response = await fetch(`${registry.replace(/\/+$/, '')}/${packageName}`);
2410
+ if (!response.ok)
2411
+ throw new Error(`registry answered ${String(response.status)}`);
2412
+ const body = (await response.json());
2413
+ if (typeof body['dist-tags']?.latest !== 'string')
2414
+ throw new Error('no latest tag');
2415
+ latest = body['dist-tags'].latest;
2416
+ }
2417
+ catch (error) {
2418
+ this.setStatus(`update check failed: ${describeError(error)}`, true);
2419
+ this.paint();
2420
+ return;
2421
+ }
2422
+ if (latest === VERSION) {
2423
+ this.setStatus(`already the latest version (${VERSION})`);
2424
+ this.paint();
2425
+ return;
2426
+ }
2427
+ this.setStatus(`updating to ${latest}…`);
2428
+ this.paint();
2429
+ const code = await new Promise((resolve) => {
2430
+ const child = spawn('npm', ['install', '-g', `${packageName}@${latest}`], {
2431
+ stdio: 'ignore',
2432
+ });
2433
+ child.on('error', () => resolve(1));
2434
+ child.on('exit', (exitCode) => resolve(exitCode ?? 1));
2435
+ });
2436
+ this.setStatus(code === 0
2437
+ ? `updated to ${latest} — restart dsh; your sessions reopen`
2438
+ : 'update failed — installed from a checkout? git pull and rebuild instead', code !== 0);
2439
+ this.paint();
2440
+ }
2441
+ /** Show version and connection details in the transcript pane. */
2442
+ showAbout() {
2443
+ this.showOverlay([
2444
+ '**moqi**',
2445
+ '',
2446
+ `- version \`${VERSION}\``,
2447
+ `- profile \`tui\` · host \`${hostLabel(process.env['DSH_HOST'] ?? 'local harness')}\``,
2448
+ `- model \`${this.tab.modelName}\``,
2449
+ '',
2450
+ 'Moqi — the unspoken understanding between you and your harness.',
2451
+ 'See the README for the full key and command reference.',
2452
+ ].join('\n'), 'esc to close');
2453
+ }
2454
+ // --------------------------------------------------------------- behaviors
2455
+ /**
2456
+ * Send a prompt and stream the reply into the transcript.
2457
+ *
2458
+ * A thin wrapper over {@link sendTo} pinned to the session on screen; the
2459
+ * queue-draining follow-up needs the tab-explicit form, because it must
2460
+ * keep writing to the conversation that queued the prompt even if the user
2461
+ * has since switched sessions.
2462
+ */
2463
+ /**
2464
+ * Collect the composer into a prompt: strip `[Image #N]` tokens and pair
2465
+ * them with their staged references. Staged images the draft no longer
2466
+ * names are dropped — deleting a token deletes the attachment.
2467
+ */
2468
+ materializePrompt() {
2469
+ const extracted = extractImageTokens(this.composer.value());
2470
+ const wanted = new Set(extracted.numbers);
2471
+ const images = this.stagedImages
2472
+ .filter((staged) => wanted.has(staged.n))
2473
+ .map((staged) => staged.ref);
2474
+ this.stagedImages = this.stagedImages.filter((staged) => wanted.has(staged.n));
2475
+ return { text: extracted.text.trim(), images };
2476
+ }
2477
+ /**
2478
+ * Steer a prompt into the turn that is still running.
2479
+ *
2480
+ * The follow-up is delivered to the agent mid-turn and lands at its next
2481
+ * step boundary; nothing is queued and nothing waits. The transcript shows
2482
+ * it dimmed, marked as steered, so the interleaving reads honestly.
2483
+ */
2484
+ async steer(prompt) {
2485
+ const agent = this.tab.agent;
2486
+ if (agent === undefined)
2487
+ return;
2488
+ this.tab.messages.push(textMessage('user', prompt.text, {
2489
+ steering: true,
2490
+ attachments: prompt.images.map((ref) => ({
2491
+ name: ref.name ?? 'image',
2492
+ width: ref.width,
2493
+ height: ref.height,
2494
+ })),
2495
+ }));
2496
+ this.scrollToBottom();
2497
+ this.setStatus('steered into the running turn');
2498
+ this.paint();
2499
+ try {
2500
+ agent.followup(createUserMessage({
2501
+ content: promptBlocks(prompt),
2502
+ source: { kind: 'user' },
2503
+ }));
2504
+ }
2505
+ catch (error) {
2506
+ this.setStatus(describeError(error), true);
2507
+ this.paint();
2508
+ }
2509
+ }
2510
+ async send(text) {
2511
+ await this.sendTo(this.tab, { text, images: [] });
2512
+ }
2513
+ /**
2514
+ * Round-trip the draft through `$VISUAL`/`$EDITOR`.
2515
+ *
2516
+ * The screen leaves the alternate buffer for the duration — the editor owns
2517
+ * the terminal — and comes back with the frame repainted. A non-zero exit
2518
+ * keeps the draft untouched (the `:cq` convention), and neither variable
2519
+ * being set is a status line, not a `vi` fallback nobody asked for.
2520
+ */
2521
+ async editDraft() {
2522
+ const editor = process.env['VISUAL'] ?? process.env['EDITOR'];
2523
+ if (editor === undefined || editor.trim() === '') {
2524
+ this.setStatus('set $VISUAL or $EDITOR to edit the draft outside');
2525
+ this.paint();
2526
+ return;
2527
+ }
2528
+ const file = join(tmpdir(), `moqi-draft-${String(process.pid)}.md`);
2529
+ await writeFile(file, `${this.composer.value()}\n`);
2530
+ this.screen.stop();
2531
+ try {
2532
+ const code = await new Promise((resolve, reject) => {
2533
+ const child = spawn(editor, [file], { stdio: 'inherit' });
2534
+ child.on('error', reject);
2535
+ child.on('exit', (exitCode) => {
2536
+ resolve(exitCode ?? 0);
2537
+ });
2538
+ });
2539
+ if (code === 0) {
2540
+ const edited = (await readFile(file, 'utf8')).replace(/\n$/, '');
2541
+ this.composer.setValue(edited);
2542
+ this.history.reset();
2543
+ this.atMenu.close();
2544
+ }
2545
+ else {
2546
+ this.setStatus('editor exited non-zero — draft kept');
2547
+ }
2548
+ }
2549
+ catch (error) {
2550
+ this.setStatus(describeError(error), true);
2551
+ }
2552
+ finally {
2553
+ await unlink(file).catch(() => { });
2554
+ this.screen.start();
2555
+ this.updateAtMenu();
2556
+ this.paint();
2557
+ }
2558
+ }
2559
+ /** Send a materialized prompt to the active session. */
2560
+ async sendPrompt(prompt) {
2561
+ await this.sendTo(this.tab, prompt);
2562
+ }
2563
+ /**
2564
+ * Send a prompt to one specific session and stream the reply.
2565
+ *
2566
+ * `fromQueue` marks a prompt that was already recorded in the composer
2567
+ * history at the moment it was queued, so the drain must not record it a
2568
+ * second time (recall would then surface it twice).
2569
+ */
2570
+ async sendTo(tab, prompt, fromQueue = false) {
2571
+ const agent = tab.agent;
2572
+ if (agent === undefined)
2573
+ return;
2574
+ const text = prompt.text;
2575
+ this.overlay = '';
2576
+ // Only follow the newest output when the queued conversation is the one
2577
+ // on screen; a backgrounded session must not yank the view around.
2578
+ if (this.tabs[this.active] === tab)
2579
+ this.scrollToBottom();
2580
+ if (!fromQueue) {
2581
+ this.history.add(text);
2582
+ this.persistSoon();
2583
+ }
2584
+ tab.messages.push(textMessage('user', text, {
2585
+ attachments: prompt.images.length === 0
2586
+ ? undefined
2587
+ : prompt.images.map((ref) => ({
2588
+ name: ref.name ?? 'image',
2589
+ width: ref.width,
2590
+ height: ref.height,
2591
+ })),
2592
+ }));
2593
+ if (tab.title === '') {
2594
+ tab.title = text.slice(0, 60);
2595
+ // A tab restored with no title reads as "new session" in the bar, which
2596
+ // is exactly the wrong label for a conversation that already has one.
2597
+ this.persistSoon();
2598
+ }
2599
+ tab.streaming = true;
2600
+ tab.streamStartedAt = Date.now();
2601
+ tab.turnStartTokens = tab.completionTokens;
2602
+ tab.drainQueue = false;
2603
+ tab.streamingSegments = [];
2604
+ tab.streamingReasoning = '';
2605
+ // Tool results land in the session log between model streams, where no
2606
+ // frame fires; the sync reads them from here on. Events before this point
2607
+ // belong to earlier turns and must not color this one's rows.
2608
+ tab.logSyncedSeq = tab.agent === undefined ? 0 : tab.agent.session.seq;
2609
+ this.setSessionStatus(tab, 'running');
2610
+ this.setStatus('');
2611
+ if (tab.queued.length > 0) {
2612
+ // More still waiting behind this one: say so, or the queue silently
2613
+ // draining looks like prompts disappearing.
2614
+ this.setStatus(`${String(tab.queued.length)} queued — sends when the reply finishes`);
2615
+ }
2616
+ this.startSpinner();
2617
+ this.paint();
2618
+ const abort = new AbortController();
2619
+ this.abort = abort;
2620
+ try {
2621
+ agent.followup(createUserMessage({ content: promptBlocks(prompt), source: { kind: 'user' } }));
2622
+ await agent.whenIdle();
2623
+ }
2624
+ catch (error) {
2625
+ this.setStatus(describeError(error), true);
2626
+ }
2627
+ finally {
2628
+ this.abort = undefined;
2629
+ const turnSeconds = (Date.now() - tab.streamStartedAt) / 1000;
2630
+ const turnOutput = tab.completionTokens - tab.turnStartTokens;
2631
+ tab.tps = turnSeconds > 0 && turnOutput > 0 ? turnOutput / turnSeconds : 0;
2632
+ tab.streaming = false;
2633
+ tab.streamStartedAt = 0;
2634
+ this.releaseSpinner();
2635
+ // Commit whatever streamed, even on an interrupt, so nothing is lost.
2636
+ // One last log sync first: the final tool results may have landed after
2637
+ // the last frame, and the committed rows are what /export and a restart
2638
+ // will show.
2639
+ this.syncToolLog(tab);
2640
+ const reasoning = tab.streamingReasoning.trim();
2641
+ // The turn commits with its order intact — the same segments that were
2642
+ // on screen while it streamed, so nothing rearranges itself once it
2643
+ // settles.
2644
+ const segments = tab.streamingSegments.filter((segment) => segment.kind === 'tool' || segment.text.trim() !== '');
2645
+ if (segments.length > 0 || reasoning !== '') {
2646
+ tab.messages.push({
2647
+ role: 'assistant',
2648
+ segments,
2649
+ reasoning: reasoning === '' ? undefined : reasoning,
2650
+ });
2651
+ }
2652
+ tab.streamingSegments = [];
2653
+ tab.streamingReasoning = '';
2654
+ // The answer is in: ring unless it is already on screen.
2655
+ this.setSessionStatus(tab, 'ready');
2656
+ void this.flush(tab);
2657
+ // An interrupted turn must not launch the next prompt unbidden: the
2658
+ // user asked for silence, so the queue waits for a clean finish —
2659
+ // unless the interrupt was the redirection kind (`/interrupt`), which
2660
+ // stops this answer precisely so the queue can carry on. The follow-up
2661
+ // is fire-and-forget like every other send call site — awaiting it here
2662
+ // would stack one frame per queued prompt.
2663
+ const drain = queueShouldDrain({
2664
+ interrupted: abort.signal.aborted,
2665
+ drainRequested: tab.drainQueue,
2666
+ });
2667
+ tab.drainQueue = false;
2668
+ if (drain && tab.queued.length > 0) {
2669
+ const next = tab.queued.shift();
2670
+ if (next !== undefined) {
2671
+ void this.sendTo(tab, next, true);
2672
+ this.paint();
2673
+ return;
2674
+ }
2675
+ }
2676
+ if (!drain && tab.queued.length > 0 && this.tabs[this.active] === tab) {
2677
+ this.setStatus(`${String(tab.queued.length)} queued — kept after the interrupt · /interrupt runs them`);
2678
+ }
2679
+ this.paint();
2680
+ }
2681
+ }
2682
+ /** Persist the session, ignoring a backend that does not support it. */
2683
+ async flush(tab = this.tab) {
2684
+ const sessions = this.ctx.get('sessions');
2685
+ const agent = tab.agent;
2686
+ if (sessions === undefined || agent === undefined)
2687
+ return;
2688
+ try {
2689
+ await sessions.flush(agent.session);
2690
+ }
2691
+ catch {
2692
+ // A profile without persistence is a valid configuration.
2693
+ }
2694
+ }
2695
+ /** Ask the running turn to stop. */
2696
+ interrupt() {
2697
+ this.abort?.abort();
2698
+ const agent = this.tab.agent;
2699
+ try {
2700
+ agent?.interrupt?.();
2701
+ agent?.abort?.();
2702
+ this.setStatus('interrupting…');
2703
+ }
2704
+ catch (error) {
2705
+ this.setStatus(describeError(error), true);
2706
+ }
2707
+ }
2708
+ /**
2709
+ * Name the active session (`/rename <title>`), or with no argument ask the
2710
+ * title service to regenerate the automatic one. A rename pins the title in
2711
+ * the session's own durable log — a `session/title` event with the `user`
2712
+ * source — so every surface that reads titles agrees: this tab, the resume
2713
+ * picker, and any other dsh client of the same session. The tab label and
2714
+ * the restore cache follow immediately.
2715
+ */
2716
+ async renameSession(rawInput) {
2717
+ const plan = planRename(rawInput);
2718
+ const tab = this.tab;
2719
+ const agent = tab.agent;
2720
+ if (agent === undefined) {
2721
+ this.setStatus('this session has no agent yet', true);
2722
+ this.paint();
2723
+ return;
2724
+ }
2725
+ const titles = this.ctx.get('sessionTitle');
2726
+ if (titles?.rename === undefined || titles.refresh === undefined) {
2727
+ this.setStatus('this profile has no session title service', true);
2728
+ this.paint();
2729
+ return;
2730
+ }
2731
+ try {
2732
+ if (plan.kind === 'pin') {
2733
+ const snapshot = titles.rename(agent.session, plan.title);
2734
+ // The service normalized the text; the tab label keeps the same
2735
+ // 60-character budget as the automatic first-prompt title.
2736
+ const title = (snapshotTitle(snapshot) ?? plan.title).slice(0, 60);
2737
+ tab.title = title;
2738
+ this.persistSoon();
2739
+ this.setStatus(`renamed → ${title}`);
2740
+ }
2741
+ else {
2742
+ const snapshot = await titles.refresh(agent.session);
2743
+ const title = snapshotTitle(snapshot);
2744
+ if (title === undefined) {
2745
+ this.setStatus('no automatic title yet — send something first');
2746
+ }
2747
+ else {
2748
+ tab.title = title.slice(0, 60);
2749
+ this.persistSoon();
2750
+ this.setStatus(`title → ${tab.title}`);
2751
+ }
2752
+ }
2753
+ }
2754
+ catch (error) {
2755
+ this.setStatus(describeError(error), true);
2756
+ }
2757
+ this.paint();
2758
+ }
2759
+ /** Run a slash command: this app's own first, then the Harness registry. */
2760
+ async runCommand(name, rawInput) {
2761
+ this.tab.scrollBack = 0;
2762
+ switch (name) {
2763
+ case 'new':
2764
+ await this.newSession();
2765
+ return;
2766
+ case 'resume':
2767
+ await this.showSessions();
2768
+ return;
2769
+ case 'delete':
2770
+ await this.showSessions('delete');
2771
+ return;
2772
+ case 'rename':
2773
+ await this.renameSession(rawInput);
2774
+ return;
2775
+ case 'sessions':
2776
+ this.showOpenSessions();
2777
+ return;
2778
+ case 'close':
2779
+ this.closeSession(this.active);
2780
+ return;
2781
+ case 'model': {
2782
+ if (rawInput.trim() === '')
2783
+ await this.showModels();
2784
+ else
2785
+ await this.selectModelByName(rawInput.trim());
2786
+ return;
2787
+ }
2788
+ case 'theme': {
2789
+ if (rawInput.trim() === '')
2790
+ this.showThemes();
2791
+ else
2792
+ this.selectTheme(rawInput.trim());
2793
+ return;
2794
+ }
2795
+ case 'plugins':
2796
+ this.handlePluginsInput(rawInput.trim());
2797
+ return;
2798
+ case 'thinking':
2799
+ this.showThinking = !this.showThinking;
2800
+ this.persistSoon();
2801
+ this.setStatus(this.showThinking ? 'showing reasoner thinking' : 'hiding reasoner thinking');
2802
+ this.paint();
2803
+ return;
2804
+ case 'find': {
2805
+ const words = rawInput.trim().split(/\s+/).filter((word) => word !== '');
2806
+ if (words[0] === '--sessions' || words[0] === '-s') {
2807
+ this.searchStoredSessions(words.slice(1).join(' '));
2808
+ return;
2809
+ }
2810
+ this.startSearch(rawInput);
2811
+ return;
2812
+ }
2813
+ case 'unqueue': {
2814
+ const count = this.tab.queued.length;
2815
+ this.tab.queued = [];
2816
+ this.setStatus(count === 0
2817
+ ? 'nothing queued'
2818
+ : `cleared ${String(count)} queued message${count === 1 ? '' : 's'}`);
2819
+ this.paint();
2820
+ return;
2821
+ }
2822
+ case 'rewind':
2823
+ this.showRewind();
2824
+ return;
2825
+ case 'fork':
2826
+ void this.performFork();
2827
+ return;
2828
+ case 'tree':
2829
+ void this.showTree();
2830
+ return;
2831
+ case 'jobs':
2832
+ this.showJobs(rawInput);
2833
+ return;
2834
+ case 'mcp':
2835
+ this.showMcp();
2836
+ return;
2837
+ case 'dispatch': {
2838
+ const words = rawInput.trim().split(/\s+/).filter((word) => word !== '');
2839
+ const device = words.shift();
2840
+ const task = words.join(' ');
2841
+ if (device === undefined || task === '') {
2842
+ this.setStatus('usage: /dispatch <device> <task>', true);
2843
+ this.paint();
2844
+ return;
2845
+ }
2846
+ void this.dispatchTo(device, task);
2847
+ return;
2848
+ }
2849
+ case 'vim': {
2850
+ this.vim.setEnabled(!this.vim.enabled);
2851
+ this.setStatus(this.vim.enabled
2852
+ ? 'vim keys on — esc leaves insert, ctrl+c still interrupts'
2853
+ : 'vim keys off');
2854
+ this.paint();
2855
+ return;
2856
+ }
2857
+ case 'lang': {
2858
+ const wanted = rawInput.trim();
2859
+ if (wanted === '') {
2860
+ this.picker.show('lang', 'Language', LANGS.map((entry) => ({
2861
+ id: entry.id,
2862
+ title: entry.label,
2863
+ subtitle: entry.id,
2864
+ active: entry.id === currentLanguage(),
2865
+ })));
2866
+ this.setStatus('');
2867
+ }
2868
+ else if (isLang(wanted)) {
2869
+ this.selectLanguage(wanted);
2870
+ }
2871
+ else {
2872
+ this.setStatus(`unknown language ${wanted} — try en or zh-CN`, true);
2873
+ }
2874
+ this.paint();
2875
+ return;
2876
+ }
2877
+ case 'interrupt': {
2878
+ // Two ways to stop a reply. `esc` is a bid for silence: the queue
2879
+ // freezes until the user sends again. `/interrupt` is a redirection:
2880
+ // stop this answer, then push the queued prompts into the loop.
2881
+ if (!this.tab.streaming) {
2882
+ this.setStatus('nothing is streaming');
2883
+ this.paint();
2884
+ return;
2885
+ }
2886
+ this.tab.drainQueue = true;
2887
+ this.setStatus(this.tab.queued.length > 0
2888
+ ? `interrupting — ${String(this.tab.queued.length)} queued will run`
2889
+ : 'interrupting…');
2890
+ this.interrupt();
2891
+ return;
2892
+ }
2893
+ case 'copy':
2894
+ this.copyLastReply();
2895
+ return;
2896
+ case 'export':
2897
+ await this.exportTranscript(rawInput.trim());
2898
+ return;
2899
+ case 'fleet':
2900
+ this.openFleet();
2901
+ return;
2902
+ case 'peer': {
2903
+ const [verb = '', ...rest] = rawInput.trim().split(/\s+/);
2904
+ const host = rest.join(' ').trim();
2905
+ if (verb === '') {
2906
+ this.setStatus(this.peers.length === 0
2907
+ ? 'no devices yet — /peer add <host>, or press a in the fleet'
2908
+ : `fleet devices: ${this.peers.join(', ')}`);
2909
+ }
2910
+ else if (verb === 'add') {
2911
+ if (this.addPeer(host))
2912
+ this.setStatus(`added ${host} — ctrl+f to see it`);
2913
+ }
2914
+ else if (verb === 'rm' || verb === 'remove') {
2915
+ this.setStatus(this.removePeer(host) ? `removed ${host}` : `${host} is not in the fleet`, !this.peers.includes(host) && host === '');
2916
+ }
2917
+ else {
2918
+ this.setStatus('usage: /peer [add|rm <host>]', true);
2919
+ }
2920
+ this.paint();
2921
+ return;
2922
+ }
2923
+ case 'about':
2924
+ this.showAbout();
2925
+ return;
2926
+ case 'update':
2927
+ void this.selfUpdate();
2928
+ return;
2929
+ case 'tools': {
2930
+ const tools = this.ctx.get('tools');
2931
+ let listed = [];
2932
+ try {
2933
+ const raw = tools?.list?.() ?? [];
2934
+ listed = raw.map((tool) => String(tool.name ?? '')).filter((tool) => tool !== '');
2935
+ }
2936
+ catch {
2937
+ listed = [];
2938
+ }
2939
+ this.showOverlay(listed.length === 0
2940
+ ? '**Tools**\n\nThis profile exposes no tool registry to the app.'
2941
+ : `**Tools**\n\n${listed.map((tool) => `- \`${tool}\``).join('\n')}`, 'esc to close');
2942
+ return;
2943
+ }
2944
+ case 'help':
2945
+ this.showOverlay(keyReference(), 'esc to close help');
2946
+ return;
2947
+ case 'exit':
2948
+ case 'quit':
2949
+ this.quit();
2950
+ return;
2951
+ default:
2952
+ break;
2953
+ }
2954
+ // Anything else belongs to the Harness command registry.
2955
+ const registry = this.ctx.get('commands');
2956
+ const agent = this.tab.agent;
2957
+ if (registry === undefined || agent === undefined) {
2958
+ this.setStatus(`unknown command /${name} — type / to see them`, true);
2959
+ this.paint();
2960
+ return;
2961
+ }
2962
+ const line = rawInput.trim() === '' ? `/${name}` : `/${name} ${rawInput}`;
2963
+ this.setStatus(`running /${name}…`);
2964
+ this.paint();
2965
+ try {
2966
+ // execute() takes a required AbortSignal and reads .aborted on it.
2967
+ // Passing undefined threw "Cannot read properties of undefined" out of
2968
+ // the registry, which surfaced as every Harness command failing --
2969
+ // /permission, /plan, /goal -- with an error that named nothing useful.
2970
+ const controller = new AbortController();
2971
+ const execution = await registry.execute(agent, line, [], controller.signal);
2972
+ if (execution === undefined) {
2973
+ this.setStatus(`unknown command /${name} — type / to see them`, true);
2974
+ this.paint();
2975
+ return;
2976
+ }
2977
+ const result = execution.result ?? execution;
2978
+ const okResult = String(result.kind ?? 'success') === 'success';
2979
+ const text = String(result.text ?? '');
2980
+ this.tab.messages.push(textMessage('assistant', text === '' ? (okResult ? 'done' : 'failed') : text, {
2981
+ command: { name, ok: okResult },
2982
+ }));
2983
+ this.setStatus(okResult ? '' : `/${name} failed`, !okResult);
2984
+ // A command can rewrite history (compaction does), so re-read it.
2985
+ if (this.tab.agent !== undefined)
2986
+ this.refreshFromSession();
2987
+ void this.flush();
2988
+ }
2989
+ catch (error) {
2990
+ this.setStatus(describeError(error), true);
2991
+ }
2992
+ this.paint();
2993
+ }
2994
+ /** Replace the transcript with a fresh agent and session. */
2995
+ async newSession() {
2996
+ const agents = this.ctx.get('agents');
2997
+ const defaultModel = this.ctx.get('agentDefaultModel');
2998
+ if (agents === undefined || defaultModel === undefined)
2999
+ return;
3000
+ this.setStatus('starting a new session\u2026');
3001
+ this.paint();
3002
+ // A new session starts from whatever model the session it was opened from
3003
+ // is using — it should not silently revert to the stored default the user
3004
+ // just switched away from — but with its OWN selection ref, so later
3005
+ // switches in either conversation leave the other alone.
3006
+ const seed = this.tab.selection.current ?? defaultModel.currentSelection();
3007
+ const fs = this.ctx.get('fs');
3008
+ const cwd = fs === undefined ? process.cwd() : fs.processPath(await fs.resolve('.'));
3009
+ const sessionId = brandString(`session-${randomUUID()}`);
3010
+ const tab = newTab(String(sessionId));
3011
+ tab.selection.current = seed;
3012
+ tab.modelName = String(seed.model);
3013
+ try {
3014
+ const created = await agents.create({
3015
+ sessionId,
3016
+ meta: { cwd },
3017
+ agentOptions: { provider: seed.provider, model: seed.model },
3018
+ setup: this.selectionSetupFor(tab),
3019
+ });
3020
+ await created.agent.whenIdle();
3021
+ // A new session is an additional one: the conversation that was open
3022
+ // keeps running, and its reply will still arrive and ring.
3023
+ tab.agent = created.agent;
3024
+ this.adoptForeground(tab);
3025
+ this.tabs.push(tab);
3026
+ this.active = this.tabs.length - 1;
3027
+ this.persistSoon();
3028
+ this.overlay = '';
3029
+ this.composer.reset();
3030
+ this.setStatus(this.tabs.length > 1 ? `session ${String(this.tabs.length)}` : 'new session');
3031
+ this.screen.invalidate();
3032
+ void this.refreshContextLimit(tab);
3033
+ }
3034
+ catch (error) {
3035
+ this.setStatus(describeError(error), true);
3036
+ }
3037
+ this.paint();
3038
+ }
3039
+ /**
3040
+ * `/rewind` — pick an earlier prompt, then redo it in a forked session.
3041
+ *
3042
+ * The fork keeps every completed turn before that prompt, and the prompt
3043
+ * itself returns to the composer for editing: the original session is never
3044
+ * touched, so nothing is lost by trying a different direction.
3045
+ */
3046
+ showRewind() {
3047
+ const session = this.tab.agent?.session;
3048
+ if (session === undefined) {
3049
+ this.setStatus('no session to rewind', true);
3050
+ this.paint();
3051
+ return;
3052
+ }
3053
+ const turns = projectUserTurns(sessionEvents(session));
3054
+ const items = [];
3055
+ turns.forEach((turn, index) => {
3056
+ const first = turn.text.split('\n')[0] ?? '';
3057
+ items.push({
3058
+ id: String(index),
3059
+ title: first.length > 60 ? `${first.slice(0, 59)}…` : first,
3060
+ subtitle: index === 0
3061
+ ? 'first prompt — cannot rewind past it'
3062
+ : `${String(index + 1)} of ${String(turns.length)}`,
3063
+ });
3064
+ });
3065
+ if (items.length === 0) {
3066
+ this.setStatus('nothing to rewind yet');
3067
+ this.paint();
3068
+ return;
3069
+ }
3070
+ this.picker.show('rewind', 'Rewind to a prompt', items);
3071
+ this.setStatus('enter forks the session at that point · esc cancels');
3072
+ this.paint();
3073
+ }
3074
+ /** Fork at the chosen prompt's turn boundary and restore it to the composer. */
3075
+ async performRewind(chosenIndex) {
3076
+ const session = this.tab.agent?.session;
3077
+ if (session === undefined)
3078
+ return;
3079
+ const target = rewindTarget(projectUserTurns(sessionEvents(session)), chosenIndex);
3080
+ if (target === undefined) {
3081
+ this.setStatus('cannot rewind past the first prompt — /fork copies the whole conversation');
3082
+ this.paint();
3083
+ return;
3084
+ }
3085
+ await this.interruptAndSettle();
3086
+ const forked = await this.forkAt(target.cutSeq);
3087
+ if (forked === undefined)
3088
+ return;
3089
+ this.composer.setValue(target.text);
3090
+ this.history.reset();
3091
+ this.setStatus(`rewound — edit the prompt and press enter (${String(target.cutSeq)} events kept)`);
3092
+ this.paint();
3093
+ }
3094
+ /** `/fork` — copy the whole conversation into a twin. */
3095
+ async performFork() {
3096
+ const session = this.tab.agent?.session;
3097
+ if (session === undefined)
3098
+ return;
3099
+ const cut = forkCut(sessionEvents(session), session.seq);
3100
+ if (cut === 0) {
3101
+ this.setStatus('nothing to fork yet — send a prompt first');
3102
+ this.paint();
3103
+ return;
3104
+ }
3105
+ await this.interruptAndSettle();
3106
+ const forked = await this.forkAt(cut);
3107
+ if (forked !== undefined) {
3108
+ this.setStatus(`forked — ${String(cut)} events inherited, the original is untouched`);
3109
+ this.paint();
3110
+ }
3111
+ }
3112
+ /**
3113
+ * Create a new session seeded with the current log's first `cut` events.
3114
+ *
3115
+ * The Harness validates the seed at its own boundary: a prefix that ends
3116
+ * mid-turn or carries a dangling tool call is rejected there, which is why
3117
+ * the callers compute turn boundaries rather than guessing.
3118
+ */
3119
+ async forkAt(cut) {
3120
+ const agents = this.ctx.get('agents');
3121
+ const session = this.tab.agent?.session;
3122
+ if (agents === undefined || session === undefined)
3123
+ return undefined;
3124
+ const seed = session.snapshotEvents(SessionLogOffset(0), SessionLogOffset(cut));
3125
+ const carried = this.tab.selection.current;
3126
+ const sessionId = brandString(`session-${randomUUID()}`);
3127
+ const tab = newTab(String(sessionId));
3128
+ if (carried !== undefined) {
3129
+ tab.selection.current = carried;
3130
+ tab.modelName = String(carried.model);
3131
+ }
3132
+ try {
3133
+ const created = await agents.create({
3134
+ sessionId,
3135
+ meta: { cwd: this.cwd, parentSession: session.header.id, isSeeded: true },
3136
+ inheritedEventCount: SessionLogOffset(cut),
3137
+ seed,
3138
+ agentOptions: carried === undefined
3139
+ ? undefined
3140
+ : { provider: carried.provider, model: carried.model },
3141
+ setup: this.selectionSetupFor(tab),
3142
+ });
3143
+ tab.agent = created.agent;
3144
+ this.adoptForeground(tab);
3145
+ this.tabs.push(tab);
3146
+ this.active = this.tabs.length - 1;
3147
+ tab.messages = readHistory(created.agent.session);
3148
+ this.persistSoon();
3149
+ this.overlay = '';
3150
+ this.screen.invalidate();
3151
+ void this.refreshContextLimit(tab);
3152
+ return tab;
3153
+ }
3154
+ catch (error) {
3155
+ this.setStatus(`fork failed: ${describeError(error)}`, true);
3156
+ this.paint();
3157
+ return undefined;
3158
+ }
3159
+ }
3160
+ /** `/tree` — the fork family this session belongs to, oldest first. */
3161
+ async showTree() {
3162
+ const open = this.tabs
3163
+ .filter((tab) => tab.agent !== undefined)
3164
+ .map((tab) => ({ id: tab.id, title: tab.title, parentSession: parentOf(tab.agent?.session) }));
3165
+ const query = this.ctx.get('sessionQuery');
3166
+ let stored = [];
3167
+ if (query !== undefined) {
3168
+ try {
3169
+ stored = (await listSessionsWithParents(query)).map((row) => ({
3170
+ id: row.id,
3171
+ title: row.title,
3172
+ parentSession: row.parentSession,
3173
+ }));
3174
+ }
3175
+ catch {
3176
+ stored = [];
3177
+ }
3178
+ }
3179
+ const known = [...open, ...stored];
3180
+ const path = lineage(known, this.tab.id);
3181
+ const lines = path.map((node, index) => {
3182
+ const title = node.title === undefined || node.title === '' ? node.id : node.title;
3183
+ const mark = node.id === this.tab.id ? '●' : '○';
3184
+ return `${' '.repeat(index)}${mark} ${title}`;
3185
+ });
3186
+ const children = known.filter((node) => node.parentSession === this.tab.id);
3187
+ this.showOverlay([
3188
+ '**Session tree**',
3189
+ '',
3190
+ ...(lines.length > 0 ? lines : ['(no lineage recorded)']),
3191
+ '',
3192
+ children.length === 0
3193
+ ? 'No forks of this session yet — `/rewind` or `/fork` creates one.'
3194
+ : `Forks of this session: ${String(children.length)}`,
3195
+ ].join('\n'), 'esc to close');
3196
+ }
3197
+ /**
3198
+ * `/find --sessions <text>` — search every stored session on this machine.
3199
+ *
3200
+ * The store is read-only here: hits are shown in a picker and opening one
3201
+ * resumes that session, so a search can never disturb a log another process
3202
+ * is still appending to.
3203
+ */
3204
+ searchStoredSessions(query) {
3205
+ if (query.trim() === '') {
3206
+ this.setStatus('usage: /find --sessions <text>', true);
3207
+ this.paint();
3208
+ return;
3209
+ }
3210
+ this.setStatus('searching stored sessions…');
3211
+ this.paint();
3212
+ const result = searchSessions(sessionsRoot(), query);
3213
+ if (result.hits.length === 0) {
3214
+ const note = result.skippedCompressed > 0
3215
+ ? ` (${String(result.skippedCompressed)} compressed logs need a newer Node)`
3216
+ : '';
3217
+ this.setStatus(`no stored session mentions that${note}`, true);
3218
+ this.paint();
3219
+ return;
3220
+ }
3221
+ this.storedHits = result.hits;
3222
+ this.picker.show('stored', `Stored sessions: ${String(result.hits.length)} hits in ${String(result.scanned)} logs`, result.hits.map((hit, index) => ({
3223
+ id: String(index),
3224
+ title: hit.line,
3225
+ subtitle: `${hit.role === 'user' ? 'you' : 'model'} · ${hit.project} · ${hit.sessionId.slice(-8)}`,
3226
+ })));
3227
+ this.setStatus('enter opens that session · esc cancels');
3228
+ this.paint();
3229
+ }
3230
+ /** Resume the session a stored-search hit came from. */
3231
+ async openStoredHit(item) {
3232
+ const hit = this.storedHits[Number.parseInt(item.id, 10)];
3233
+ if (hit === undefined)
3234
+ return;
3235
+ await this.openSession(hit.sessionId, hit.project);
3236
+ }
3237
+ /** Switch the interface language and remember it across restarts. */
3238
+ selectLanguage(lang) {
3239
+ setLanguage(lang);
3240
+ this.persisted.lang = lang;
3241
+ this.persistSoon();
3242
+ this.screen.invalidate();
3243
+ this.setStatus(lang === 'zh-CN' ? '界面语言:简体中文' : 'interface language: English');
3244
+ this.paint();
3245
+ }
3246
+ /**
3247
+ * `/mcp` — which MCP servers' tools this profile actually mounted.
3248
+ *
3249
+ * The MCP client is composition-level, so the view is honest: it reports the
3250
+ * tools whose names carry a bridge prefix and tells the user where servers
3251
+ * are declared, rather than pretending to add or remove them at runtime.
3252
+ */
3253
+ showMcp() {
3254
+ let names = [];
3255
+ const tools = this.ctx.get('tools');
3256
+ try {
3257
+ names = (tools?.list?.() ?? [])
3258
+ .map((tool) => String(tool.name ?? ''))
3259
+ .filter((name) => name !== '');
3260
+ }
3261
+ catch {
3262
+ names = [];
3263
+ }
3264
+ this.showOverlay(renderMcp(groupMcpTools(names), names.length), 'esc to close');
3265
+ }
3266
+ /**
3267
+ * `/jobs` — what ran or is running in the background, and how to stop one.
3268
+ *
3269
+ * The registry is scoped to the agent that owns each job, so the list is the
3270
+ * current session's jobs plus whatever it delegated.
3271
+ */
3272
+ showJobs(requested) {
3273
+ const registry = this.ctx.get('jobs');
3274
+ if (registry === undefined) {
3275
+ this.setStatus('this profile composes no background job registry', true);
3276
+ this.paint();
3277
+ return;
3278
+ }
3279
+ const words = requested.trim().split(/\s+/).filter((word) => word !== '');
3280
+ if (words[0] === 'kill') {
3281
+ const id = words[1];
3282
+ if (id === undefined) {
3283
+ this.setStatus('usage: /jobs kill <id>', true);
3284
+ this.paint();
3285
+ return;
3286
+ }
3287
+ try {
3288
+ const outcome = registry.kill(id, this.tab.agent);
3289
+ this.setStatus(outcome === 'requested' ? `stopping ${id}` : `${id} had already finished`);
3290
+ }
3291
+ catch (error) {
3292
+ this.setStatus(describeError(error), true);
3293
+ }
3294
+ this.paint();
3295
+ return;
3296
+ }
3297
+ let jobs = [];
3298
+ try {
3299
+ jobs = registry.list(this.tab.agent).map((job) => ({
3300
+ id: String(job.id),
3301
+ kind: String(job.kind),
3302
+ label: job.label,
3303
+ status: job.status,
3304
+ detail: job.detail,
3305
+ startedAt: job.startedAt,
3306
+ finishedAt: job.finishedAt,
3307
+ }));
3308
+ }
3309
+ catch (error) {
3310
+ this.setStatus(describeError(error), true);
3311
+ this.paint();
3312
+ return;
3313
+ }
3314
+ this.showOverlay(renderJobs(jobs), 'esc to close');
3315
+ }
3316
+ /**
3317
+ * Stop a running turn and wait for it to settle, capped, before forking.
3318
+ *
3319
+ * A fork that lands while the loop is still appending would inherit a
3320
+ * half-finished turn; the cap keeps an unresponsive turn from hanging the
3321
+ * command forever.
3322
+ */
3323
+ async interruptAndSettle() {
3324
+ const tab = this.tab;
3325
+ if (!tab.streaming)
3326
+ return;
3327
+ this.setStatus('stopping the running reply…');
3328
+ this.paint();
3329
+ this.interrupt();
3330
+ await Promise.race([
3331
+ tab.agent?.whenIdle() ?? Promise.resolve(),
3332
+ new Promise((resolve) => setTimeout(resolve, 30_000)),
3333
+ ]);
3334
+ }
3335
+ /**
3336
+ * Every model the mounted adapters can serve, as picker rows. A provider
3337
+ * whose listing fails (an unreachable gateway, a missing credential) is
3338
+ * skipped rather than failing the whole list, so one broken route does not
3339
+ * hide the working ones.
3340
+ */
3341
+ async listModelRows() {
3342
+ const llm = this.ctx.get('llm');
3343
+ if (llm === undefined)
3344
+ return [];
3345
+ const rows = [];
3346
+ for (const provider of llm.listProviders()) {
3347
+ let models = [];
3348
+ try {
3349
+ models = await llm.listModels(provider.id);
3350
+ }
3351
+ catch {
3352
+ continue;
3353
+ }
3354
+ for (const model of models) {
3355
+ rows.push({
3356
+ id: `${provider.id}/${model.id}`,
3357
+ title: model.name === '' || model.name === model.id ? model.id : `${model.id} ${model.name}`,
3358
+ subtitle: provider.name === '' ? provider.id : provider.name,
3359
+ provider: provider.id,
3360
+ model: model.id,
3361
+ active: model.id === this.tab.modelName,
3362
+ });
3363
+ }
3364
+ }
3365
+ return rows;
3366
+ }
3367
+ /** Open the model picker. */
3368
+ async showModels() {
3369
+ this.setStatus('loading models…');
3370
+ this.paint();
3371
+ try {
3372
+ const rows = await this.listModelRows();
3373
+ if (rows.length === 0) {
3374
+ this.setStatus('no models available — is a provider credential set?', true);
3375
+ this.paint();
3376
+ return;
3377
+ }
3378
+ this.picker.show('models', 'Models', rows, { grouped: true });
3379
+ // Start on the model already in use, so enter is a no-op rather than a
3380
+ // surprise switch.
3381
+ const current = rows.find((row) => row.active === true);
3382
+ if (current !== undefined)
3383
+ this.picker.selectById(current.id);
3384
+ this.setStatus('');
3385
+ }
3386
+ catch (error) {
3387
+ this.setStatus(describeError(error), true);
3388
+ }
3389
+ this.paint();
3390
+ }
3391
+ /**
3392
+ * Resolve `/model <name>` against the catalog. Accepts a bare model id, or
3393
+ * `provider/model` when two routes serve the same id.
3394
+ */
3395
+ async selectModelByName(request) {
3396
+ this.setStatus('resolving model…');
3397
+ this.paint();
3398
+ const rows = await this.listModelRows();
3399
+ const wanted = request.toLowerCase();
3400
+ const matches = rows.filter((row) => row.id.toLowerCase() === wanted || (row.model ?? '').toLowerCase() === wanted);
3401
+ if (matches.length === 0) {
3402
+ this.setStatus(`unknown model ${request} — /model lists them`, true);
3403
+ this.paint();
3404
+ return;
3405
+ }
3406
+ if (matches.length > 1) {
3407
+ this.setStatus(`${request} is served by several routes — use provider/model`, true);
3408
+ this.paint();
3409
+ return;
3410
+ }
3411
+ const chosen = matches[0];
3412
+ if (chosen !== undefined)
3413
+ await this.switchModel(chosen);
3414
+ }
3415
+ /**
3416
+ * Switch the active session's model. The running Agent's selection was
3417
+ * installed when it was created, so the switch re-resolves the Agent
3418
+ * against the same Session — that keeps the conversation instead of
3419
+ * starting a new one — and saves the choice as the default for future
3420
+ * sessions. Only this session's ref moves: every other tab keeps routing
3421
+ * through its own, unchanged selection.
3422
+ */
3423
+ async switchModel(row) {
3424
+ const defaultModel = this.ctx.get('agentDefaultModel');
3425
+ if (defaultModel === undefined)
3426
+ return;
3427
+ if (row.provider === undefined || row.model === undefined)
3428
+ return;
3429
+ if (this.tab.streaming) {
3430
+ this.setStatus('cannot switch model while a reply is streaming', true);
3431
+ this.paint();
3432
+ return;
3433
+ }
3434
+ // Mutating this tab's installed ref is the whole switch: the running
3435
+ // Agent reads it when the next step enters prompt assembly, so the
3436
+ // conversation and the Session carry on untouched — and no other tab's
3437
+ // Agent shares the ref, so they are not rerouted.
3438
+ const tab = this.tab;
3439
+ const next = {
3440
+ ...(tab.selection.current ?? defaultModel.currentSelection()),
3441
+ provider: row.provider,
3442
+ model: row.model,
3443
+ };
3444
+ tab.selection.current = next;
3445
+ tab.modelName = row.model;
3446
+ // A restored tab should answer on the model its conversation was moved to.
3447
+ this.persistSoon();
3448
+ void this.refreshContextLimit(tab);
3449
+ // Token counts belong to the previous route's accounting.
3450
+ tab.haveUsage = false;
3451
+ this.setStatus(`model → ${row.model}`);
3452
+ this.paint();
3453
+ // Persisting is only about what a future session starts with, so a store
3454
+ // without a settings provider must not surface as a failed switch.
3455
+ try {
3456
+ await defaultModel.saveSelection(next);
3457
+ }
3458
+ catch (error) {
3459
+ this.setStatus(`model → ${row.model} (not saved: ${describeError(error)})`, true);
3460
+ this.paint();
3461
+ }
3462
+ }
3463
+ /**
3464
+ * Open the palette picker. The rows come straight from the theme table, so
3465
+ * a palette added there shows up here with no further wiring.
3466
+ */
3467
+ showThemes() {
3468
+ const current = activeTheme();
3469
+ const rows = listThemes().map((theme) => ({
3470
+ id: theme.name,
3471
+ title: theme.name,
3472
+ subtitle: theme.description,
3473
+ active: theme.name === current,
3474
+ }));
3475
+ this.picker.show('themes', 'Themes', rows);
3476
+ // Start on the palette in use, so esc and enter are both a no-op.
3477
+ this.picker.selectById(current);
3478
+ this.setStatus('');
3479
+ this.paint();
3480
+ }
3481
+ /**
3482
+ * Install a palette by name and redraw.
3483
+ *
3484
+ * A palette change moves the color of nearly every cell, and the screen
3485
+ * driver only rewrites lines whose text it has seen change — so the cache
3486
+ * has to be dropped explicitly or the old colors stay on screen until
3487
+ * something else happens to touch those rows.
3488
+ */
3489
+ selectTheme(name) {
3490
+ if (!applyTheme(name)) {
3491
+ this.setStatus(`unknown theme ${name} — /theme lists them`, true);
3492
+ this.paint();
3493
+ return;
3494
+ }
3495
+ this.persistSoon();
3496
+ this.setStatus(`theme → ${activeTheme()}`);
3497
+ this.screen.invalidate();
3498
+ this.paint();
3499
+ }
3500
+ // -------------------------------------------------------------- plugins
3501
+ /** The directory of the profile this app booted from, or why there is none. */
3502
+ profileDir() {
3503
+ const name = activeProfileName();
3504
+ if (name === undefined) {
3505
+ return { error: 'launched without --profile — there is no plugin list to show' };
3506
+ }
3507
+ const home = process.env['DSH_HOME'] ?? join(homedir(), '.dsh');
3508
+ return { dir: resolveProfileDir(name, home), name };
3509
+ }
3510
+ /** `/plugins` with nothing after it: the picker over the profile's packages. */
3511
+ showPlugins() {
3512
+ const profile = this.profileDir();
3513
+ if ('error' in profile) {
3514
+ this.setStatus(profile.error, true);
3515
+ this.paint();
3516
+ return;
3517
+ }
3518
+ const manifest = readProfileManifest(profile.dir);
3519
+ const rows = listPlugins(manifest).map((entry) => ({
3520
+ id: entry.name,
3521
+ title: entry.name,
3522
+ // The dependency spec, or the fact that the layer came in the box.
3523
+ subtitle: entry.spec !== '' ? entry.spec : entry.installed ? '' : 'in-box',
3524
+ active: entry.enabled,
3525
+ }));
3526
+ if (rows.length === 0) {
3527
+ this.setStatus(`no packages listed in profile ${profile.name}`, true);
3528
+ this.paint();
3529
+ return;
3530
+ }
3531
+ this.picker.show('plugins', 'Plugins', rows);
3532
+ this.setStatus('enter toggles a package · restart applies it');
3533
+ this.paint();
3534
+ }
3535
+ /** `/plugins <verb> …` — everything the pane cannot do with the enter key. */
3536
+ handlePluginsInput(input) {
3537
+ if (input === '') {
3538
+ this.showPlugins();
3539
+ return;
3540
+ }
3541
+ const [verb, ...rest] = input.split(/\s+/);
3542
+ const argument = rest.join(' ').trim();
3543
+ if (verb === 'add')
3544
+ this.confirmAddPlugin(argument);
3545
+ else if (verb === 'remove')
3546
+ this.confirmRemovePlugin(argument);
3547
+ else
3548
+ this.setStatus('usage: /plugins [add <pkg> | remove <pkg>]', true);
3549
+ this.paint();
3550
+ }
3551
+ /** Enable or disable the row the picker landed on. */
3552
+ togglePlugin(name) {
3553
+ const profile = this.profileDir();
3554
+ if ('error' in profile) {
3555
+ this.setStatus(profile.error, true);
3556
+ return;
3557
+ }
3558
+ const manifest = readProfileManifest(profile.dir);
3559
+ const enabled = !isPluginEnabled(manifest, name);
3560
+ const edit = setPluginEnabled(manifest, name, enabled);
3561
+ if (!edit.changed) {
3562
+ this.setStatus(edit.reason, true);
3563
+ return;
3564
+ }
3565
+ writeProfileManifest(profile.dir, edit.manifest);
3566
+ this.setStatus(`${name} ${enabled ? 'enabled' : 'disabled'} — restart to apply`);
3567
+ }
3568
+ /** `/plugins add <pkg>` — install into the profile, then compose it. */
3569
+ confirmAddPlugin(argument) {
3570
+ const parsed = parsePackageRequest(argument);
3571
+ if (!parsed.ok) {
3572
+ this.setStatus(parsed.reason, true);
3573
+ return;
3574
+ }
3575
+ const request = parsed.request;
3576
+ const profile = this.profileDir();
3577
+ if ('error' in profile) {
3578
+ this.setStatus(profile.error, true);
3579
+ return;
3580
+ }
3581
+ this.confirm(`Install ${request.spec} into ${profile.name}? Its install scripts run as you.`, () => {
3582
+ void this.performAddPlugin(profile.dir, request.spec, request.name);
3583
+ });
3584
+ }
3585
+ async performAddPlugin(dir, spec, name) {
3586
+ this.setStatus(`installing ${spec}…`);
3587
+ this.paint();
3588
+ const install = await runPackageManager(dir, ['add', spec]);
3589
+ if (!install.ok) {
3590
+ this.setStatus(install.message, true);
3591
+ this.paint();
3592
+ return;
3593
+ }
3594
+ // The install may have reordered the manifest; re-read before composing.
3595
+ const manifest = readProfileManifest(dir);
3596
+ const edit = setPluginEnabled(manifest, name, true);
3597
+ if (edit.changed)
3598
+ writeProfileManifest(dir, edit.manifest);
3599
+ this.setStatus(`${install.message} — restart to compose it`);
3600
+ this.paint();
3601
+ }
3602
+ /** `/plugins remove <pkg>` — uncompose it, then uninstall it. */
3603
+ confirmRemovePlugin(argument) {
3604
+ const name = argument.trim();
3605
+ if (name === '') {
3606
+ this.setStatus('name the package to remove', true);
3607
+ return;
3608
+ }
3609
+ const profile = this.profileDir();
3610
+ if ('error' in profile) {
3611
+ this.setStatus(profile.error, true);
3612
+ return;
3613
+ }
3614
+ this.confirm(`Remove ${name} from ${profile.name}?`, () => {
3615
+ void this.performRemovePlugin(profile.dir, name);
3616
+ });
3617
+ }
3618
+ async performRemovePlugin(dir, name) {
3619
+ // The stack is edited first: a name left in `bundles` with no package
3620
+ // behind it stops the profile booting, so that must not survive a failure.
3621
+ const manifest = readProfileManifest(dir);
3622
+ const edit = forgetPlugin(manifest, name);
3623
+ if (edit.changed)
3624
+ writeProfileManifest(dir, edit.manifest);
3625
+ const removal = await runPackageManager(dir, ['remove', name]);
3626
+ this.setStatus(removal.ok ? `${removal.message} — restart to apply` : `${removal.message} (uncomposed anyway)`, !removal.ok);
3627
+ this.paint();
3628
+ }
3629
+ /** Open a picker over the sessions already open in this app. */
3630
+ showOpenSessions() {
3631
+ const rows = this.tabs.map((tab, index) => ({
3632
+ id: String(index),
3633
+ title: `${String(index + 1)}. ${tab.title === '' ? 'new session' : tab.title}`,
3634
+ subtitle: tab.streaming ? 'running' : tab.status === 'ready' ? 'ready' : 'idle',
3635
+ active: index === this.active,
3636
+ }));
3637
+ // Starting a conversation is the other thing you come to this list to do,
3638
+ // so it is an entry here rather than a key you have to already know.
3639
+ rows.push({ id: NEW_SESSION_ROW, title: '+ Ask the harness in a new session', subtitle: 'ctrl+n' });
3640
+ this.picker.show('open', 'Open sessions', rows);
3641
+ this.picker.selectById(String(this.active));
3642
+ this.setStatus('');
3643
+ this.paint();
3644
+ }
3645
+ /** Open the session picker, listing what the query service can see. */
3646
+ async showSessions(mode = 'resume') {
3647
+ const query = this.ctx.get('sessionQuery');
3648
+ if (query === undefined) {
3649
+ this.setStatus('this profile has no session query service', true);
3650
+ this.paint();
3651
+ return;
3652
+ }
3653
+ this.setStatus('loading sessions…');
3654
+ this.paint();
3655
+ try {
3656
+ const rows = await listSessions(query);
3657
+ if (rows.length === 0) {
3658
+ this.setStatus('no earlier sessions found');
3659
+ this.paint();
3660
+ return;
3661
+ }
3662
+ if (mode === 'delete') {
3663
+ // A session open in a tab must not be pulled out from under it.
3664
+ const open = new Set(this.tabs.map((tab) => tab.id));
3665
+ const deletable = rows.filter((row) => !open.has(row.id));
3666
+ if (deletable.length === 0) {
3667
+ this.setStatus('every stored session is open here — /close one first', true);
3668
+ this.paint();
3669
+ return;
3670
+ }
3671
+ this.picker.show('delete', 'Delete a session', deletable);
3672
+ this.setStatus('');
3673
+ }
3674
+ else {
3675
+ this.picker.show('sessions', 'Sessions', rows);
3676
+ this.setStatus('');
3677
+ }
3678
+ }
3679
+ catch (error) {
3680
+ this.setStatus(describeError(error), true);
3681
+ }
3682
+ this.paint();
3683
+ }
3684
+ /** Ask, then remove a stored session from disk for good. */
3685
+ confirmDelete(id, title) {
3686
+ const label = title === '' ? id : title;
3687
+ this.confirm(`Delete "${label}" for good?`, () => {
3688
+ void this.performDelete(id, label);
3689
+ });
3690
+ }
3691
+ async performDelete(id, label) {
3692
+ try {
3693
+ const dir = await findStoredSessionDir(id);
3694
+ if (dir === undefined) {
3695
+ this.setStatus(`no stored session ${label}`, true);
3696
+ this.paint();
3697
+ return;
3698
+ }
3699
+ const removed = await deleteStoredSessionDir(dir);
3700
+ this.setStatus(removed ? `deleted ${label}` : `no stored session ${label}`, !removed);
3701
+ }
3702
+ catch (error) {
3703
+ this.setStatus(`delete failed: ${describeError(error)}`, true);
3704
+ }
3705
+ this.paint();
3706
+ }
3707
+ /** Adopt an existing session and load its transcript. */
3708
+ async openSession(id, title) {
3709
+ const agents = this.ctx.get('agents');
3710
+ const defaultModel = this.ctx.get('agentDefaultModel');
3711
+ if (agents === undefined || defaultModel === undefined)
3712
+ return;
3713
+ this.setStatus('loading…');
3714
+ this.paint();
3715
+ const selection = this.tab.selection.current ?? defaultModel.currentSelection();
3716
+ this.tab.selection.current = selection;
3717
+ this.tab.modelName = String(selection.model);
3718
+ try {
3719
+ const resumed = await agents.resume({
3720
+ resumeSessionId: brandString(id),
3721
+ agentOptions: { provider: selection.provider, model: selection.model },
3722
+ setup: this.selectionSetupFor(this.tab),
3723
+ });
3724
+ this.tab.agent = resumed.agent;
3725
+ this.adoptForeground(this.tab);
3726
+ await this.tab.agent.whenIdle();
3727
+ // The tab now *is* that session. Leaving the old id behind made presence
3728
+ // publish the wrong one to the fleet and let `/delete` offer the session
3729
+ // this very tab had open, and a restore would have remembered the id of
3730
+ // a conversation nobody was looking at.
3731
+ this.tab.id = id;
3732
+ this.tab.title = title;
3733
+ this.refreshFromSession();
3734
+ this.tab.scrollBack = 0;
3735
+ // This tab now points at a different session than the one remembered.
3736
+ this.persistSoon();
3737
+ this.setStatus('');
3738
+ }
3739
+ catch (error) {
3740
+ this.setStatus(describeError(error), true);
3741
+ }
3742
+ this.paint();
3743
+ }
3744
+ /** Re-read the transcript from the agent's session log. */
3745
+ refreshFromSession() {
3746
+ if (this.tab.agent === undefined)
3747
+ return;
3748
+ try {
3749
+ this.tab.messages = readHistory(this.tab.agent.session);
3750
+ }
3751
+ catch {
3752
+ // A log the app cannot parse is not a reason to lose the screen.
3753
+ }
3754
+ }
3755
+ /** Restore the terminal and ask the launcher to exit. */
3756
+ quit() {
3757
+ this.abort?.abort();
3758
+ this.stop();
3759
+ this.exit(0);
3760
+ }
3761
+ /**
3762
+ * The two-step ctrl+c: the first press opens the sessions menu (or, when a
3763
+ * picker is already up, just arms the confirmation), and a second press
3764
+ * inside the window quits. Anything else leaves the app running.
3765
+ */
3766
+ requestQuit() {
3767
+ const now = Date.now();
3768
+ if (now - this.lastQuitRequest < QUIT_CONFIRM_MS) {
3769
+ this.quit();
3770
+ return;
3771
+ }
3772
+ this.lastQuitRequest = now;
3773
+ if (this.picker.kind === 'none')
3774
+ this.showOpenSessions();
3775
+ this.setStatus('ctrl+c again to quit');
3776
+ this.paint();
3777
+ }
3778
+ }
3779
+ /**
3780
+ * List sessions through whichever method this build of the query service
3781
+ * exposes. The service is documented as providing "filtered lists"; probing
3782
+ * keeps the app working across the rc releases rather than pinning one name.
3783
+ */
3784
+ async function listSessions(query) {
3785
+ const method = query.listSessions ?? query.list ?? query.querySessions;
3786
+ if (method === undefined)
3787
+ return [];
3788
+ const raw = await method.call(query, {});
3789
+ const rows = Array.isArray(raw)
3790
+ ? raw
3791
+ : Array.isArray(raw?.items)
3792
+ ? (raw.items)
3793
+ : [];
3794
+ return rows.slice(0, 200).map((row) => {
3795
+ const record = row;
3796
+ const id = String(record['sessionId'] ?? record['id'] ?? '');
3797
+ const title = String(record['title'] ?? record['summary'] ?? id);
3798
+ const when = record['updatedAt'] ?? record['createdAt'];
3799
+ const subtitle = typeof when === 'number' ? relativeTime(when) : '';
3800
+ return { id, title, subtitle };
3801
+ }).filter((item) => item.id !== '');
3802
+ }
3803
+ /**
3804
+ * Hand the text to the desktop's own clipboard helper, when there is one.
3805
+ *
3806
+ * This is the companion to OSC 52, not a replacement: the escape is what works
3807
+ * over SSH and inside tmux, where no local helper can reach the clipboard the
3808
+ * user is actually looking at. Locally the reverse holds — a Wayland
3809
+ * compositor grants clipboard ownership only against an input-focus serial, so
3810
+ * a terminal can accept the escape and still not own the selection.
3811
+ *
3812
+ * Best effort by design: a missing helper, a sandbox with no display, or a
3813
+ * helper that exits non-zero all leave OSC 52 as the result, and none of them
3814
+ * are worth interrupting a copy to report.
3815
+ */
3816
+ function copyWithLocalHelper(text) {
3817
+ const wayland = process.env['WAYLAND_DISPLAY'] !== undefined;
3818
+ const x11 = process.env['DISPLAY'] !== undefined;
3819
+ const candidates = [
3820
+ ...(wayland ? [['wl-copy', []]] : []),
3821
+ ...(x11
3822
+ ? [
3823
+ ['xclip', ['-selection', 'clipboard']],
3824
+ ['xsel', ['--clipboard', '--input']],
3825
+ ]
3826
+ : []),
3827
+ ['pbcopy', []],
3828
+ ];
3829
+ for (const [command, args] of candidates) {
3830
+ try {
3831
+ const run = spawnSync(command, args, {
3832
+ input: text,
3833
+ // The helper must never inherit the terminal: wl-copy stays resident to
3834
+ // serve the selection, and a shared stdout would corrupt the frame.
3835
+ stdio: ['pipe', 'ignore', 'ignore'],
3836
+ timeout: 2000,
3837
+ });
3838
+ if (run.error === undefined && run.status === 0)
3839
+ return true;
3840
+ }
3841
+ catch {
3842
+ // Try the next candidate.
3843
+ }
3844
+ }
3845
+ return false;
3846
+ }
3847
+ /** A compact "3h ago" label for the picker's right column. */
3848
+ function relativeTime(epochMillis) {
3849
+ const seconds = Math.max(Math.floor((Date.now() - epochMillis) / 1000), 0);
3850
+ if (seconds < 60)
3851
+ return 'just now';
3852
+ if (seconds < 3600)
3853
+ return `${Math.floor(seconds / 60)}m ago`;
3854
+ if (seconds < 86400)
3855
+ return `${Math.floor(seconds / 3600)}h ago`;
3856
+ return `${Math.floor(seconds / 86400)}d ago`;
3857
+ }
3858
+ /** A filesystem-safe timestamp for export file names. */
3859
+ function timestampForFile(date = new Date()) {
3860
+ const pad = (value) => String(value).padStart(2, '0');
3861
+ return (`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
3862
+ `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`);
3863
+ }
3864
+ /**
3865
+ * Rebuild the transcript from a session's durable log. Only user and assistant
3866
+ * text is projected; everything else the log carries belongs to other surfaces.
3867
+ */
3868
+ function readHistory(session) {
3869
+ const out = [];
3870
+ const length = session.seq;
3871
+ for (let seq = 0; seq < length; seq += 1) {
3872
+ const event = session.eventAt(SessionSeq(seq));
3873
+ if (event === undefined)
3874
+ continue;
3875
+ const message = event.data?.message;
3876
+ const blocks = Array.isArray(message?.content) ? message.content : [];
3877
+ const text = blocks
3878
+ .filter((block) => {
3879
+ const candidate = block;
3880
+ return candidate.type === 'text' && typeof candidate.text === 'string';
3881
+ })
3882
+ .map((block) => block.text)
3883
+ .join('');
3884
+ if (text === '')
3885
+ continue;
3886
+ if (event.type === 'assistant/message')
3887
+ out.push(textMessage('assistant', text));
3888
+ else if (event.type === 'user/message')
3889
+ out.push(textMessage('user', text));
3890
+ }
3891
+ return out;
3892
+ }
3893
+ /**
3894
+ * The session log as the rewind/fork rules want it: every event with the seq
3895
+ * that indexes it, since a log is contiguous from 0.
3896
+ */
3897
+ function sessionEvents(session) {
3898
+ return session
3899
+ .snapshotEvents(SessionLogOffset(0), session.seq)
3900
+ .map((event, seq) => ({ seq, type: String(event.type ?? '') }));
3901
+ }
3902
+ /** A session's fork parent, when its header records one. */
3903
+ function parentOf(session) {
3904
+ const parent = session?.header.parentSession;
3905
+ return parent === undefined ? undefined : String(parent);
3906
+ }
3907
+ /**
3908
+ * Session rows with their fork parent, for `/tree`.
3909
+ *
3910
+ * The query service's row shape is probed rather than assumed: an rc that
3911
+ * names the field differently still yields a tree, just without lineage.
3912
+ */
3913
+ async function listSessionsWithParents(query) {
3914
+ const method = query.listSessions ?? query.list ?? query.querySessions;
3915
+ if (method === undefined)
3916
+ return [];
3917
+ const raw = await method.call(query, {});
3918
+ const rows = Array.isArray(raw)
3919
+ ? raw
3920
+ : Array.isArray(raw?.items)
3921
+ ? raw.items
3922
+ : [];
3923
+ return rows.slice(0, 500).map((row) => {
3924
+ const record = row;
3925
+ const parent = record['parentSession'] ?? record['parent'];
3926
+ return {
3927
+ id: String(record['sessionId'] ?? record['id'] ?? ''),
3928
+ title: typeof record['title'] === 'string' ? record['title'] : undefined,
3929
+ parentSession: parent === undefined || parent === null ? undefined : String(parent),
3930
+ };
3931
+ }).filter((row) => row.id !== '');
3932
+ }
3933
+ /**
3934
+ * A short label for a background agent: the preset it was composed from when
3935
+ * there is one, else its origin, else a short form of the session id.
3936
+ */
3937
+ function labelFor(agent) {
3938
+ const header = agent.session.header;
3939
+ const preset = header.agentPreset;
3940
+ if (preset !== undefined && preset !== '')
3941
+ return preset;
3942
+ if (header.origin === 'subagent')
3943
+ return 'subagent';
3944
+ return String(header.id).replace(/^session-/, '').slice(0, 8);
3945
+ }
3946
+ /** A one-line, human-readable form of anything thrown. */
3947
+ /** Content blocks for one prompt: its text plus any staged image blocks. */
3948
+ function promptBlocks(prompt) {
3949
+ if (prompt.images.length === 0)
3950
+ return [{ type: 'text', text: prompt.text }];
3951
+ return [
3952
+ { type: 'text', text: prompt.text },
3953
+ ...prompt.images.map((attachment) => ({
3954
+ type: 'image',
3955
+ attachment,
3956
+ })),
3957
+ ];
3958
+ }
3959
+ /** Map a picked image path to the media type the attachment store expects. */
3960
+ function mediaTypeOf(path) {
3961
+ const extension = path.slice(path.lastIndexOf('.')).toLowerCase();
3962
+ if (extension === '.png')
3963
+ return 'image/png';
3964
+ if (extension === '.jpg' || extension === '.jpeg')
3965
+ return 'image/jpeg';
3966
+ if (extension === '.webp')
3967
+ return 'image/webp';
3968
+ if (extension === '.gif')
3969
+ return 'image/gif';
3970
+ return undefined;
3971
+ }
3972
+ function describeError(error) {
3973
+ if (error instanceof Error)
3974
+ return error.message;
3975
+ return String(error);
3976
+ }
3977
+ /**
3978
+ * Mount the terminal app.
3979
+ * @param ctx - plugin context carrying the core services and launcher exit.
3980
+ * @param config - validated startup options.
3981
+ */
3982
+ export function apply(ctx, config) {
3983
+ const exit = ctx.get('appExit');
3984
+ if (exit === undefined) {
3985
+ throw new Error('moqi: the launcher must provide ctx.appExit before the tree mounts');
3986
+ }
3987
+ const app = new TuiApp(ctx, config, exit);
3988
+ // Registering the teardown as an effect is what guarantees the terminal is
3989
+ // restored when the tree unwinds — a reload or a failure elsewhere in the
3990
+ // profile must not leave the user in the alternate screen with no cursor.
3991
+ ctx.effect(() => () => {
3992
+ app.stop();
3993
+ });
3994
+ void app.start().catch((error) => {
3995
+ app.stop();
3996
+ process.stderr.write(`dsh: ${describeError(error)}\n`);
3997
+ exit(1);
3998
+ });
3999
+ }