@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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/dsh-tui-pi +5 -0
- package/cordis.patch.yml +9 -0
- package/lib/append-system.d.ts +66 -0
- package/lib/append-system.js +161 -0
- package/lib/append-system.js.map +1 -0
- package/lib/commands.d.ts +53 -0
- package/lib/commands.js +167 -0
- package/lib/commands.js.map +1 -0
- package/lib/dsh-events.d.ts +106 -0
- package/lib/dsh-events.js +30 -0
- package/lib/dsh-events.js.map +1 -0
- package/lib/editor.d.ts +28 -0
- package/lib/editor.js +70 -0
- package/lib/editor.js.map +1 -0
- package/lib/footer.d.ts +36 -0
- package/lib/footer.js +112 -0
- package/lib/footer.js.map +1 -0
- package/lib/frame.d.ts +35 -0
- package/lib/frame.js +75 -0
- package/lib/frame.js.map +1 -0
- package/lib/git.d.ts +17 -0
- package/lib/git.js +51 -0
- package/lib/git.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +781 -0
- package/lib/index.js.map +1 -0
- package/lib/instructions.d.ts +29 -0
- package/lib/instructions.js +67 -0
- package/lib/instructions.js.map +1 -0
- package/lib/live-widgets.d.ts +85 -0
- package/lib/live-widgets.js +218 -0
- package/lib/live-widgets.js.map +1 -0
- package/lib/messages.d.ts +277 -0
- package/lib/messages.js +734 -0
- package/lib/messages.js.map +1 -0
- package/lib/permission.d.ts +27 -0
- package/lib/permission.js +48 -0
- package/lib/permission.js.map +1 -0
- package/lib/provider-catalog.d.ts +114 -0
- package/lib/provider-catalog.js +124 -0
- package/lib/provider-catalog.js.map +1 -0
- package/lib/quotes.d.ts +28 -0
- package/lib/quotes.js +144 -0
- package/lib/quotes.js.map +1 -0
- package/lib/reload.d.ts +23 -0
- package/lib/reload.js +171 -0
- package/lib/reload.js.map +1 -0
- package/lib/selectors.d.ts +48 -0
- package/lib/selectors.js +261 -0
- package/lib/selectors.js.map +1 -0
- package/lib/session.d.ts +157 -0
- package/lib/session.js +555 -0
- package/lib/session.js.map +1 -0
- package/lib/sessions.d.ts +73 -0
- package/lib/sessions.js +253 -0
- package/lib/sessions.js.map +1 -0
- package/lib/settings.d.ts +180 -0
- package/lib/settings.js +1328 -0
- package/lib/settings.js.map +1 -0
- package/lib/text.d.ts +22 -0
- package/lib/text.js +45 -0
- package/lib/text.js.map +1 -0
- package/lib/theme/index.d.ts +79 -0
- package/lib/theme/index.js +121 -0
- package/lib/theme/index.js.map +1 -0
- package/lib/theme/palette.d.ts +56 -0
- package/lib/theme/palette.js +154 -0
- package/lib/theme/palette.js.map +1 -0
- package/lib/theme-settings.d.ts +68 -0
- package/lib/theme-settings.js +223 -0
- package/lib/theme-settings.js.map +1 -0
- package/lib/tui.d.ts +70 -0
- package/lib/tui.js +206 -0
- package/lib/tui.js.map +1 -0
- package/lib/welcome.d.ts +91 -0
- package/lib/welcome.js +281 -0
- package/lib/welcome.js.map +1 -0
- package/package.json +50 -0
- package/patches/@earendil-works__pi-tui.patch +72 -0
- package/pnpm-workspace.yaml +2 -0
- 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
|