@aiwayds/dsh-tui-pi 0.1.1 → 0.4.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 (62) hide show
  1. package/README.md +175 -41
  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 +489 -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 +46 -0
  16. package/lib/footer.js +65 -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 -61
  22. package/lib/index.js.map +1 -1
  23. package/lib/keymap.d.ts +243 -0
  24. package/lib/keymap.js +245 -0
  25. package/lib/keymap.js.map +1 -0
  26. package/lib/live-widgets.d.ts +59 -49
  27. package/lib/live-widgets.js +174 -117
  28. package/lib/live-widgets.js.map +1 -1
  29. package/lib/panels.d.ts +177 -0
  30. package/lib/panels.js +331 -0
  31. package/lib/panels.js.map +1 -0
  32. package/lib/selectors.d.ts +12 -1
  33. package/lib/selectors.js +1 -1
  34. package/lib/selectors.js.map +1 -1
  35. package/lib/session.d.ts +25 -0
  36. package/lib/session.js +173 -12
  37. package/lib/session.js.map +1 -1
  38. package/lib/settings.d.ts +1 -2
  39. package/lib/subagent-policy.d.ts +88 -0
  40. package/lib/subagent-policy.js +189 -0
  41. package/lib/subagent-policy.js.map +1 -0
  42. package/lib/subagent-viewer.d.ts +49 -0
  43. package/lib/subagent-viewer.js +523 -0
  44. package/lib/subagent-viewer.js.map +1 -0
  45. package/lib/theme/index.js +4 -0
  46. package/lib/theme/index.js.map +1 -1
  47. package/lib/theme/palette.d.ts +21 -3
  48. package/lib/theme/palette.js +27 -6
  49. package/lib/theme/palette.js.map +1 -1
  50. package/lib/theme-settings.d.ts +71 -9
  51. package/lib/theme-settings.js +145 -21
  52. package/lib/theme-settings.js.map +1 -1
  53. package/lib/tui.d.ts +23 -9
  54. package/lib/tui.js +110 -7
  55. package/lib/tui.js.map +1 -1
  56. package/package.json +3 -4
  57. package/patches/@earendil-works__pi-tui.patch +206 -2
  58. package/scripts/dev-install.mjs +87 -0
  59. package/scripts/link-dsh-closure.mjs +85 -0
  60. package/templates/agents/ArtyDuck.md +11 -0
  61. package/templates/agents/oldfox.md +33 -0
  62. package/templates/agents/workhorse.md +34 -0
package/lib/index.js CHANGED
@@ -11,38 +11,59 @@
11
11
  import { mkdir, writeFile } from 'node:fs/promises';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join, resolve } from 'node:path';
14
- import { Loader, Text } from '@earendil-works/pi-tui';
14
+ import { Loader } from '@earendil-works/pi-tui';
15
15
  import { CommandService } from "./commands.js";
16
- import { PowerlineFooter } from "./footer.js";
16
+ import { FooterHint, 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 } 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
23
  import { DshSessionBridge, persistDefaultModel, stashSessionIdForReload, takeStashedSessionId } from "./session.js";
24
- import { currentThemePreference, readPanelHeightPreference, readThemePreference, registerThemeSettings, writeThemePreference, } from "./theme-settings.js";
24
+ import { currentThemePreference, readFooterHintsPreference, 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";
29
+ import { applySubagentPolicy } from "./subagent-policy.js";
30
+ import { openSubagentViewer } from "./subagent-viewer.js";
28
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
- export const inject = ['agents', 'commands', 'systemPrompt'];
55
+ export const inject = ['agents', 'commands'];
34
56
  export function apply(ctx) {
35
57
  let handle;
36
58
  // 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');
59
+ // user-editable file appended to the system prompt of the MAIN agent this
60
+ // TUI creates - and to no subagent (an orchestrator identity riding on the
61
+ // children defeats its own purpose). The section is registered on the
62
+ // agent's scoped context inside DshSessionBridge's create/resume setup
63
+ // (see installAppendSystem in session.ts); the text provider reads the
64
+ // file at each assembly, so edits apply to the next request without a
65
+ // restart or watcher, and empty content contributes nothing.
66
+ //
46
67
  // The TUI's own todo-lifecycle guidance rides the same file (idempotent
47
68
  // marker); a fresh file is seeded with the orchestrator template. The
48
69
  // legacy AGENTS.md delivery is migrated out. All best-effort.
@@ -61,6 +82,12 @@ export function apply(ctx) {
61
82
  * the new row budget. Armed inside runTui once the renderer exists.
62
83
  */
63
84
  let applyPanelHeightRef;
85
+ /**
86
+ * Live footer-hints hot-reload sink, wired to the same watch hook: a
87
+ * committed `dsh-tui` footerHints change repaints the footer hint bar with
88
+ * the new segment selection. Armed inside runTui once the TUI exists.
89
+ */
90
+ let applyFooterHintsRef;
64
91
  ctx.effect(async () => {
65
92
  // The theme bundle is built once at TUI startup and held by every
66
93
  // component, so the persisted preference must land before startTui — the
@@ -68,7 +95,8 @@ export function apply(ctx) {
68
95
  // awaits it (bounded, degrades to the defaults without a settings
69
96
  // service). The registration also watches the namespace: `applies:
70
97
  // 'live'`, so later commits (the /theme picker, the /settings browser, an
71
- // external edit) hot-apply through applyThemeRef / applyPanelHeightRef.
98
+ // external edit) hot-apply through applyThemeRef / applyPanelHeightRef /
99
+ // applyFooterHintsRef.
72
100
  // Panel height FIRST, theme second: a single commit of both fields (a
73
101
  // namespace-level reset, an external edit) must not replay twice at the
74
102
  // wrong height. setPanelHeight + relayout repaint the transcript at the
@@ -76,15 +104,17 @@ export function apply(ctx) {
76
104
  // that new height, so the theme rebuild is the one complete rebuild.
77
105
  // applyTheme carries the theme-bundle identity guard, so a height-only
78
106
  // commit never triggers a second rebuild.
79
- registerThemeSettings(ctx, (pref, height) => {
107
+ registerThemeSettings(ctx, (pref, height, footerHints) => {
80
108
  applyPanelHeightRef?.(height);
81
109
  applyThemeRef?.(pref);
110
+ applyFooterHintsRef?.(footerHints);
82
111
  });
83
112
  const themePreference = await readThemePreference(ctx);
84
113
  const panelHeight = await readPanelHeightPreference(ctx);
114
+ const footerHints = await readFooterHintsPreference(ctx);
85
115
  let disposer;
86
116
  try {
87
- disposer = runTui(themePreference, panelHeight);
117
+ disposer = runTui(themePreference, panelHeight, footerHints);
88
118
  }
89
119
  catch (error) {
90
120
  // An async-effect failure after startTui would otherwise orphan the
@@ -98,50 +128,189 @@ export function apply(ctx) {
98
128
  return disposer;
99
129
  }, 'dsh-tui-pi.render');
100
130
  /**
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.
131
+ * Build the TUI and its slash commands for the resolved theme preference,
132
+ * panel height and footer-hint selection. Declared as a hoisted function so
133
+ * the effect body stays a thin wrapper: the whole build runs inside the
134
+ * try/catch above, so any failure disposes the TUI handle before the error
135
+ * reaches cordis. Returns the effect disposer handed back to cordis on
136
+ * teardown.
106
137
  */
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;
138
+ function runTui(themePreference, panelHeight, footerHints) {
139
+ // User keybindings (`~/.dsh/keybindings.json`): a partial map of the app
140
+ // keys, read once per TUI start — `/reload` re-runs apply() and re-reads
141
+ // it. Broken entries surface as notices instead of breaking startup.
142
+ const keyFile = keybindingsPath(dshHome());
143
+ const keyBindings = loadKeyBindings(keyFile);
113
144
  const ui = startTui({
114
145
  onSubmit: text => {
115
146
  void submit(text);
116
147
  },
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)
148
+ keyBindings: keyBindings.bindings,
149
+ // App-level keys (pi interrupt chain): Esc on a running task is a
150
+ // deliberate double-press — the first press arms the window (with
151
+ // feedback), the second within 500ms stops the whole task (parent +
152
+ // subagents); popup/autocomplete first, anti-misfire no-op on a
153
+ // non-empty editor, and an empty idle editor double-Esc opens /session.
154
+ // Ctrl+C cancels a running turn or clears the editor — a second press
155
+ // within 500ms quits — and Ctrl+D quits only on an empty editor.
156
+ // Ctrl+G opens the subagent picker while children run. All decisions
157
+ // live in keymap.ts.
158
+ isRunning: () => bridge.isRunning(),
159
+ getRunningAgents: () => bridge.getLiveChildren().length,
160
+ onKeyAction: (action) => {
161
+ switch (action.kind) {
162
+ case 'interrupt-arm-stop':
163
+ // First Esc while the task runs: the stop is ARMED, not fired —
164
+ // a second Esc within the window confirms it, so a stray Esc can
165
+ // never kill a running turn. The notice keeps the armed first
166
+ // press from feeling dead and states the contract explicitly.
167
+ renderer.renderNotice('Press Esc again to stop the current task', 'info');
168
+ break;
169
+ case 'interrupt-cancel': {
170
+ // Second Esc while the task runs (parent + subagents). Like the
171
+ // Ctrl+C quit, the stop is CONFIRMED, not immediate: a held
172
+ // Esc's FIRST auto-repeat lands at the OS repeat delay (right at
173
+ // the 500ms window boundary) looking exactly like a deliberate
174
+ // second press. The stop fires after STOP_CONFIRM_MS unless a
175
+ // follow-up `key-repeat` aborts it (a repeat betrays a held key),
176
+ // while another HUMAN-speed press during the window stops right
177
+ // away. The '⏹ stopping…' notice renders now, so the visible
178
+ // feedback is immediate even though the cancellation (and its
179
+ // '⏹ canceling current turn…' notice inside `stopTask`) is
180
+ // deferred by the confirm window.
181
+ if (stopConfirmTimer !== undefined) {
182
+ clearTimeout(stopConfirmTimer);
183
+ stopConfirmTimer = undefined;
184
+ void stopTask();
185
+ break;
186
+ }
187
+ renderer.renderNotice('⏹ stopping — Esc was double-pressed', 'info');
188
+ stopConfirmTimer = setTimeout(() => {
189
+ stopConfirmTimer = undefined;
190
+ void stopTask();
191
+ }, STOP_CONFIRM_MS);
192
+ break;
193
+ }
194
+ case 'ctrl-c-cancel': {
195
+ // First Ctrl+C mid-turn: cancel straight away with the same
196
+ // on-screen feedback as Esc ×2 (mirrors the web client's stop
197
+ // button; keepInbox preserves the queue).
198
+ void stopTask();
199
+ break;
200
+ }
201
+ case 'interrupt-double':
202
+ // Double-Esc on an empty editor: pi's default double action is
203
+ // /tree; dsh has no /tree, so this opens /session (the session
204
+ // info panel — the closest non-invasive analogue).
205
+ void sessionHandler('', new AbortController().signal);
206
+ break;
207
+ case 'ctrl-c-clear':
208
+ // First Ctrl+C while idle: clear the editor (pi app.clear); a
209
+ // second press within 500ms quits.
210
+ ui.editor.setText('');
211
+ ui.requestRender();
212
+ break;
213
+ case 'ctrl-c-quit': {
214
+ // Double Ctrl+C quits — but a held key must not. The terminal
215
+ // re-sends a held Ctrl+C every ~30-50ms after the OS repeat delay,
216
+ // and the FIRST repeat can land inside the 500ms double-press
217
+ // window looking exactly like a deliberate second press. So the
218
+ // quit is confirmed, not immediate: it fires after
219
+ // QUIT_CONFIRM_MS unless a `key-repeat` arrives (a repeat betrays
220
+ // a held key → abort), while another HUMAN-speed press during the
221
+ // window (a deliberate mash) quits right away.
222
+ if (quitConfirmTimer !== undefined) {
223
+ clearTimeout(quitConfirmTimer);
224
+ quitConfirmTimer = undefined;
130
225
  void disposeAndExit(0);
131
- });
132
- }
133
- else {
134
- void disposeAndExit(0);
226
+ break;
227
+ }
228
+ renderer.renderNotice('⏹ Ctrl+C ×2 — quitting…', 'info');
229
+ quitConfirmTimer = setTimeout(() => {
230
+ quitConfirmTimer = undefined;
231
+ void disposeAndExit(0);
232
+ }, QUIT_CONFIRM_MS);
233
+ break;
234
+ }
235
+ case 'key-repeat':
236
+ // Auto-repeat of a held key — betrays a hold, never a double:
237
+ // abort a pending Ctrl+C quit confirmation and/or a pending
238
+ // double-Esc stop confirmation.
239
+ if (action.key === 'ctrl-c' && quitConfirmTimer !== undefined) {
240
+ clearTimeout(quitConfirmTimer);
241
+ quitConfirmTimer = undefined;
242
+ renderer.renderNotice('quit aborted — Ctrl+C was held, not double-pressed', 'info');
243
+ }
244
+ if (action.key === 'escape' && stopConfirmTimer !== undefined) {
245
+ clearTimeout(stopConfirmTimer);
246
+ stopConfirmTimer = undefined;
247
+ renderer.renderNotice('stop aborted — Esc was held, not double-pressed', 'info');
248
+ }
249
+ break;
250
+ case 'ctrl-d-quit':
251
+ void disposeAndExit(0);
252
+ break;
253
+ case 'model-picker':
254
+ // Ctrl+L: pi app.model.select — open the model/think picker.
255
+ void modelHandler('', new AbortController().signal);
256
+ break;
257
+ case 'subagent-viewer':
258
+ // Ctrl+G: open the subagent picker → transcript viewer while
259
+ // children run (modal overlay; Esc / double-x closes).
260
+ void openSubagentViewer(ctx, ui.tui, ui.theme, bridge, () => ui.tui.setFocus(ui.editor));
261
+ break;
262
+ default:
263
+ break;
135
264
  }
136
265
  },
137
266
  themePreference,
138
267
  });
139
268
  handle = ui;
269
+ // Live theme preference, tracked so terminal-following (below) can tell
270
+ // 'auto' (follow the terminal) from an explicit light/dark pin.
271
+ let themePreferenceRef = themePreference;
272
+ /**
273
+ * Pending Ctrl+C quit confirmation (see the `ctrl-c-quit` case): armed by
274
+ * a double press, fired after QUIT_CONFIRM_MS, aborted by a `key-repeat`
275
+ * (held key) or torn down with the TUI.
276
+ */
277
+ let quitConfirmTimer;
278
+ /**
279
+ * Pending double-Esc stop confirmation (see the `interrupt-cancel` case):
280
+ * armed by the second Esc while running, fired after STOP_CONFIRM_MS,
281
+ * aborted by a `key-repeat` (held key) or torn down with the TUI. The
282
+ * Ctrl+C quit and the Esc stop never share a timer — distinct keys, both
283
+ * could theoretically be in flight.
284
+ */
285
+ let stopConfirmTimer;
286
+ /**
287
+ * Stop the whole task (parent turn + subagents), mirroring the web
288
+ * client's stop button: `agent.cancel({ kind: 'user' }, { keepInbox:
289
+ * true })` via the bridge. Deferred by the confirm windows above (the
290
+ * ESC/Ctrl+C double-press guards); the notice renders immediately.
291
+ */
292
+ const stopTask = async () => {
293
+ renderer.renderNotice('⏹ canceling current turn…', 'info');
294
+ const cancelled = await bridge.cancelActiveTurn();
295
+ // State raced idle between the decision and the cancel call — nothing
296
+ // to cancel (e.g. the turn settled inside the confirm window).
297
+ if (!cancelled)
298
+ renderer.renderNotice('Nothing running to cancel.', 'info');
299
+ };
140
300
  // Arm the settings watch sink now that the renderer exists (see apply()).
141
301
  applyThemeRef = (pref) => {
302
+ themePreferenceRef = pref;
303
+ // 'auto' subscribes to the terminal's live color-scheme pushes; an
304
+ // explicit pin opts out. resolveTheme guards env-pinned displays.
305
+ ui.tui.setTerminalColorSchemeNotifications(pref === 'auto');
142
306
  applyTheme(resolveTheme(process.env, pref));
143
307
  };
144
308
  const renderer = new TranscriptRenderer(ui.transcript, ui.theme, () => ui.requestRender(), panelHeight);
309
+ // Startup notices for a broken/misleading keybindings file — the TUI keeps
310
+ // running with defaults; the panel shows the same warnings via /hotkeys.
311
+ for (const warning of keyBindings.warnings) {
312
+ renderer.renderNotice(`keybindings: ${warning}`, 'error');
313
+ }
145
314
  // Live Todos widget, pinned above the chat window, plus the running-agent
146
315
  // activity merged into the last-request area below the editor: shown while
147
316
  // the model has (incomplete) todos or subagents running, collapsed when
@@ -201,8 +370,8 @@ export function apply(ctx) {
201
370
  // with a repaint request here. The badge reads the cached current preset
202
371
  // (see the provider below), so a knob change must refresh the cache too.
203
372
  const PERMISSION_KNOB_EVENTS = new Set(['permission/preset', 'sandbox/mode', 'approval/policy']);
204
- const bridge = new DshSessionBridge(ctx, {
205
- onEvent: event => {
373
+ const bridgeCallbacks = {
374
+ onEvent: (event) => {
206
375
  renderer.applyEvent(event);
207
376
  if (event.type === 'todo/write') {
208
377
  // Todos render in the fixed live widget, not the transcript.
@@ -214,10 +383,23 @@ export function apply(ctx) {
214
383
  }
215
384
  },
216
385
  onStatus: setStatus,
217
- onLive: agents => {
386
+ onLive: (agents) => {
218
387
  liveWidgets.renderAgents(agents);
219
388
  },
389
+ };
390
+ const bridge = new DshSessionBridge(ctx, bridgeCallbacks);
391
+ // Subagent fine-grained control, all in-process (see subagent-policy.ts):
392
+ // a tools.guard denies spawn-tool calls once `maxAgents` children run
393
+ // (workflow fan-out, which bypasses the tool pipeline, is pruned on
394
+ // `subagent/start`), and when a child's completed turns reach `maxRounds`
395
+ // the policy queues one wrap-up message into its next turn. Limits are
396
+ // read live from the `dsh-tui` settings namespace (/agents → l limits).
397
+ const subagentPolicy = applySubagentPolicy(ctx, {
398
+ getLive: () => bridge.getLiveChildren(),
399
+ getTurnCount: childId => bridge.getTurnCount(childId),
400
+ isSettled: childId => bridge.isChildSettled(childId),
220
401
  });
402
+ bridgeCallbacks.onTurnCount = (childId, count) => subagentPolicy.onTurnCount(childId, count);
221
403
  /**
222
404
  * One O(events) fold of the session log into the effective preset, stored
223
405
  * for the badge provider. Runs once per session (ensure/resume, see the
@@ -270,6 +452,35 @@ export function apply(ctx) {
270
452
  description: 'Select the model (and think level) for this conversation',
271
453
  handler: invocation => modelHandler(invocation.rawInput, invocation.signal),
272
454
  }), 'dsh-tui-pi: /model');
455
+ // /agents: manage agent definition markdown files (model, think level,
456
+ // spawn depth) — the terminal counterpart of pi's /fun-agent-cfg. An
457
+ // optional argument preselects one agent by name.
458
+ const agentsHandler = async (rawInput) => {
459
+ const trimmed = rawInput?.trim() ?? '';
460
+ const result = await openAgentManager(ctx, ui.tui, ui.theme, () => ui.tui.setFocus(ui.editor), trimmed === '' ? undefined : trimmed);
461
+ if (result === undefined)
462
+ return { kind: 'success', text: 'Agents unchanged.' };
463
+ return { kind: 'success', text: result };
464
+ };
465
+ commands.registerLocal('agents', agentsHandler);
466
+ ctx.effect(() => ctx.commands.register({
467
+ name: 'agents',
468
+ description: 'Manage agent definitions (model, think level, spawn depth) from markdown files',
469
+ handler: invocation => agentsHandler(invocation.rawInput, invocation.signal),
470
+ }), 'dsh-tui-pi: /agents');
471
+ // /subagents: the command twin of Ctrl+G — pick a running (or recently
472
+ // settled) subagent and inspect its live transcript in the 80% viewer.
473
+ // Same flow as the key path; empty board closes immediately.
474
+ const subagentsHandler = async () => {
475
+ await openSubagentViewer(ctx, ui.tui, ui.theme, bridge, () => ui.tui.setFocus(ui.editor));
476
+ return { kind: 'success', text: 'Subagent viewer closed.' };
477
+ };
478
+ commands.registerLocal('subagents', subagentsHandler);
479
+ ctx.effect(() => ctx.commands.register({
480
+ name: 'subagents',
481
+ description: 'Browse subagents and inspect their live transcript',
482
+ handler: invocation => subagentsHandler(invocation.rawInput, invocation.signal),
483
+ }), 'dsh-tui-pi: /subagents');
273
484
  // /think: cycle the current model's reasoning effort without re-picking
274
485
  // the model. A no-session /think still lands in the selection ref and
275
486
  // survives the lazy session creation (bridge seeds only an empty ref).
@@ -318,6 +529,25 @@ export function apply(ctx) {
318
529
  description: 'Switch the current model\'s think (reasoning) level',
319
530
  handler: invocation => thinkHandler(invocation.rawInput, invocation.signal),
320
531
  }), 'dsh-tui-pi: /think');
532
+ // /models-sync: run one dsh-model-sync catalog round on demand and show
533
+ // its report. Agentless like the pickers above — the sync only touches the
534
+ // settings document, never a conversation, so no throwaway session. The
535
+ // service is optional: without the dsh-model-sync plugin loaded, the
536
+ // command says so instead of failing elsewhere.
537
+ const modelSyncHandler = async () => {
538
+ const modelSync = ctx.get('modelSync');
539
+ if (modelSync === undefined) {
540
+ return { kind: 'error', text: 'dsh-model-sync plugin is not loaded.' };
541
+ }
542
+ const report = await modelSync.syncNow();
543
+ return { kind: 'success', text: report };
544
+ };
545
+ commands.registerLocal('models-sync', modelSyncHandler);
546
+ ctx.effect(() => ctx.commands.register({
547
+ name: 'models-sync',
548
+ description: 'Sync the model catalog from configured providers (custom providers excluded)',
549
+ handler: invocation => modelSyncHandler(invocation.rawInput, invocation.signal),
550
+ }), 'dsh-tui-pi: /models-sync');
321
551
  const sessionHandler = async () => {
322
552
  const agent = bridge.getAgent();
323
553
  const stats = bridge.getStats();
@@ -575,6 +805,23 @@ export function apply(ctx) {
575
805
  description: 'Reload the TUI from the current source (apply code changes without restarting dsh)',
576
806
  handler: invocation => reloadHandler(invocation.rawInput, invocation.signal),
577
807
  }), 'dsh-tui-pi: /reload');
808
+ // /hotkeys — pi's keybinding browser, in the /agents select-panel style:
809
+ // a field list of the app keys, Enter opens an editor that writes
810
+ // ~/.dsh/keybindings.json and live-applies the change (no /reload).
811
+ const hotkeysHandler = async () => {
812
+ const summary = await openHotkeysManager(ui.tui, ui.theme, {
813
+ filePath: keyFile,
814
+ apply: bindings => ui.setKeyBindings(bindings),
815
+ restoreFocus: () => ui.tui.setFocus(ui.editor),
816
+ });
817
+ return { kind: 'success', text: summary ?? 'Keybindings unchanged.' };
818
+ };
819
+ commands.registerLocal('hotkeys', hotkeysHandler);
820
+ ctx.effect(() => ctx.commands.register({
821
+ name: 'hotkeys',
822
+ description: 'Show the current keybindings (custom file: ~/.dsh/keybindings.json)',
823
+ handler: invocation => hotkeysHandler(invocation.rawInput, invocation.signal),
824
+ }), 'dsh-tui-pi: /hotkeys');
578
825
  // ------------------------------------------------- powerline footer + git --
579
826
  const git = new GitBranchWatcher(process.cwd());
580
827
  git.onChange = () => ui.requestRender();
@@ -627,18 +874,18 @@ export function apply(ctx) {
627
874
  };
628
875
  ui.footer.clear();
629
876
  ui.footer.addChild(new PowerlineFooter(footerSource));
630
- // Keybinding hint, added *after* the clear above: the placeholder line in
631
- // tui.ts used to be wiped before first render. paddingX 1 aligns the text
632
- // with the powerline segment labels (each starts with a leading space).
633
- // The hint is the footer stack's only theme-dependent piece (the powerline
634
- // segments carry fixed theme-agnostic colors) — `paintFooterHint` re-colors
635
- // it under a theme hot-swap; the PowerlineFooter itself needs no rebuild.
636
- const footerHint = new Text('', 1, 0);
637
- const paintFooterHint = () => {
638
- footerHint.setText(ansiFg(ui.theme.palette.fgSubtle) + '⌨ Enter: send · Ctrl+C: cancel / double: quit' + RESET);
639
- };
640
- paintFooterHint();
877
+ // Keybinding hint, added *after* the clear above: a single width-clipped
878
+ // row (never word-wraps) reading the live footer-hints selection through
879
+ // its getter — a /settings toggle applies on the next repaint, and the
880
+ // theme colors follow `ui.theme` (see FooterHint in footer.ts).
881
+ let footerHintsRef = footerHints;
882
+ const footerHint = new FooterHint(() => ui.theme, () => footerHintsRef);
641
883
  ui.footer.addChild(footerHint);
884
+ // Arm the footer-hints watch sink now that the TUI exists (see apply()).
885
+ applyFooterHintsRef = (hints) => {
886
+ footerHintsRef = hints;
887
+ ui.requestRender();
888
+ };
642
889
  /**
643
890
  * Hot-apply a theme bundle to the running TUI: the transcript rebuilds
644
891
  * from its replay buffer, the dock/editor swap to the new bundle, and the
@@ -654,7 +901,6 @@ export function apply(ctx) {
654
901
  renderer.setTheme(theme);
655
902
  liveWidgets.setTheme(theme);
656
903
  ui.applyTheme(theme);
657
- paintFooterHint();
658
904
  if (loader !== undefined) {
659
905
  loader.stop();
660
906
  ui.status.removeChild(loader);
@@ -662,6 +908,34 @@ export function apply(ctx) {
662
908
  setStatus(agentStatus);
663
909
  }
664
910
  };
911
+ // 'auto' refinement (the cmux/gostty case): COLORFGBG is often absent or
912
+ // wrong inside multiplexers, so the synchronous startup guess may be
913
+ // wrong. Ask the terminal itself — CSI ?996n color-scheme query first
914
+ // (Ghostty/cmux, kitty, iTerm answer), then the OSC 11 background color
915
+ // as ground truth — and hot-apply when the answer differs. Both queries
916
+ // time out silently on quiet terminals; the startup resolution stands.
917
+ if (themePreference === 'auto') {
918
+ ui.tui.setTerminalColorSchemeNotifications(true);
919
+ void (async () => {
920
+ const scheme = await ui.tui.queryTerminalColorScheme({ timeoutMs: 200 }).catch(() => undefined);
921
+ if (scheme === 'light' || scheme === 'dark') {
922
+ applyTheme(resolveTheme(process.env, scheme));
923
+ return;
924
+ }
925
+ const background = await ui.tui.queryTerminalBackgroundColor({ timeoutMs: 200 }).catch(() => undefined);
926
+ if (background !== undefined) {
927
+ applyTheme(resolveTheme(process.env, rgbIsLight(background) ? 'light' : 'dark'));
928
+ }
929
+ })();
930
+ }
931
+ // Follow the terminal's live light/dark switches (CSI 997 pushes) while
932
+ // the preference is 'auto' — the terminal flips its OS appearance and the
933
+ // TUI repaints on the next frame, no restart. Explicit pins ignore it.
934
+ const stopTerminalFollow = ui.tui.onTerminalColorSchemeChange(scheme => {
935
+ if (themePreferenceRef !== 'auto')
936
+ return;
937
+ applyTheme(resolveTheme(process.env, scheme));
938
+ });
665
939
  // Live clock: the footer is the only thing that changes each second.
666
940
  const clockTimer = setInterval(() => ui.requestRender(), 1000);
667
941
  clockTimer.unref?.();
@@ -676,7 +950,7 @@ export function apply(ctx) {
676
950
  * "aborted due to timeout" — those run with a never-aborting signal
677
951
  * instead.
678
952
  */
679
- const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'theme', 'permission']);
953
+ const MODAL_COMMANDS = new Set(['settings', 'model', 'think', 'session', 'resume', 'theme', 'permission', 'agents', 'subagents']);
680
954
  /** Route one submitted line: dsh slash command first, model prompt second. */
681
955
  const submit = async (text) => {
682
956
  const line = text.trim();
@@ -785,11 +1059,13 @@ export function apply(ctx) {
785
1059
  if (relayoutTimer !== undefined)
786
1060
  clearTimeout(relayoutTimer);
787
1061
  process.stdout.removeListener('resize', onResize);
1062
+ stopTerminalFollow();
788
1063
  clearInterval(clockTimer);
789
1064
  clearInterval(liveTimer);
790
1065
  git.dispose();
791
1066
  applyThemeRef = undefined;
792
1067
  applyPanelHeightRef = undefined;
1068
+ applyFooterHintsRef = undefined;
793
1069
  // Stop the TUI FIRST, before the (possibly slow) agent teardown: the
794
1070
  // terminal must be released while the fiber is still alone with it.
795
1071
  // Deferring tui.stop() until after `await bridge.dispose()` lets any
@@ -803,6 +1079,11 @@ export function apply(ctx) {
803
1079
  // fresh fiber. On shutdown the process exits and the stash dies with it
804
1080
  // (harmless) — only /reload in the same process consumes it.
805
1081
  stashSessionIdForReload(bridge.getSessionId());
1082
+ if (quitConfirmTimer !== undefined)
1083
+ clearTimeout(quitConfirmTimer);
1084
+ if (stopConfirmTimer !== undefined)
1085
+ clearTimeout(stopConfirmTimer);
1086
+ subagentPolicy.dispose();
806
1087
  try {
807
1088
  await bridge.dispose();
808
1089
  }