@aiwayds/dsh-tui-pi 0.1.0 → 0.3.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/README.md +205 -68
- package/lib/agent-manager.d.ts +125 -0
- package/lib/agent-manager.js +298 -0
- package/lib/agent-manager.js.map +1 -0
- package/lib/agents.d.ts +30 -0
- package/lib/agents.js +468 -0
- package/lib/agents.js.map +1 -0
- package/lib/append-system.d.ts +2 -0
- package/lib/append-system.js +1 -1
- package/lib/append-system.js.map +1 -1
- package/lib/dsh-events.d.ts +12 -0
- package/lib/editor.d.ts +58 -0
- package/lib/editor.js +92 -0
- package/lib/editor.js.map +1 -1
- package/lib/footer.d.ts +8 -0
- package/lib/footer.js +8 -0
- package/lib/footer.js.map +1 -1
- package/lib/hotkeys.d.ts +93 -0
- package/lib/hotkeys.js +305 -0
- package/lib/hotkeys.js.map +1 -0
- package/lib/index.js +342 -38
- package/lib/index.js.map +1 -1
- package/lib/keymap.d.ts +177 -0
- package/lib/keymap.js +189 -0
- package/lib/keymap.js.map +1 -0
- package/lib/live-widgets.d.ts +82 -29
- package/lib/live-widgets.js +222 -92
- package/lib/live-widgets.js.map +1 -1
- package/lib/messages.d.ts +4 -2
- package/lib/messages.js +8 -3
- package/lib/messages.js.map +1 -1
- package/lib/panels.d.ts +177 -0
- package/lib/panels.js +331 -0
- package/lib/panels.js.map +1 -0
- package/lib/provider-catalog.d.ts +31 -8
- package/lib/provider-catalog.js +63 -8
- package/lib/provider-catalog.js.map +1 -1
- package/lib/selectors.d.ts +12 -1
- package/lib/selectors.js +5 -2
- package/lib/selectors.js.map +1 -1
- package/lib/session.d.ts +36 -0
- package/lib/session.js +171 -10
- package/lib/session.js.map +1 -1
- package/lib/settings.d.ts +1 -2
- package/lib/settings.js +29 -2
- package/lib/settings.js.map +1 -1
- package/lib/subagent-policy.d.ts +58 -0
- package/lib/subagent-policy.js +137 -0
- package/lib/subagent-policy.js.map +1 -0
- package/lib/subagent-viewer.d.ts +49 -0
- package/lib/subagent-viewer.js +523 -0
- package/lib/subagent-viewer.js.map +1 -0
- package/lib/theme/index.js +4 -0
- package/lib/theme/index.js.map +1 -1
- package/lib/theme/palette.d.ts +21 -3
- package/lib/theme/palette.js +27 -6
- package/lib/theme/palette.js.map +1 -1
- package/lib/theme-settings.d.ts +39 -4
- package/lib/theme-settings.js +69 -12
- package/lib/theme-settings.js.map +1 -1
- package/lib/tui.d.ts +32 -12
- package/lib/tui.js +109 -36
- package/lib/tui.js.map +1 -1
- package/package.json +3 -4
- package/patches/@earendil-works__pi-tui.patch +206 -2
- package/scripts/dev-install.mjs +87 -0
- package/scripts/link-dsh-closure.mjs +85 -0
package/lib/index.js
CHANGED
|
@@ -13,22 +13,44 @@ import { homedir } from 'node:os';
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { Loader, Text } from '@earendil-works/pi-tui';
|
|
15
15
|
import { CommandService } from "./commands.js";
|
|
16
|
-
import { PowerlineFooter } from "./footer.js";
|
|
16
|
+
import { FOOTER_HINT, PowerlineFooter } from "./footer.js";
|
|
17
17
|
import { GitBranchWatcher } from "./git.js";
|
|
18
|
-
import { ensureAppendSystemFile, migrateAgentsMdTodoSection, readAppendSystem } from "./append-system.js";
|
|
18
|
+
import { ensureAppendSystemFile, dshHome, migrateAgentsMdTodoSection, readAppendSystem } from "./append-system.js";
|
|
19
19
|
import { TranscriptRenderer } from "./messages.js";
|
|
20
20
|
import { AGENT_TICK_MS, LiveWidgets } from "./live-widgets.js";
|
|
21
21
|
import { displayPermissionPreset } from "./permission.js";
|
|
22
22
|
import { pickEffort, pickModel, pickPermission, pickTheme } from "./selectors.js";
|
|
23
|
-
import { DshSessionBridge, persistDefaultModel } from "./session.js";
|
|
23
|
+
import { DshSessionBridge, persistDefaultModel, stashSessionIdForReload, takeStashedSessionId } from "./session.js";
|
|
24
24
|
import { currentThemePreference, readPanelHeightPreference, readThemePreference, registerThemeSettings, writeThemePreference, } from "./theme-settings.js";
|
|
25
|
+
import { openAgentManager } from "./agents.js";
|
|
25
26
|
import { openSettingsBrowser } from "./settings.js";
|
|
26
27
|
import { reloadPlugin } from "./reload.js";
|
|
27
28
|
import { inspectPersistedSession, pickPersistedSession, showSessionInfo } from "./sessions.js";
|
|
28
|
-
import {
|
|
29
|
+
import { applySubagentPolicy } from "./subagent-policy.js";
|
|
30
|
+
import { openSubagentViewer } from "./subagent-viewer.js";
|
|
31
|
+
import { ansiFg, BOLD, darkTheme, lightTheme, RESET, resolveTheme } from "./theme/index.js";
|
|
32
|
+
import { rgbIsLight } from "./theme/palette.js";
|
|
29
33
|
import { clipToWidth } from "./text.js";
|
|
34
|
+
import {} from "./keymap.js";
|
|
35
|
+
import { keybindingsPath, loadKeyBindings, openHotkeysManager } from "./hotkeys.js";
|
|
30
36
|
import { startTui } from "./tui.js";
|
|
31
37
|
export const name = 'dsh-tui-pi';
|
|
38
|
+
/**
|
|
39
|
+
* Delay (ms) before a double-Ctrl+C quit actually fires — a held key's first
|
|
40
|
+
* auto-repeat lands inside the 500ms double-press window looking like a
|
|
41
|
+
* deliberate second press, and a follow-up `key-repeat` (arriving within
|
|
42
|
+
* ~30-50ms) aborts the quit before this timer ever fires.
|
|
43
|
+
*/
|
|
44
|
+
const QUIT_CONFIRM_MS = 200;
|
|
45
|
+
/**
|
|
46
|
+
* Delay (ms) before the double-Esc stop actually fires — the same held-key
|
|
47
|
+
* defence as the Ctrl+C quit: the OS repeat delay (~183ms-2s) puts the first
|
|
48
|
+
* auto-repeat of a held Esc exactly at the 500ms stop-window boundary; the
|
|
49
|
+
* stop is confirmed for this long and a follow-up `key-repeat` (betraying
|
|
50
|
+
* the held key) aborts it. A second HUMAN-speed press during the window
|
|
51
|
+
* fires the stop immediately (see `interrupt-cancel`).
|
|
52
|
+
*/
|
|
53
|
+
const STOP_CONFIRM_MS = 200;
|
|
32
54
|
/** The TUI drives the agent factory and registers slash commands. */
|
|
33
55
|
export const inject = ['agents', 'commands', 'systemPrompt'];
|
|
34
56
|
export function apply(ctx) {
|
|
@@ -105,48 +127,187 @@ export function apply(ctx) {
|
|
|
105
127
|
* the effect disposer handed back to cordis on teardown.
|
|
106
128
|
*/
|
|
107
129
|
function runTui(themePreference, panelHeight) {
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
// User keybindings (`~/.dsh/keybindings.json`): a partial map of the app
|
|
131
|
+
// keys, read once per TUI start — `/reload` re-runs apply() and re-reads
|
|
132
|
+
// it. Broken entries surface as notices instead of breaking startup.
|
|
133
|
+
const keyFile = keybindingsPath(dshHome());
|
|
134
|
+
const keyBindings = loadKeyBindings(keyFile);
|
|
113
135
|
const ui = startTui({
|
|
114
136
|
onSubmit: text => {
|
|
115
137
|
void submit(text);
|
|
116
138
|
},
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
keyBindings: keyBindings.bindings,
|
|
140
|
+
// App-level keys (pi interrupt chain): Esc on a running task is a
|
|
141
|
+
// deliberate double-press — the first press arms the window (with
|
|
142
|
+
// feedback), the second within 500ms stops the whole task (parent +
|
|
143
|
+
// subagents); popup/autocomplete first, anti-misfire no-op on a
|
|
144
|
+
// non-empty editor, and an empty idle editor double-Esc opens /session.
|
|
145
|
+
// Ctrl+C cancels a running turn or clears the editor — a second press
|
|
146
|
+
// within 500ms quits — and Ctrl+D quits only on an empty editor.
|
|
147
|
+
// Ctrl+G opens the subagent picker while children run. All decisions
|
|
148
|
+
// live in keymap.ts.
|
|
149
|
+
isRunning: () => bridge.isRunning(),
|
|
150
|
+
getRunningAgents: () => bridge.getLiveChildren().length,
|
|
151
|
+
onKeyAction: (action) => {
|
|
152
|
+
switch (action.kind) {
|
|
153
|
+
case 'interrupt-arm-stop':
|
|
154
|
+
// First Esc while the task runs: the stop is ARMED, not fired —
|
|
155
|
+
// a second Esc within the window confirms it, so a stray Esc can
|
|
156
|
+
// never kill a running turn. The notice keeps the armed first
|
|
157
|
+
// press from feeling dead and states the contract explicitly.
|
|
158
|
+
renderer.renderNotice('Press Esc again to stop the current task', 'info');
|
|
159
|
+
break;
|
|
160
|
+
case 'interrupt-cancel': {
|
|
161
|
+
// Second Esc while the task runs (parent + subagents). Like the
|
|
162
|
+
// Ctrl+C quit, the stop is CONFIRMED, not immediate: a held
|
|
163
|
+
// Esc's FIRST auto-repeat lands at the OS repeat delay (right at
|
|
164
|
+
// the 500ms window boundary) looking exactly like a deliberate
|
|
165
|
+
// second press. The stop fires after STOP_CONFIRM_MS unless a
|
|
166
|
+
// follow-up `key-repeat` aborts it (a repeat betrays a held key),
|
|
167
|
+
// while another HUMAN-speed press during the window stops right
|
|
168
|
+
// away. The '⏹ stopping…' notice renders now, so the visible
|
|
169
|
+
// feedback is immediate even though the cancellation (and its
|
|
170
|
+
// '⏹ canceling current turn…' notice inside `stopTask`) is
|
|
171
|
+
// deferred by the confirm window.
|
|
172
|
+
if (stopConfirmTimer !== undefined) {
|
|
173
|
+
clearTimeout(stopConfirmTimer);
|
|
174
|
+
stopConfirmTimer = undefined;
|
|
175
|
+
void stopTask();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
renderer.renderNotice('⏹ stopping — Esc was double-pressed', 'info');
|
|
179
|
+
stopConfirmTimer = setTimeout(() => {
|
|
180
|
+
stopConfirmTimer = undefined;
|
|
181
|
+
void stopTask();
|
|
182
|
+
}, STOP_CONFIRM_MS);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'ctrl-c-cancel': {
|
|
186
|
+
// First Ctrl+C mid-turn: cancel straight away with the same
|
|
187
|
+
// on-screen feedback as Esc ×2 (mirrors the web client's stop
|
|
188
|
+
// button; keepInbox preserves the queue).
|
|
189
|
+
void stopTask();
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'interrupt-double':
|
|
193
|
+
// Double-Esc on an empty editor: pi's default double action is
|
|
194
|
+
// /tree; dsh has no /tree, so this opens /session (the session
|
|
195
|
+
// info panel — the closest non-invasive analogue).
|
|
196
|
+
void sessionHandler('', new AbortController().signal);
|
|
197
|
+
break;
|
|
198
|
+
case 'ctrl-c-clear':
|
|
199
|
+
// First Ctrl+C while idle: clear the editor (pi app.clear); a
|
|
200
|
+
// second press within 500ms quits.
|
|
201
|
+
ui.editor.setText('');
|
|
202
|
+
ui.requestRender();
|
|
203
|
+
break;
|
|
204
|
+
case 'ctrl-c-quit': {
|
|
205
|
+
// Double Ctrl+C quits — but a held key must not. The terminal
|
|
206
|
+
// re-sends a held Ctrl+C every ~30-50ms after the OS repeat delay,
|
|
207
|
+
// and the FIRST repeat can land inside the 500ms double-press
|
|
208
|
+
// window looking exactly like a deliberate second press. So the
|
|
209
|
+
// quit is confirmed, not immediate: it fires after
|
|
210
|
+
// QUIT_CONFIRM_MS unless a `key-repeat` arrives (a repeat betrays
|
|
211
|
+
// a held key → abort), while another HUMAN-speed press during the
|
|
212
|
+
// window (a deliberate mash) quits right away.
|
|
213
|
+
if (quitConfirmTimer !== undefined) {
|
|
214
|
+
clearTimeout(quitConfirmTimer);
|
|
215
|
+
quitConfirmTimer = undefined;
|
|
130
216
|
void disposeAndExit(0);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
renderer.renderNotice('⏹ Ctrl+C ×2 — quitting…', 'info');
|
|
220
|
+
quitConfirmTimer = setTimeout(() => {
|
|
221
|
+
quitConfirmTimer = undefined;
|
|
222
|
+
void disposeAndExit(0);
|
|
223
|
+
}, QUIT_CONFIRM_MS);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case 'key-repeat':
|
|
227
|
+
// Auto-repeat of a held key — betrays a hold, never a double:
|
|
228
|
+
// abort a pending Ctrl+C quit confirmation and/or a pending
|
|
229
|
+
// double-Esc stop confirmation.
|
|
230
|
+
if (action.key === 'ctrl-c' && quitConfirmTimer !== undefined) {
|
|
231
|
+
clearTimeout(quitConfirmTimer);
|
|
232
|
+
quitConfirmTimer = undefined;
|
|
233
|
+
renderer.renderNotice('quit aborted — Ctrl+C was held, not double-pressed', 'info');
|
|
234
|
+
}
|
|
235
|
+
if (action.key === 'escape' && stopConfirmTimer !== undefined) {
|
|
236
|
+
clearTimeout(stopConfirmTimer);
|
|
237
|
+
stopConfirmTimer = undefined;
|
|
238
|
+
renderer.renderNotice('stop aborted — Esc was held, not double-pressed', 'info');
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case 'ctrl-d-quit':
|
|
242
|
+
void disposeAndExit(0);
|
|
243
|
+
break;
|
|
244
|
+
case 'model-picker':
|
|
245
|
+
// Ctrl+L: pi app.model.select — open the model/think picker.
|
|
246
|
+
void modelHandler('', new AbortController().signal);
|
|
247
|
+
break;
|
|
248
|
+
case 'subagent-viewer':
|
|
249
|
+
// Ctrl+G: open the subagent picker → transcript viewer while
|
|
250
|
+
// children run (modal overlay; Esc / double-x closes).
|
|
251
|
+
void openSubagentViewer(ctx, ui.tui, ui.theme, bridge, () => ui.tui.setFocus(ui.editor));
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
break;
|
|
135
255
|
}
|
|
136
256
|
},
|
|
137
257
|
themePreference,
|
|
138
258
|
});
|
|
139
259
|
handle = ui;
|
|
260
|
+
// Live theme preference, tracked so terminal-following (below) can tell
|
|
261
|
+
// 'auto' (follow the terminal) from an explicit light/dark pin.
|
|
262
|
+
let themePreferenceRef = themePreference;
|
|
263
|
+
/**
|
|
264
|
+
* Pending Ctrl+C quit confirmation (see the `ctrl-c-quit` case): armed by
|
|
265
|
+
* a double press, fired after QUIT_CONFIRM_MS, aborted by a `key-repeat`
|
|
266
|
+
* (held key) or torn down with the TUI.
|
|
267
|
+
*/
|
|
268
|
+
let quitConfirmTimer;
|
|
269
|
+
/**
|
|
270
|
+
* Pending double-Esc stop confirmation (see the `interrupt-cancel` case):
|
|
271
|
+
* armed by the second Esc while running, fired after STOP_CONFIRM_MS,
|
|
272
|
+
* aborted by a `key-repeat` (held key) or torn down with the TUI. The
|
|
273
|
+
* Ctrl+C quit and the Esc stop never share a timer — distinct keys, both
|
|
274
|
+
* could theoretically be in flight.
|
|
275
|
+
*/
|
|
276
|
+
let stopConfirmTimer;
|
|
277
|
+
/**
|
|
278
|
+
* Stop the whole task (parent turn + subagents), mirroring the web
|
|
279
|
+
* client's stop button: `agent.cancel({ kind: 'user' }, { keepInbox:
|
|
280
|
+
* true })` via the bridge. Deferred by the confirm windows above (the
|
|
281
|
+
* ESC/Ctrl+C double-press guards); the notice renders immediately.
|
|
282
|
+
*/
|
|
283
|
+
const stopTask = async () => {
|
|
284
|
+
renderer.renderNotice('⏹ canceling current turn…', 'info');
|
|
285
|
+
const cancelled = await bridge.cancelActiveTurn();
|
|
286
|
+
// State raced idle between the decision and the cancel call — nothing
|
|
287
|
+
// to cancel (e.g. the turn settled inside the confirm window).
|
|
288
|
+
if (!cancelled)
|
|
289
|
+
renderer.renderNotice('Nothing running to cancel.', 'info');
|
|
290
|
+
};
|
|
140
291
|
// Arm the settings watch sink now that the renderer exists (see apply()).
|
|
141
292
|
applyThemeRef = (pref) => {
|
|
293
|
+
themePreferenceRef = pref;
|
|
294
|
+
// 'auto' subscribes to the terminal's live color-scheme pushes; an
|
|
295
|
+
// explicit pin opts out. resolveTheme guards env-pinned displays.
|
|
296
|
+
ui.tui.setTerminalColorSchemeNotifications(pref === 'auto');
|
|
142
297
|
applyTheme(resolveTheme(process.env, pref));
|
|
143
298
|
};
|
|
144
299
|
const renderer = new TranscriptRenderer(ui.transcript, ui.theme, () => ui.requestRender(), panelHeight);
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
300
|
+
// Startup notices for a broken/misleading keybindings file — the TUI keeps
|
|
301
|
+
// running with defaults; the panel shows the same warnings via /hotkeys.
|
|
302
|
+
for (const warning of keyBindings.warnings) {
|
|
303
|
+
renderer.renderNotice(`keybindings: ${warning}`, 'error');
|
|
304
|
+
}
|
|
305
|
+
// Live Todos widget, pinned above the chat window, plus the running-agent
|
|
306
|
+
// activity merged into the last-request area below the editor: shown while
|
|
307
|
+
// the model has (incomplete) todos or subagents running, collapsed when
|
|
308
|
+
// done. Owned here — fed by todo/write events and the bridge's subagent
|
|
309
|
+
// fold, ticked by the live timer, recolored by applyTheme.
|
|
310
|
+
const liveWidgets = new LiveWidgets(ui.widgets, ui.lastRequest, ui.theme, () => ui.requestRender());
|
|
150
311
|
// Arm the panel-height watch sink now that the renderer exists: a
|
|
151
312
|
// committed panelHeight change sets the new height and relayouts the
|
|
152
313
|
// transcript (the replay rebuild), repainting every panel at the new row
|
|
@@ -182,7 +343,7 @@ export function apply(ctx) {
|
|
|
182
343
|
agentStatus = status;
|
|
183
344
|
if (status === 'running') {
|
|
184
345
|
if (loader === undefined) {
|
|
185
|
-
loader = new Loader(ui.tui, text => ansiFg(ui.theme.palette.accent) + text + RESET, text => ansiFg(ui.theme.palette.
|
|
346
|
+
loader = new Loader(ui.tui, text => BOLD + ansiFg(ui.theme.palette.accent) + text + RESET, text => BOLD + ansiFg(ui.theme.palette.accent) + text + RESET, 'working…');
|
|
186
347
|
ui.status.addChild(loader);
|
|
187
348
|
loader.start();
|
|
188
349
|
}
|
|
@@ -200,8 +361,8 @@ export function apply(ctx) {
|
|
|
200
361
|
// with a repaint request here. The badge reads the cached current preset
|
|
201
362
|
// (see the provider below), so a knob change must refresh the cache too.
|
|
202
363
|
const PERMISSION_KNOB_EVENTS = new Set(['permission/preset', 'sandbox/mode', 'approval/policy']);
|
|
203
|
-
const
|
|
204
|
-
onEvent: event => {
|
|
364
|
+
const bridgeCallbacks = {
|
|
365
|
+
onEvent: (event) => {
|
|
205
366
|
renderer.applyEvent(event);
|
|
206
367
|
if (event.type === 'todo/write') {
|
|
207
368
|
// Todos render in the fixed live widget, not the transcript.
|
|
@@ -213,10 +374,23 @@ export function apply(ctx) {
|
|
|
213
374
|
}
|
|
214
375
|
},
|
|
215
376
|
onStatus: setStatus,
|
|
216
|
-
onLive: agents => {
|
|
377
|
+
onLive: (agents) => {
|
|
217
378
|
liveWidgets.renderAgents(agents);
|
|
218
379
|
},
|
|
380
|
+
};
|
|
381
|
+
const bridge = new DshSessionBridge(ctx, bridgeCallbacks);
|
|
382
|
+
// Subagent fine-grained control, all in-process (see subagent-policy.ts):
|
|
383
|
+
// a tools.guard denies spawn-tool calls once `maxAgents` children run
|
|
384
|
+
// (workflow fan-out, which bypasses the tool pipeline, is pruned on
|
|
385
|
+
// `subagent/start`), and when a child's completed turns reach `maxRounds`
|
|
386
|
+
// the policy queues one wrap-up message into its next turn. Limits are
|
|
387
|
+
// read live from the `dsh-tui` settings namespace (/agents → l limits).
|
|
388
|
+
const subagentPolicy = applySubagentPolicy(ctx, {
|
|
389
|
+
getLive: () => bridge.getLiveChildren(),
|
|
390
|
+
getTurnCount: childId => bridge.getTurnCount(childId),
|
|
391
|
+
isSettled: childId => bridge.isChildSettled(childId),
|
|
219
392
|
});
|
|
393
|
+
bridgeCallbacks.onTurnCount = (childId, count) => subagentPolicy.onTurnCount(childId, count);
|
|
220
394
|
/**
|
|
221
395
|
* One O(events) fold of the session log into the effective preset, stored
|
|
222
396
|
* for the badge provider. Runs once per session (ensure/resume, see the
|
|
@@ -269,6 +443,35 @@ export function apply(ctx) {
|
|
|
269
443
|
description: 'Select the model (and think level) for this conversation',
|
|
270
444
|
handler: invocation => modelHandler(invocation.rawInput, invocation.signal),
|
|
271
445
|
}), 'dsh-tui-pi: /model');
|
|
446
|
+
// /agents: manage agent definition markdown files (model, think level,
|
|
447
|
+
// spawn depth) — the terminal counterpart of pi's /fun-agent-cfg. An
|
|
448
|
+
// optional argument preselects one agent by name.
|
|
449
|
+
const agentsHandler = async (rawInput) => {
|
|
450
|
+
const trimmed = rawInput?.trim() ?? '';
|
|
451
|
+
const result = await openAgentManager(ctx, ui.tui, ui.theme, () => ui.tui.setFocus(ui.editor), trimmed === '' ? undefined : trimmed);
|
|
452
|
+
if (result === undefined)
|
|
453
|
+
return { kind: 'success', text: 'Agents unchanged.' };
|
|
454
|
+
return { kind: 'success', text: result };
|
|
455
|
+
};
|
|
456
|
+
commands.registerLocal('agents', agentsHandler);
|
|
457
|
+
ctx.effect(() => ctx.commands.register({
|
|
458
|
+
name: 'agents',
|
|
459
|
+
description: 'Manage agent definitions (model, think level, spawn depth) from markdown files',
|
|
460
|
+
handler: invocation => agentsHandler(invocation.rawInput, invocation.signal),
|
|
461
|
+
}), 'dsh-tui-pi: /agents');
|
|
462
|
+
// /subagents: the command twin of Ctrl+G — pick a running (or recently
|
|
463
|
+
// settled) subagent and inspect its live transcript in the 80% viewer.
|
|
464
|
+
// Same flow as the key path; empty board closes immediately.
|
|
465
|
+
const subagentsHandler = async () => {
|
|
466
|
+
await openSubagentViewer(ctx, ui.tui, ui.theme, bridge, () => ui.tui.setFocus(ui.editor));
|
|
467
|
+
return { kind: 'success', text: 'Subagent viewer closed.' };
|
|
468
|
+
};
|
|
469
|
+
commands.registerLocal('subagents', subagentsHandler);
|
|
470
|
+
ctx.effect(() => ctx.commands.register({
|
|
471
|
+
name: 'subagents',
|
|
472
|
+
description: 'Browse subagents and inspect their live transcript',
|
|
473
|
+
handler: invocation => subagentsHandler(invocation.rawInput, invocation.signal),
|
|
474
|
+
}), 'dsh-tui-pi: /subagents');
|
|
272
475
|
// /think: cycle the current model's reasoning effort without re-picking
|
|
273
476
|
// the model. A no-session /think still lands in the selection ref and
|
|
274
477
|
// survives the lazy session creation (bridge seeds only an empty ref).
|
|
@@ -317,6 +520,25 @@ export function apply(ctx) {
|
|
|
317
520
|
description: 'Switch the current model\'s think (reasoning) level',
|
|
318
521
|
handler: invocation => thinkHandler(invocation.rawInput, invocation.signal),
|
|
319
522
|
}), 'dsh-tui-pi: /think');
|
|
523
|
+
// /models-sync: run one dsh-model-sync catalog round on demand and show
|
|
524
|
+
// its report. Agentless like the pickers above — the sync only touches the
|
|
525
|
+
// settings document, never a conversation, so no throwaway session. The
|
|
526
|
+
// service is optional: without the dsh-model-sync plugin loaded, the
|
|
527
|
+
// command says so instead of failing elsewhere.
|
|
528
|
+
const modelSyncHandler = async () => {
|
|
529
|
+
const modelSync = ctx.get('modelSync');
|
|
530
|
+
if (modelSync === undefined) {
|
|
531
|
+
return { kind: 'error', text: 'dsh-model-sync plugin is not loaded.' };
|
|
532
|
+
}
|
|
533
|
+
const report = await modelSync.syncNow();
|
|
534
|
+
return { kind: 'success', text: report };
|
|
535
|
+
};
|
|
536
|
+
commands.registerLocal('models-sync', modelSyncHandler);
|
|
537
|
+
ctx.effect(() => ctx.commands.register({
|
|
538
|
+
name: 'models-sync',
|
|
539
|
+
description: 'Sync the model catalog from configured providers (custom providers excluded)',
|
|
540
|
+
handler: invocation => modelSyncHandler(invocation.rawInput, invocation.signal),
|
|
541
|
+
}), 'dsh-tui-pi: /models-sync');
|
|
320
542
|
const sessionHandler = async () => {
|
|
321
543
|
const agent = bridge.getAgent();
|
|
322
544
|
const stats = bridge.getStats();
|
|
@@ -413,6 +635,32 @@ export function apply(ctx) {
|
|
|
413
635
|
description: 'Resume a persisted session',
|
|
414
636
|
handler: invocation => resumeHandler(invocation.rawInput, invocation.signal),
|
|
415
637
|
}), 'dsh-tui-pi: /resume');
|
|
638
|
+
// Auto-resume after a hot-reload: a `/reload` stashes the previously
|
|
639
|
+
// current session id before this fiber's teardown, and the freshly
|
|
640
|
+
// re-imported module consumes it best-effort here so the next prompt
|
|
641
|
+
// continues that session instead of lazily creating a fresh one. The stash
|
|
642
|
+
// is already one-shot-consumed by takeStashedSessionId, so on ANY failure
|
|
643
|
+
// we fall back silently to today's behavior (next prompt creates a new
|
|
644
|
+
// session); the apply must never crash on this. Guarded against re-entrant
|
|
645
|
+
// reloads: if a second reload happens while this runs, takeStashedSessionId
|
|
646
|
+
// returns undefined and the block no-ops.
|
|
647
|
+
const stashedSessionId = takeStashedSessionId();
|
|
648
|
+
if (stashedSessionId !== undefined) {
|
|
649
|
+
void (async () => {
|
|
650
|
+
try {
|
|
651
|
+
const resumed = await bridge.resume(stashedSessionId);
|
|
652
|
+
// Mirror the /resume flow: seed the badge cache, drop the previous
|
|
653
|
+
// session's render state, and replay only the seed history.
|
|
654
|
+
refreshPermissionPreset();
|
|
655
|
+
renderer.clear();
|
|
656
|
+
liveWidgets.clear();
|
|
657
|
+
const session = resumed.agent.session;
|
|
658
|
+
bridge.replay(session.events.filter(event => event.seq < session.firstLiveSeq));
|
|
659
|
+
ui.requestRender();
|
|
660
|
+
}
|
|
661
|
+
catch { /* best-effort: fall back to lazy session creation */ }
|
|
662
|
+
})();
|
|
663
|
+
}
|
|
416
664
|
// Agentless guard: dsh's own /export addresses a live agent and would
|
|
417
665
|
// mint a throwaway session just to report there is nothing to export —
|
|
418
666
|
// CommandService routes this locally only when no agent exists, and
|
|
@@ -548,6 +796,23 @@ export function apply(ctx) {
|
|
|
548
796
|
description: 'Reload the TUI from the current source (apply code changes without restarting dsh)',
|
|
549
797
|
handler: invocation => reloadHandler(invocation.rawInput, invocation.signal),
|
|
550
798
|
}), 'dsh-tui-pi: /reload');
|
|
799
|
+
// /hotkeys — pi's keybinding browser, in the /agents select-panel style:
|
|
800
|
+
// a field list of the app keys, Enter opens an editor that writes
|
|
801
|
+
// ~/.dsh/keybindings.json and live-applies the change (no /reload).
|
|
802
|
+
const hotkeysHandler = async () => {
|
|
803
|
+
const summary = await openHotkeysManager(ui.tui, ui.theme, {
|
|
804
|
+
filePath: keyFile,
|
|
805
|
+
apply: bindings => ui.setKeyBindings(bindings),
|
|
806
|
+
restoreFocus: () => ui.tui.setFocus(ui.editor),
|
|
807
|
+
});
|
|
808
|
+
return { kind: 'success', text: summary ?? 'Keybindings unchanged.' };
|
|
809
|
+
};
|
|
810
|
+
commands.registerLocal('hotkeys', hotkeysHandler);
|
|
811
|
+
ctx.effect(() => ctx.commands.register({
|
|
812
|
+
name: 'hotkeys',
|
|
813
|
+
description: 'Show the current keybindings (custom file: ~/.dsh/keybindings.json)',
|
|
814
|
+
handler: invocation => hotkeysHandler(invocation.rawInput, invocation.signal),
|
|
815
|
+
}), 'dsh-tui-pi: /hotkeys');
|
|
551
816
|
// ------------------------------------------------- powerline footer + git --
|
|
552
817
|
const git = new GitBranchWatcher(process.cwd());
|
|
553
818
|
git.onChange = () => ui.requestRender();
|
|
@@ -608,7 +873,7 @@ export function apply(ctx) {
|
|
|
608
873
|
// it under a theme hot-swap; the PowerlineFooter itself needs no rebuild.
|
|
609
874
|
const footerHint = new Text('', 1, 0);
|
|
610
875
|
const paintFooterHint = () => {
|
|
611
|
-
footerHint.setText(ansiFg(ui.theme.palette.fgSubtle) +
|
|
876
|
+
footerHint.setText(ansiFg(ui.theme.palette.fgSubtle) + FOOTER_HINT + RESET);
|
|
612
877
|
};
|
|
613
878
|
paintFooterHint();
|
|
614
879
|
ui.footer.addChild(footerHint);
|
|
@@ -635,6 +900,34 @@ export function apply(ctx) {
|
|
|
635
900
|
setStatus(agentStatus);
|
|
636
901
|
}
|
|
637
902
|
};
|
|
903
|
+
// 'auto' refinement (the cmux/gostty case): COLORFGBG is often absent or
|
|
904
|
+
// wrong inside multiplexers, so the synchronous startup guess may be
|
|
905
|
+
// wrong. Ask the terminal itself — CSI ?996n color-scheme query first
|
|
906
|
+
// (Ghostty/cmux, kitty, iTerm answer), then the OSC 11 background color
|
|
907
|
+
// as ground truth — and hot-apply when the answer differs. Both queries
|
|
908
|
+
// time out silently on quiet terminals; the startup resolution stands.
|
|
909
|
+
if (themePreference === 'auto') {
|
|
910
|
+
ui.tui.setTerminalColorSchemeNotifications(true);
|
|
911
|
+
void (async () => {
|
|
912
|
+
const scheme = await ui.tui.queryTerminalColorScheme({ timeoutMs: 200 }).catch(() => undefined);
|
|
913
|
+
if (scheme === 'light' || scheme === 'dark') {
|
|
914
|
+
applyTheme(resolveTheme(process.env, scheme));
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const background = await ui.tui.queryTerminalBackgroundColor({ timeoutMs: 200 }).catch(() => undefined);
|
|
918
|
+
if (background !== undefined) {
|
|
919
|
+
applyTheme(resolveTheme(process.env, rgbIsLight(background) ? 'light' : 'dark'));
|
|
920
|
+
}
|
|
921
|
+
})();
|
|
922
|
+
}
|
|
923
|
+
// Follow the terminal's live light/dark switches (CSI 997 pushes) while
|
|
924
|
+
// the preference is 'auto' — the terminal flips its OS appearance and the
|
|
925
|
+
// TUI repaints on the next frame, no restart. Explicit pins ignore it.
|
|
926
|
+
const stopTerminalFollow = ui.tui.onTerminalColorSchemeChange(scheme => {
|
|
927
|
+
if (themePreferenceRef !== 'auto')
|
|
928
|
+
return;
|
|
929
|
+
applyTheme(resolveTheme(process.env, scheme));
|
|
930
|
+
});
|
|
638
931
|
// Live clock: the footer is the only thing that changes each second.
|
|
639
932
|
const clockTimer = setInterval(() => ui.requestRender(), 1000);
|
|
640
933
|
clockTimer.unref?.();
|
|
@@ -649,13 +942,13 @@ export function apply(ctx) {
|
|
|
649
942
|
* "aborted due to timeout" — those run with a never-aborting signal
|
|
650
943
|
* instead.
|
|
651
944
|
*/
|
|
652
|
-
const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'theme', 'permission']);
|
|
945
|
+
const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'theme', 'permission', 'agents', 'subagents']);
|
|
653
946
|
/** Route one submitted line: dsh slash command first, model prompt second. */
|
|
654
947
|
const submit = async (text) => {
|
|
655
948
|
const line = text.trim();
|
|
656
949
|
if (line === '')
|
|
657
950
|
return;
|
|
658
|
-
|
|
951
|
+
liveWidgets.setLastRequest(line);
|
|
659
952
|
const tokens = line.startsWith('/') ? line.slice(1).split(/\s+/) : [];
|
|
660
953
|
const name = tokens[0]?.toLowerCase();
|
|
661
954
|
let executeLine = line;
|
|
@@ -758,6 +1051,7 @@ export function apply(ctx) {
|
|
|
758
1051
|
if (relayoutTimer !== undefined)
|
|
759
1052
|
clearTimeout(relayoutTimer);
|
|
760
1053
|
process.stdout.removeListener('resize', onResize);
|
|
1054
|
+
stopTerminalFollow();
|
|
761
1055
|
clearInterval(clockTimer);
|
|
762
1056
|
clearInterval(liveTimer);
|
|
763
1057
|
git.dispose();
|
|
@@ -771,6 +1065,16 @@ export function apply(ctx) {
|
|
|
771
1065
|
// disables raw mode and pauses stdin out from under the new TUI.
|
|
772
1066
|
handle?.dispose();
|
|
773
1067
|
handle = undefined;
|
|
1068
|
+
// Stash the current session BEFORE the bridge clears its sessionId on
|
|
1069
|
+
// dispose, so a same-process hot-reload (/reload) can resume it in the
|
|
1070
|
+
// fresh fiber. On shutdown the process exits and the stash dies with it
|
|
1071
|
+
// (harmless) — only /reload in the same process consumes it.
|
|
1072
|
+
stashSessionIdForReload(bridge.getSessionId());
|
|
1073
|
+
if (quitConfirmTimer !== undefined)
|
|
1074
|
+
clearTimeout(quitConfirmTimer);
|
|
1075
|
+
if (stopConfirmTimer !== undefined)
|
|
1076
|
+
clearTimeout(stopConfirmTimer);
|
|
1077
|
+
subagentPolicy.dispose();
|
|
774
1078
|
try {
|
|
775
1079
|
await bridge.dispose();
|
|
776
1080
|
}
|