@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.
Files changed (67) hide show
  1. package/README.md +205 -68
  2. package/lib/agent-manager.d.ts +125 -0
  3. package/lib/agent-manager.js +298 -0
  4. package/lib/agent-manager.js.map +1 -0
  5. package/lib/agents.d.ts +30 -0
  6. package/lib/agents.js +468 -0
  7. package/lib/agents.js.map +1 -0
  8. package/lib/append-system.d.ts +2 -0
  9. package/lib/append-system.js +1 -1
  10. package/lib/append-system.js.map +1 -1
  11. package/lib/dsh-events.d.ts +12 -0
  12. package/lib/editor.d.ts +58 -0
  13. package/lib/editor.js +92 -0
  14. package/lib/editor.js.map +1 -1
  15. package/lib/footer.d.ts +8 -0
  16. package/lib/footer.js +8 -0
  17. package/lib/footer.js.map +1 -1
  18. package/lib/hotkeys.d.ts +93 -0
  19. package/lib/hotkeys.js +305 -0
  20. package/lib/hotkeys.js.map +1 -0
  21. package/lib/index.js +342 -38
  22. package/lib/index.js.map +1 -1
  23. package/lib/keymap.d.ts +177 -0
  24. package/lib/keymap.js +189 -0
  25. package/lib/keymap.js.map +1 -0
  26. package/lib/live-widgets.d.ts +82 -29
  27. package/lib/live-widgets.js +222 -92
  28. package/lib/live-widgets.js.map +1 -1
  29. package/lib/messages.d.ts +4 -2
  30. package/lib/messages.js +8 -3
  31. package/lib/messages.js.map +1 -1
  32. package/lib/panels.d.ts +177 -0
  33. package/lib/panels.js +331 -0
  34. package/lib/panels.js.map +1 -0
  35. package/lib/provider-catalog.d.ts +31 -8
  36. package/lib/provider-catalog.js +63 -8
  37. package/lib/provider-catalog.js.map +1 -1
  38. package/lib/selectors.d.ts +12 -1
  39. package/lib/selectors.js +5 -2
  40. package/lib/selectors.js.map +1 -1
  41. package/lib/session.d.ts +36 -0
  42. package/lib/session.js +171 -10
  43. package/lib/session.js.map +1 -1
  44. package/lib/settings.d.ts +1 -2
  45. package/lib/settings.js +29 -2
  46. package/lib/settings.js.map +1 -1
  47. package/lib/subagent-policy.d.ts +58 -0
  48. package/lib/subagent-policy.js +137 -0
  49. package/lib/subagent-policy.js.map +1 -0
  50. package/lib/subagent-viewer.d.ts +49 -0
  51. package/lib/subagent-viewer.js +523 -0
  52. package/lib/subagent-viewer.js.map +1 -0
  53. package/lib/theme/index.js +4 -0
  54. package/lib/theme/index.js.map +1 -1
  55. package/lib/theme/palette.d.ts +21 -3
  56. package/lib/theme/palette.js +27 -6
  57. package/lib/theme/palette.js.map +1 -1
  58. package/lib/theme-settings.d.ts +39 -4
  59. package/lib/theme-settings.js +69 -12
  60. package/lib/theme-settings.js.map +1 -1
  61. package/lib/tui.d.ts +32 -12
  62. package/lib/tui.js +109 -36
  63. package/lib/tui.js.map +1 -1
  64. package/package.json +3 -4
  65. package/patches/@earendil-works__pi-tui.patch +206 -2
  66. package/scripts/dev-install.mjs +87 -0
  67. 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 { ansiFg, darkTheme, lightTheme, RESET, resolveTheme } from "./theme/index.js";
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
- // 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;
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
- 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)
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
- else {
134
- void disposeAndExit(0);
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
- // 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());
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.fgMuted) + text + RESET, 'working…');
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 bridge = new DshSessionBridge(ctx, {
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) + '⌨ Enter: send · Ctrl+C: cancel / double: quit' + RESET);
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
- ui.setLastRequest(line);
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
  }