@aiwayds/dsh-tui-pi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/dsh-tui-pi +5 -0
  4. package/cordis.patch.yml +9 -0
  5. package/lib/append-system.d.ts +66 -0
  6. package/lib/append-system.js +161 -0
  7. package/lib/append-system.js.map +1 -0
  8. package/lib/commands.d.ts +53 -0
  9. package/lib/commands.js +167 -0
  10. package/lib/commands.js.map +1 -0
  11. package/lib/dsh-events.d.ts +106 -0
  12. package/lib/dsh-events.js +30 -0
  13. package/lib/dsh-events.js.map +1 -0
  14. package/lib/editor.d.ts +28 -0
  15. package/lib/editor.js +70 -0
  16. package/lib/editor.js.map +1 -0
  17. package/lib/footer.d.ts +36 -0
  18. package/lib/footer.js +112 -0
  19. package/lib/footer.js.map +1 -0
  20. package/lib/frame.d.ts +35 -0
  21. package/lib/frame.js +75 -0
  22. package/lib/frame.js.map +1 -0
  23. package/lib/git.d.ts +17 -0
  24. package/lib/git.js +51 -0
  25. package/lib/git.js.map +1 -0
  26. package/lib/index.d.ts +15 -0
  27. package/lib/index.js +781 -0
  28. package/lib/index.js.map +1 -0
  29. package/lib/instructions.d.ts +29 -0
  30. package/lib/instructions.js +67 -0
  31. package/lib/instructions.js.map +1 -0
  32. package/lib/live-widgets.d.ts +85 -0
  33. package/lib/live-widgets.js +218 -0
  34. package/lib/live-widgets.js.map +1 -0
  35. package/lib/messages.d.ts +277 -0
  36. package/lib/messages.js +734 -0
  37. package/lib/messages.js.map +1 -0
  38. package/lib/permission.d.ts +27 -0
  39. package/lib/permission.js +48 -0
  40. package/lib/permission.js.map +1 -0
  41. package/lib/provider-catalog.d.ts +114 -0
  42. package/lib/provider-catalog.js +124 -0
  43. package/lib/provider-catalog.js.map +1 -0
  44. package/lib/quotes.d.ts +28 -0
  45. package/lib/quotes.js +144 -0
  46. package/lib/quotes.js.map +1 -0
  47. package/lib/reload.d.ts +23 -0
  48. package/lib/reload.js +171 -0
  49. package/lib/reload.js.map +1 -0
  50. package/lib/selectors.d.ts +48 -0
  51. package/lib/selectors.js +261 -0
  52. package/lib/selectors.js.map +1 -0
  53. package/lib/session.d.ts +157 -0
  54. package/lib/session.js +555 -0
  55. package/lib/session.js.map +1 -0
  56. package/lib/sessions.d.ts +73 -0
  57. package/lib/sessions.js +253 -0
  58. package/lib/sessions.js.map +1 -0
  59. package/lib/settings.d.ts +180 -0
  60. package/lib/settings.js +1328 -0
  61. package/lib/settings.js.map +1 -0
  62. package/lib/text.d.ts +22 -0
  63. package/lib/text.js +45 -0
  64. package/lib/text.js.map +1 -0
  65. package/lib/theme/index.d.ts +79 -0
  66. package/lib/theme/index.js +121 -0
  67. package/lib/theme/index.js.map +1 -0
  68. package/lib/theme/palette.d.ts +56 -0
  69. package/lib/theme/palette.js +154 -0
  70. package/lib/theme/palette.js.map +1 -0
  71. package/lib/theme-settings.d.ts +68 -0
  72. package/lib/theme-settings.js +223 -0
  73. package/lib/theme-settings.js.map +1 -0
  74. package/lib/tui.d.ts +70 -0
  75. package/lib/tui.js +206 -0
  76. package/lib/tui.js.map +1 -0
  77. package/lib/welcome.d.ts +91 -0
  78. package/lib/welcome.js +281 -0
  79. package/lib/welcome.js.map +1 -0
  80. package/package.json +50 -0
  81. package/patches/@earendil-works__pi-tui.patch +72 -0
  82. package/pnpm-workspace.yaml +2 -0
  83. package/templates/APPEND_SYSTEM.md +34 -0
package/lib/index.js ADDED
@@ -0,0 +1,781 @@
1
+ /**
2
+ * dsh-tui-pi — pi-style terminal UI for DeepSeek Harness.
3
+ *
4
+ * Cordis plugin entry, mounted as a dsh profile bundle (`dsh.bundle.patch`).
5
+ * The TUI runs in-process inside the dsh tree: it renders with
6
+ * `@earendil-works/pi-tui`, talks to dsh services directly (ctx.agents,
7
+ * ctx.commands, session events), and keeps dsh's slash commands untouched.
8
+ *
9
+ * @module dsh-tui-pi
10
+ */
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { homedir } from 'node:os';
13
+ import { dirname, join, resolve } from 'node:path';
14
+ import { Loader, Text } from '@earendil-works/pi-tui';
15
+ import { CommandService } from "./commands.js";
16
+ import { PowerlineFooter } from "./footer.js";
17
+ import { GitBranchWatcher } from "./git.js";
18
+ import { ensureAppendSystemFile, migrateAgentsMdTodoSection, readAppendSystem } from "./append-system.js";
19
+ import { TranscriptRenderer } from "./messages.js";
20
+ import { AGENT_TICK_MS, LiveWidgets } from "./live-widgets.js";
21
+ import { displayPermissionPreset } from "./permission.js";
22
+ import { pickEffort, pickModel, pickPermission, pickTheme } from "./selectors.js";
23
+ import { DshSessionBridge, persistDefaultModel } from "./session.js";
24
+ import { currentThemePreference, readPanelHeightPreference, readThemePreference, registerThemeSettings, writeThemePreference, } from "./theme-settings.js";
25
+ import { openSettingsBrowser } from "./settings.js";
26
+ import { reloadPlugin } from "./reload.js";
27
+ import { inspectPersistedSession, pickPersistedSession, showSessionInfo } from "./sessions.js";
28
+ import { ansiFg, darkTheme, lightTheme, RESET, resolveTheme } from "./theme/index.js";
29
+ import { clipToWidth } from "./text.js";
30
+ import { startTui } from "./tui.js";
31
+ export const name = 'dsh-tui-pi';
32
+ /** The TUI drives the agent factory and registers slash commands. */
33
+ export const inject = ['agents', 'commands', 'systemPrompt'];
34
+ export function apply(ctx) {
35
+ let handle;
36
+ // APPEND_SYSTEM.md (pi's convention; dsh side ~/.dsh/APPEND_SYSTEM.md): a
37
+ // user-editable file appended to the system prompt of every agent this TUI
38
+ // creates. The section text provider reads the file at each assembly, so
39
+ // edits apply to the next request without a restart or watcher. Empty
40
+ // content contributes nothing.
41
+ ctx.effect(() => ctx.systemPrompt.section({
42
+ name: 'dsh-tui-pi:append-system',
43
+ order: 200,
44
+ text: () => readAppendSystem(),
45
+ }), 'dsh-tui-pi: append-system');
46
+ // The TUI's own todo-lifecycle guidance rides the same file (idempotent
47
+ // marker); a fresh file is seeded with the orchestrator template. The
48
+ // legacy AGENTS.md delivery is migrated out. All best-effort.
49
+ void ensureAppendSystemFile();
50
+ void migrateAgentsMdTodoSection();
51
+ /**
52
+ * Live theme hot-reload sink, wired to the settings watch hook: a committed
53
+ * `dsh-tui` theme change (this TUI's /theme write included) is applied to
54
+ * the running TUI. Set inside runTui once the renderer exists; the first
55
+ * commit can only follow a user write, long after startup.
56
+ */
57
+ let applyThemeRef;
58
+ /**
59
+ * Live panel-height hot-reload sink, wired to the same watch hook: a
60
+ * committed `dsh-tui` panelHeight change rebuilds the transcript panels at
61
+ * the new row budget. Armed inside runTui once the renderer exists.
62
+ */
63
+ let applyPanelHeightRef;
64
+ ctx.effect(async () => {
65
+ // The theme bundle is built once at TUI startup and held by every
66
+ // component, so the persisted preference must land before startTui — the
67
+ // namespace registration rides the settings injection fiber and the read
68
+ // awaits it (bounded, degrades to the defaults without a settings
69
+ // service). The registration also watches the namespace: `applies:
70
+ // 'live'`, so later commits (the /theme picker, the /settings browser, an
71
+ // external edit) hot-apply through applyThemeRef / applyPanelHeightRef.
72
+ // Panel height FIRST, theme second: a single commit of both fields (a
73
+ // namespace-level reset, an external edit) must not replay twice at the
74
+ // wrong height. setPanelHeight + relayout repaint the transcript at the
75
+ // new row budget; the setTheme replay that follows already renders at
76
+ // that new height, so the theme rebuild is the one complete rebuild.
77
+ // applyTheme carries the theme-bundle identity guard, so a height-only
78
+ // commit never triggers a second rebuild.
79
+ registerThemeSettings(ctx, (pref, height) => {
80
+ applyPanelHeightRef?.(height);
81
+ applyThemeRef?.(pref);
82
+ });
83
+ const themePreference = await readThemePreference(ctx);
84
+ const panelHeight = await readPanelHeightPreference(ctx);
85
+ let disposer;
86
+ try {
87
+ disposer = runTui(themePreference, panelHeight);
88
+ }
89
+ catch (error) {
90
+ // An async-effect failure after startTui would otherwise orphan the
91
+ // terminal in raw mode — the disposer never registers, so nothing ever
92
+ // restores the TTY. Clean up and rethrow so cordis logs the error
93
+ // instead of silently leaking the TUI.
94
+ handle?.dispose();
95
+ handle = undefined;
96
+ throw error;
97
+ }
98
+ return disposer;
99
+ }, 'dsh-tui-pi.render');
100
+ /**
101
+ * Build the TUI and its slash commands for the resolved theme preference
102
+ * and panel height. Declared as a hoisted function so the effect body stays
103
+ * a thin wrapper: the whole build runs inside the try/catch above, so any
104
+ * failure disposes the TUI handle before the error reaches cordis. Returns
105
+ * the effect disposer handed back to cordis on teardown.
106
+ */
107
+ function runTui(themePreference, panelHeight) {
108
+ // Graded Ctrl+C: while the agent is mid-turn the first press cancels the
109
+ // active turn (keepInbox preserves the queue) with on-screen feedback;
110
+ // any further press — or any press while idle — quits the TUI.
111
+ let cancelAttempted = false;
112
+ let lastInterrupt = 0;
113
+ const ui = startTui({
114
+ onSubmit: text => {
115
+ void submit(text);
116
+ },
117
+ onInterrupt: () => {
118
+ if (bridge.isRunning() && !cancelAttempted && Date.now() - lastInterrupt > 1500) {
119
+ // First press mid-turn: cancel with on-screen feedback (mirrors the
120
+ // web client's stop button). The next press — of any kind — quits.
121
+ cancelAttempted = true;
122
+ lastInterrupt = Date.now();
123
+ // Transient notice: a second press quits and disposes the whole
124
+ // TUI, so the line's replay entry only matters for theme-switch
125
+ // repaints — buffering it keeps that rebuild faithful.
126
+ renderer.renderNotice('⏹ canceling current turn…', 'info');
127
+ void bridge.cancelActiveTurn().then(cancelled => {
128
+ // Nothing was running (state raced idle): nothing to cancel — quit.
129
+ if (!cancelled)
130
+ void disposeAndExit(0);
131
+ });
132
+ }
133
+ else {
134
+ void disposeAndExit(0);
135
+ }
136
+ },
137
+ themePreference,
138
+ });
139
+ handle = ui;
140
+ // Arm the settings watch sink now that the renderer exists (see apply()).
141
+ applyThemeRef = (pref) => {
142
+ applyTheme(resolveTheme(process.env, pref));
143
+ };
144
+ const renderer = new TranscriptRenderer(ui.transcript, ui.theme, () => ui.requestRender(), panelHeight);
145
+ // Live Todos/Agents widgets pinned above the chat window: show while the
146
+ // model has todos or subagents running, collapse when done. Owned here —
147
+ // fed by todo/write events and the bridge's subagent fold, ticked by the
148
+ // live timer, recolored by applyTheme.
149
+ const liveWidgets = new LiveWidgets(ui.widgets, ui.theme, () => ui.requestRender());
150
+ // Arm the panel-height watch sink now that the renderer exists: a
151
+ // committed panelHeight change sets the new height and relayouts the
152
+ // transcript (the replay rebuild), repainting every panel at the new row
153
+ // budget. setPanelHeight reports whether the height actually changed, so
154
+ // an echoed self-write is a no-op.
155
+ applyPanelHeightRef = (height) => {
156
+ if (renderer.setPanelHeight(height))
157
+ renderer.relayout();
158
+ };
159
+ // Terminal resize: pi-tui re-renders every component at the new columns,
160
+ // but bordered panel rows were padded to the OLD box width — a narrowing
161
+ // terminal wraps every row and shatters the fixed-height panels. Relayout
162
+ // the transcript (replay-buffer rebuild) on the next frame: pi-tui's own
163
+ // resize render is nextTick + 16ms throttled, so the rebuilt panels win
164
+ // the race and the first new-width frame is already intact. The trailing
165
+ // 0ms timer coalesces resize-event storms from terminal drags. The
166
+ // listener is per-runTui and removed in the effect disposer below, so a
167
+ // /reload never leaks one.
168
+ let relayoutTimer;
169
+ const onResize = () => {
170
+ if (relayoutTimer !== undefined)
171
+ clearTimeout(relayoutTimer);
172
+ relayoutTimer = setTimeout(() => {
173
+ relayoutTimer = undefined;
174
+ renderer.relayout();
175
+ }, 0);
176
+ };
177
+ process.stdout.on('resize', onResize);
178
+ // Working/idle indicator in the fixed dock (hidden while idle).
179
+ let loader;
180
+ let agentStatus = 'idle';
181
+ const setStatus = (status) => {
182
+ agentStatus = status;
183
+ if (status === 'running') {
184
+ if (loader === undefined) {
185
+ loader = new Loader(ui.tui, text => ansiFg(ui.theme.palette.accent) + text + RESET, text => ansiFg(ui.theme.palette.fgMuted) + text + RESET, 'working…');
186
+ ui.status.addChild(loader);
187
+ loader.start();
188
+ }
189
+ }
190
+ else if (loader !== undefined) {
191
+ loader.stop();
192
+ ui.status.removeChild(loader);
193
+ loader = undefined;
194
+ ui.requestRender();
195
+ }
196
+ };
197
+ // Permission knob events (preset switch, sandbox mode, approval policy)
198
+ // paint nothing in the transcript — they only change the editor's
199
+ // permission badge — so the renderer's default no-op must be compensated
200
+ // with a repaint request here. The badge reads the cached current preset
201
+ // (see the provider below), so a knob change must refresh the cache too.
202
+ const PERMISSION_KNOB_EVENTS = new Set(['permission/preset', 'sandbox/mode', 'approval/policy']);
203
+ const bridge = new DshSessionBridge(ctx, {
204
+ onEvent: event => {
205
+ renderer.applyEvent(event);
206
+ if (event.type === 'todo/write') {
207
+ // Todos render in the fixed live widget, not the transcript.
208
+ liveWidgets.renderTodos(event.data.todos);
209
+ }
210
+ if (PERMISSION_KNOB_EVENTS.has(event.type)) {
211
+ refreshPermissionPreset();
212
+ ui.requestRender();
213
+ }
214
+ },
215
+ onStatus: setStatus,
216
+ onLive: agents => {
217
+ liveWidgets.renderAgents(agents);
218
+ },
219
+ });
220
+ /**
221
+ * One O(events) fold of the session log into the effective preset, stored
222
+ * for the badge provider. Runs once per session (ensure/resume, see the
223
+ * call sites) and once per knob change — never on the render path, which
224
+ * is what makes the provider's read O(1). Clearing to undefined is the
225
+ * right answer for an absent agent/preset service: the provider then
226
+ * hides the badge.
227
+ */
228
+ let permissionPreset;
229
+ const refreshPermissionPreset = () => {
230
+ const presets = ctx.get('permissionPresets');
231
+ const agent = bridge.getAgent();
232
+ permissionPreset = presets === undefined || agent === undefined
233
+ ? undefined
234
+ : presets.current(agent.session.events);
235
+ };
236
+ const commands = new CommandService(ctx, bridge);
237
+ // Wiring through the handle: the editor is rebuilt on theme hot-swap, and
238
+ // these providers are re-applied to the replacement instance.
239
+ ui.setEditorAutocompleteProvider(commands.autocompleteProvider());
240
+ // ------------------------------------------- TUI-owned slash commands --
241
+ // Web-surface parity: `model` is a browser client contribution there and
242
+ // `export` a web-only download plugin — the terminal gets native
243
+ // equivalents registered here, so the autocomplete catalog matches web.
244
+ // Agentless bodies are registered through both ctx.commands (discovery,
245
+ // lifecycle events against a live agent) and CommandService.registerLocal
246
+ // (direct dispatch when no agent exists — no throwaway session).
247
+ const modelHandler = async () => {
248
+ const picked = await pickModel(ctx, ui.tui, ui.theme, bridge.getSelection(), () => ui.tui.setFocus(ui.editor));
249
+ if (picked === undefined)
250
+ return { kind: 'success', text: 'Model unchanged.' };
251
+ const llm = ctx.get('llm');
252
+ if (llm !== undefined) {
253
+ await llm.resolveCallConfig({ provider: picked.provider, model: picked.model });
254
+ }
255
+ bridge.setSelection(picked);
256
+ const persistError = await persistDefaultModel(ctx, picked);
257
+ ui.requestRender();
258
+ const modelText = picked.reasoningEffort === undefined
259
+ ? `Model: ${picked.provider}/${picked.model}`
260
+ : `Model: ${picked.provider}/${picked.model} · think ${String(picked.reasoningEffort)}`;
261
+ return {
262
+ kind: 'success',
263
+ text: persistError === undefined ? modelText : `${modelText} · ⚠ not persisted: ${persistError}`,
264
+ };
265
+ };
266
+ commands.registerLocal('model', modelHandler);
267
+ ctx.effect(() => ctx.commands.register({
268
+ name: 'model',
269
+ description: 'Select the model (and think level) for this conversation',
270
+ handler: invocation => modelHandler(invocation.rawInput, invocation.signal),
271
+ }), 'dsh-tui-pi: /model');
272
+ // /think: cycle the current model's reasoning effort without re-picking
273
+ // the model. A no-session /think still lands in the selection ref and
274
+ // survives the lazy session creation (bridge seeds only an empty ref).
275
+ const thinkHandler = async () => {
276
+ const current = bridge.getSelection();
277
+ if (current === undefined) {
278
+ return { kind: 'error', text: 'No model selected — pick one with /model first.' };
279
+ }
280
+ const result = await pickEffort(ctx, ui.tui, ui.theme, current, () => ui.tui.setFocus(ui.editor));
281
+ if (result.kind === 'unsupported') {
282
+ return {
283
+ kind: 'error',
284
+ text: `${current.provider}/${current.model} exposes no selectable think levels.`,
285
+ };
286
+ }
287
+ if (result.kind === 'cancelled')
288
+ return { kind: 'success', text: 'Think level unchanged.' };
289
+ if (result.effort === 'default') {
290
+ const next = { provider: current.provider, model: current.model };
291
+ bridge.setSelection(next);
292
+ const persistError = await persistDefaultModel(ctx, next);
293
+ ui.requestRender();
294
+ const thinkText = `Think: provider default (${current.provider}/${current.model}).`;
295
+ return {
296
+ kind: 'success',
297
+ text: persistError === undefined ? thinkText : `${thinkText} · ⚠ not persisted: ${persistError}`,
298
+ };
299
+ }
300
+ const next = {
301
+ provider: current.provider,
302
+ model: current.model,
303
+ reasoningEffort: result.effort,
304
+ };
305
+ bridge.setSelection(next);
306
+ const persistError = await persistDefaultModel(ctx, next);
307
+ ui.requestRender();
308
+ const thinkText = `Think: ${String(result.effort)} (${current.provider}/${current.model}).`;
309
+ return {
310
+ kind: 'success',
311
+ text: persistError === undefined ? thinkText : `${thinkText} · ⚠ not persisted: ${persistError}`,
312
+ };
313
+ };
314
+ commands.registerLocal('think', thinkHandler);
315
+ ctx.effect(() => ctx.commands.register({
316
+ name: 'think',
317
+ description: 'Switch the current model\'s think (reasoning) level',
318
+ handler: invocation => thinkHandler(invocation.rawInput, invocation.signal),
319
+ }), 'dsh-tui-pi: /think');
320
+ const sessionHandler = async () => {
321
+ const agent = bridge.getAgent();
322
+ const stats = bridge.getStats();
323
+ const selection = bridge.getSelection();
324
+ const header = agent?.session.header;
325
+ await showSessionInfo(ui.tui, ui.theme, {
326
+ id: agent === undefined ? undefined : String(agent.session.id),
327
+ cwd: header?.cwd,
328
+ createdAt: header?.createdAt,
329
+ model: selection === undefined ? undefined : `${selection.provider}/${selection.model}`,
330
+ effort: selection === undefined || selection.reasoningEffort === undefined
331
+ ? (selection === undefined ? undefined : 'provider default')
332
+ : String(selection.reasoningEffort),
333
+ msgCount: stats.msgCount,
334
+ toolCallCount: stats.toolCallCount,
335
+ inputTokens: stats.inputTokens,
336
+ outputTokens: stats.outputTokens,
337
+ cacheReadTokens: stats.cacheReadTokens,
338
+ cacheWriteTokens: stats.cacheWriteTokens,
339
+ status: agent === undefined ? 'none' : agentStatus,
340
+ eventCount: agent === undefined ? undefined : agent.session.events.length,
341
+ parentSession: header?.parentSession === undefined ? undefined : String(header.parentSession),
342
+ }, () => ui.tui.setFocus(ui.editor));
343
+ return { kind: 'success', text: agent === undefined ? 'No active session.' : 'Session info shown.' };
344
+ };
345
+ commands.registerLocal('session', sessionHandler);
346
+ ctx.effect(() => ctx.commands.register({
347
+ name: 'session',
348
+ description: 'Show the current session\'s info (id, model, stats)',
349
+ handler: invocation => sessionHandler(invocation.rawInput, invocation.signal),
350
+ }), 'dsh-tui-pi: /session');
351
+ // /resume: pick a persisted session, validate its log, swap the live
352
+ // agent for it, and rebuild transcript + stats from the stored events.
353
+ const resumeHandler = async () => {
354
+ let picked;
355
+ try {
356
+ const currentId = bridge.getSessionId();
357
+ picked = await pickPersistedSession(ctx, ui.tui, ui.theme, currentId === undefined ? undefined : String(currentId), () => ui.tui.setFocus(ui.editor));
358
+ }
359
+ catch (error) {
360
+ const message = error instanceof Error ? error.message : String(error);
361
+ return { kind: 'error', text: message };
362
+ }
363
+ if (picked.kind === 'empty') {
364
+ return { kind: 'error', text: 'No other persisted sessions to resume.' };
365
+ }
366
+ if (picked.kind === 'cancelled')
367
+ return { kind: 'success', text: 'Resume cancelled.' };
368
+ // Validate the target log before tearing down the current agent: a
369
+ // corrupt log must leave the live session untouched.
370
+ try {
371
+ await inspectPersistedSession(ctx, picked.id);
372
+ }
373
+ catch (error) {
374
+ const message = error instanceof Error ? error.message : String(error);
375
+ return { kind: 'error', text: `Cannot resume ${clipToWidth(String(picked.id), 8)}: ${message}` };
376
+ }
377
+ let resumed;
378
+ try {
379
+ resumed = await bridge.resume(picked.id);
380
+ }
381
+ catch (error) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ return {
384
+ kind: 'error',
385
+ text: `Resume failed: ${message} — the previous session was closed; the next prompt starts a new one.`,
386
+ };
387
+ }
388
+ // Seed the badge cache for the resumed session (its pin event may have
389
+ // been emitted before the bridge's session-id filter re-bound).
390
+ refreshPermissionPreset();
391
+ // Clear BEFORE replay: the renderer's local-echo dedupe must not see
392
+ // replayed user messages next to a stale prompt echo. The live widget
393
+ // drops the previous session's todos too (its agents already went via
394
+ // the bridge's onLive([]) on resume reset).
395
+ renderer.clear();
396
+ liveWidgets.clear();
397
+ // Replay seed history only: events at or above firstLiveSeq were
398
+ // published in-process and arrive again through the session/event
399
+ // subscription (replaying them would double-count stats and echo).
400
+ // Seeds entered through construction never published — replaying them
401
+ // exactly once covers the stored log with zero overlap, zero gap.
402
+ const session = resumed.agent.session;
403
+ bridge.replay(session.events.filter(event => event.seq < session.firstLiveSeq));
404
+ ui.requestRender();
405
+ return {
406
+ kind: 'success',
407
+ text: `Resumed ${clipToWidth(String(picked.id), 8)} · ${session.events.length} events.`,
408
+ };
409
+ };
410
+ commands.registerLocal('resume', resumeHandler);
411
+ ctx.effect(() => ctx.commands.register({
412
+ name: 'resume',
413
+ description: 'Resume a persisted session',
414
+ handler: invocation => resumeHandler(invocation.rawInput, invocation.signal),
415
+ }), 'dsh-tui-pi: /resume');
416
+ // Agentless guard: dsh's own /export addresses a live agent and would
417
+ // mint a throwaway session just to report there is nothing to export —
418
+ // CommandService routes this locally only when no agent exists, and
419
+ // falls back to the dsh path when one does (behavior unchanged there).
420
+ commands.registerLocal('export', async () => ({
421
+ kind: 'error',
422
+ text: 'No active session to export.',
423
+ }));
424
+ ctx.effect(() => ctx.commands.register({
425
+ name: 'export',
426
+ description: 'Export this session log as JSONL',
427
+ input: { hint: '[path]' },
428
+ handler: async (invocation) => {
429
+ const events = invocation.agent.session.events;
430
+ const fallback = join(homedir(), 'Downloads', `dsh-session-${clipToWidth(String(invocation.agent.session.id), 8)}.jsonl`);
431
+ const target = invocation.rawInput.trim() === '' ? fallback : resolve(invocation.rawInput.trim());
432
+ await mkdir(dirname(target), { recursive: true });
433
+ await writeFile(target, events.map(event => JSON.stringify(event)).join('\n') + '\n');
434
+ return { kind: 'success', text: `Exported ${events.length} events → ${target}` };
435
+ },
436
+ }), 'dsh-tui-pi: /export');
437
+ // /new: detach the live agent (and clear the on-screen transcript) so
438
+ // the next prompt opens a brand-new session. The escape hatch for
439
+ // "current session has images in history, the next model doesn't accept
440
+ // them" — and any other case where the user wants a clean slate.
441
+ // detachCurrent keeps the event subscriptions alive (dispose() would
442
+ // splice them away and no future message would ever render again).
443
+ const newHandler = async () => {
444
+ try {
445
+ await bridge.detachCurrent();
446
+ }
447
+ catch { /* contained */ }
448
+ // No agent → the badge cache must not serve the old session's preset
449
+ // to a later one (the next prompt re-seeds it).
450
+ refreshPermissionPreset();
451
+ renderer.clear();
452
+ // The widget's agents already cleared via the bridge's onLive([]); drop
453
+ // the previous session's todos too.
454
+ liveWidgets.clear();
455
+ return { kind: 'success', text: 'New session started.' };
456
+ };
457
+ commands.registerLocal('new', newHandler);
458
+ ctx.effect(() => ctx.commands.register({
459
+ name: 'new',
460
+ description: 'Start a new session',
461
+ handler: invocation => newHandler(invocation.rawInput, invocation.signal),
462
+ }), 'dsh-tui-pi: /new');
463
+ // /settings: text-based configuration browser — the terminal counterpart
464
+ // of the web GUI's settings surface. Enumerates ctx.settings.describe()
465
+ // and walks each namespace's schema (drill-ins, cycle rows, inline editors,
466
+ // reset-to-defaults), writing through settings.mutate path ops.
467
+ const settingsHandler = async () => {
468
+ if (ctx.get('settings') === undefined) {
469
+ return { kind: 'error', text: 'Settings service is not available.' };
470
+ }
471
+ const changes = await openSettingsBrowser({
472
+ ctx,
473
+ tui: ui.tui,
474
+ theme: ui.theme,
475
+ restoreFocus: () => ui.tui.setFocus(ui.editor),
476
+ onError: message => {
477
+ // Buffered notice: the settings browser outlives the write, so the
478
+ // line must survive a theme hot-swap (the doc.clear() rebuild).
479
+ renderer.renderNotice(message, 'error');
480
+ },
481
+ });
482
+ if (changes < 0)
483
+ return { kind: 'error', text: 'No settings namespaces are registered.' };
484
+ return {
485
+ kind: 'success',
486
+ text: changes === 0
487
+ ? 'Settings: no changes.'
488
+ : `Settings: ${changes} change${changes === 1 ? '' : 's'} applied.`,
489
+ };
490
+ };
491
+ commands.registerLocal('settings', settingsHandler);
492
+ ctx.effect(() => ctx.commands.register({
493
+ name: 'settings',
494
+ description: 'Browse and edit configuration (namespaces, values, resets)',
495
+ handler: invocation => settingsHandler(invocation.rawInput, invocation.signal),
496
+ }), 'dsh-tui-pi: /settings');
497
+ // /theme: pick a color scheme and apply it immediately — the choice is
498
+ // persisted to the dsh-tui settings namespace (`applies: 'live'`, so the
499
+ // watch hook would re-apply the same change anyway) and hot-swapped into
500
+ // the running TUI: footer hint, editor border and the whole transcript
501
+ // repaint on the next frame, no restart needed. The settings guard
502
+ // mirrors /settings: without the service there is nowhere to write.
503
+ const themeHandler = async () => {
504
+ if (ctx.get('settings') === undefined) {
505
+ return { kind: 'error', text: 'Settings service is not available.' };
506
+ }
507
+ // Preselect from the live settings value (which may have changed since
508
+ // startup via the /settings browser), not the startup snapshot.
509
+ const picked = await pickTheme(ui.tui, ui.theme, currentThemePreference(ctx), () => ui.tui.setFocus(ui.editor));
510
+ if (picked === undefined)
511
+ return { kind: 'success', text: 'Theme unchanged.' };
512
+ const writeError = await writeThemePreference(ctx, picked);
513
+ if (writeError !== undefined)
514
+ return { kind: 'error', text: writeError };
515
+ // DSH_TUI_THEME pins the display regardless of the preference — don't
516
+ // claim the pick was applied when it wasn't. The preference is still
517
+ // persisted (and shown once the env override is dropped); the notice
518
+ // goes through the buffered command echo, so no replay issue.
519
+ const applied = resolveTheme(process.env, picked);
520
+ applyTheme(applied);
521
+ const expected = picked === 'light' ? lightTheme : picked === 'dark' ? darkTheme : undefined;
522
+ if (expected !== undefined && applied !== expected) {
523
+ return {
524
+ kind: 'success',
525
+ text: `Theme preference saved — display is pinned by DSH_TUI_THEME=${process.env.DSH_TUI_THEME}`,
526
+ };
527
+ }
528
+ return { kind: 'success', text: `Theme: ${picked} — applied.` };
529
+ };
530
+ commands.registerLocal('theme', themeHandler);
531
+ ctx.effect(() => ctx.commands.register({
532
+ name: 'theme',
533
+ description: 'Set the terminal color scheme (applies immediately)',
534
+ handler: invocation => themeHandler(invocation.rawInput, invocation.signal),
535
+ }), 'dsh-tui-pi: /theme');
536
+ // /reload: hot-reload this plugin from the current source — re-imports the
537
+ // module and its dependencies (picking up src changes after `pnpm build`)
538
+ // and swaps the plugin runtime. The old fiber is disposed, so the TUI and
539
+ // the live agent bridge are torn down; the session log persists and can be
540
+ // rejoined with /resume. Failures before the swap leave the TUI untouched.
541
+ const reloadHandler = async () => ({
542
+ kind: 'success',
543
+ text: await reloadPlugin(ctx, import.meta.url),
544
+ });
545
+ commands.registerLocal('reload', reloadHandler);
546
+ ctx.effect(() => ctx.commands.register({
547
+ name: 'reload',
548
+ description: 'Reload the TUI from the current source (apply code changes without restarting dsh)',
549
+ handler: invocation => reloadHandler(invocation.rawInput, invocation.signal),
550
+ }), 'dsh-tui-pi: /reload');
551
+ // ------------------------------------------------- powerline footer + git --
552
+ const git = new GitBranchWatcher(process.cwd());
553
+ git.onChange = () => ui.requestRender();
554
+ ui.setEditorBranchProvider(() => git.getBranch());
555
+ // Permission badge: the live session's effective preset under the web
556
+ // client's display conventions (danger-full-access → "Full access").
557
+ // Reads the cached current preset — O(1) per render, never a session-log
558
+ // fold (that lives in refreshPermissionPreset, once per session/knob
559
+ // change). Falls back to a live fold only while the cache is unprimed:
560
+ // the session's initial pin event can be dropped by the bridge's
561
+ // session-id filter (which binds only after agent creation completes),
562
+ // so until the ensure/resume seed lands, the fallback keeps the badge
563
+ // correct at the price of one fold per frame — bounded by the short
564
+ // creation window.
565
+ ui.setEditorPermissionProvider(() => {
566
+ const presets = ctx.get('permissionPresets');
567
+ const agent = bridge.getAgent();
568
+ if (presets === undefined || agent === undefined)
569
+ return undefined;
570
+ const current = permissionPreset ?? presets.current(agent.session.events);
571
+ return displayPermissionPreset(current, presets.optionOf(current).name);
572
+ });
573
+ let contextWindow;
574
+ let contextWindowKey = '';
575
+ const footerSource = {
576
+ getStats: () => bridge.getStats(),
577
+ getSelection: () => bridge.getSelection(),
578
+ getBranch: () => git.getBranch(),
579
+ getContextWindow: () => {
580
+ const selection = bridge.getSelection();
581
+ if (selection === undefined)
582
+ return undefined;
583
+ const key = `${selection.provider}/${selection.model}`;
584
+ if (key !== contextWindowKey) {
585
+ contextWindowKey = key;
586
+ contextWindow = undefined;
587
+ const llm = ctx.get('llm');
588
+ if (llm !== undefined) {
589
+ void llm.resolveModelInfo(selection.provider, selection.model).then(info => {
590
+ const window = info.context?.contextWindow;
591
+ if (contextWindowKey === key && typeof window === 'number') {
592
+ contextWindow = window;
593
+ ui.requestRender();
594
+ }
595
+ }).catch(() => { });
596
+ }
597
+ }
598
+ return contextWindow;
599
+ },
600
+ };
601
+ ui.footer.clear();
602
+ ui.footer.addChild(new PowerlineFooter(footerSource));
603
+ // Keybinding hint, added *after* the clear above: the placeholder line in
604
+ // tui.ts used to be wiped before first render. paddingX 1 aligns the text
605
+ // with the powerline segment labels (each starts with a leading space).
606
+ // The hint is the footer stack's only theme-dependent piece (the powerline
607
+ // segments carry fixed theme-agnostic colors) — `paintFooterHint` re-colors
608
+ // it under a theme hot-swap; the PowerlineFooter itself needs no rebuild.
609
+ const footerHint = new Text('', 1, 0);
610
+ const paintFooterHint = () => {
611
+ footerHint.setText(ansiFg(ui.theme.palette.fgSubtle) + '⌨ Enter: send · Ctrl+C: cancel / double: quit' + RESET);
612
+ };
613
+ paintFooterHint();
614
+ ui.footer.addChild(footerHint);
615
+ /**
616
+ * Hot-apply a theme bundle to the running TUI: the transcript rebuilds
617
+ * from its replay buffer, the dock/editor swap to the new bundle, and the
618
+ * spinner (when mid-turn) is recreated with the new accent. Per-piece
619
+ * requestRenders coalesce into the single next-throttled frame, so the
620
+ * switch repaints once. No-op on the unchanged bundle — the settings
621
+ * watch echoes this TUI's own /theme write, and the theme modules are
622
+ * singletons.
623
+ */
624
+ const applyTheme = (theme) => {
625
+ if (theme === ui.theme)
626
+ return;
627
+ renderer.setTheme(theme);
628
+ liveWidgets.setTheme(theme);
629
+ ui.applyTheme(theme);
630
+ paintFooterHint();
631
+ if (loader !== undefined) {
632
+ loader.stop();
633
+ ui.status.removeChild(loader);
634
+ loader = undefined;
635
+ setStatus(agentStatus);
636
+ }
637
+ };
638
+ // Live clock: the footer is the only thing that changes each second.
639
+ const clockTimer = setInterval(() => ui.requestRender(), 1000);
640
+ clockTimer.unref?.();
641
+ // Live Todos/Agents widget: spinner + elapsed refresh ~10x/sec while
642
+ // children run (tickLive no-ops when nothing runs).
643
+ const liveTimer = setInterval(() => liveWidgets.tickLive(), AGENT_TICK_MS);
644
+ liveTimer.unref?.();
645
+ /**
646
+ * Modal commands keep an overlay open for as long as the user browses
647
+ * (model/effort pickers, settings browser, session panel, resume list).
648
+ * The generic 30s guard would fire mid-session and echo a spurious
649
+ * "aborted due to timeout" — those run with a never-aborting signal
650
+ * instead.
651
+ */
652
+ const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'theme', 'permission']);
653
+ /** Route one submitted line: dsh slash command first, model prompt second. */
654
+ const submit = async (text) => {
655
+ const line = text.trim();
656
+ if (line === '')
657
+ return;
658
+ ui.setLastRequest(line);
659
+ const tokens = line.startsWith('/') ? line.slice(1).split(/\s+/) : [];
660
+ const name = tokens[0]?.toLowerCase();
661
+ let executeLine = line;
662
+ // Bare /permission (no arguments) opens the preset picker — UI sugar
663
+ // over dsh's canonical command: a picked row is replayed as
664
+ // `/permission <name>` through the normal execute path below, so the
665
+ // switch stays canonical while the echo keeps the user's original line.
666
+ // `/permission <name>` passes straight through. Without a composed
667
+ // preset service there is nothing to pick and the bare line falls
668
+ // through to the model like any other unregistered command.
669
+ if (name === 'permission' && tokens.length === 1) {
670
+ const presets = ctx.get('permissionPresets');
671
+ if (presets !== undefined) {
672
+ const agent = bridge.getAgent();
673
+ const current = agent === undefined ? undefined : presets.current(agent.session.events);
674
+ let picked;
675
+ try {
676
+ picked = await pickPermission(ctx, ui.tui, ui.theme, current, () => ui.tui.setFocus(ui.editor));
677
+ }
678
+ catch (error) {
679
+ // Picker failure (preset service or overlay error) — pickPermission
680
+ // restores focus itself before rejecting, so the editor is usable
681
+ // again; surface the failure in the transcript like every other
682
+ // dispatch error. Buffered notice: this line is the only record of
683
+ // the failure and must survive a theme-switch rebuild (doc.clear()).
684
+ const message = error instanceof Error ? error.message : String(error);
685
+ renderer.renderNotice(message, 'error');
686
+ return;
687
+ }
688
+ if (picked === undefined || picked === 'custom') {
689
+ // Cancelled — or the derived custom state, which is display-only
690
+ // and not a switch target (mirrors the web client's popup
691
+ // filtering). Either way nothing changed.
692
+ renderer.renderCommandEcho(line, undefined, 'Permission unchanged.');
693
+ return;
694
+ }
695
+ executeLine = `/permission ${picked}`;
696
+ }
697
+ }
698
+ const signal = name !== undefined && MODAL_COMMANDS.has(name)
699
+ ? new AbortController().signal
700
+ : AbortSignal.timeout(30_000);
701
+ let command;
702
+ try {
703
+ command = await commands.tryExecute(executeLine, signal);
704
+ }
705
+ catch (error) {
706
+ // Dispatch itself failed (outside every contained path) — surface in
707
+ // the transcript instead of an unhandled rejection killing the TUI.
708
+ // Buffered notice: this line is the only record of the failure and
709
+ // must survive a theme-switch rebuild (doc.clear()).
710
+ const message = error instanceof Error ? error.message : String(error);
711
+ renderer.renderNotice(message, 'error');
712
+ return;
713
+ }
714
+ if (command.handled) {
715
+ renderer.renderCommandEcho(line, command.error, command.text);
716
+ return;
717
+ }
718
+ renderer.renderPromptEcho(line);
719
+ try {
720
+ await bridge.prompt(line);
721
+ }
722
+ catch (error) {
723
+ // Buffered notice: the failure line is the only on-screen record and
724
+ // must survive a theme-switch rebuild (doc.clear()).
725
+ const message = error instanceof Error ? error.message : String(error);
726
+ renderer.renderNotice(message, 'error');
727
+ }
728
+ // Session (re)created by the prompt: seed the badge cache. The initial
729
+ // permission pin event was likely dropped by the session-id filter,
730
+ // which binds only after agent creation completes.
731
+ refreshPermissionPreset();
732
+ };
733
+ /**
734
+ * The TUI owns the product lifetime in this profile (openma contract):
735
+ * dispose the agent, then the whole root runtime, then exit. Runs once —
736
+ * concurrent Ctrl+C presses share the single shutdown task.
737
+ */
738
+ let exitTask;
739
+ const disposeAndExit = async (code) => {
740
+ exitTask ??= (async () => {
741
+ clearInterval(clockTimer);
742
+ clearInterval(liveTimer);
743
+ git.dispose();
744
+ try {
745
+ await bridge.dispose();
746
+ }
747
+ catch { /* contained */ }
748
+ ui.dispose();
749
+ try {
750
+ await ctx.root.fiber.dispose();
751
+ }
752
+ catch { /* contained */ }
753
+ process.exit(code);
754
+ })();
755
+ return exitTask;
756
+ };
757
+ return async () => {
758
+ if (relayoutTimer !== undefined)
759
+ clearTimeout(relayoutTimer);
760
+ process.stdout.removeListener('resize', onResize);
761
+ clearInterval(clockTimer);
762
+ clearInterval(liveTimer);
763
+ git.dispose();
764
+ applyThemeRef = undefined;
765
+ applyPanelHeightRef = undefined;
766
+ // Stop the TUI FIRST, before the (possibly slow) agent teardown: the
767
+ // terminal must be released while the fiber is still alone with it.
768
+ // Deferring tui.stop() until after `await bridge.dispose()` lets any
769
+ // fire-and-forget disposal (e.g. /reload's fiber swap) start a fresh
770
+ // TUI while this one still holds the terminal — the late stop then
771
+ // disables raw mode and pauses stdin out from under the new TUI.
772
+ handle?.dispose();
773
+ handle = undefined;
774
+ try {
775
+ await bridge.dispose();
776
+ }
777
+ catch { /* contained */ }
778
+ };
779
+ }
780
+ }
781
+ //# sourceMappingURL=index.js.map