@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.
- package/README.md +175 -41
- 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 +489 -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 +46 -0
- package/lib/footer.js +65 -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 -61
- package/lib/index.js.map +1 -1
- package/lib/keymap.d.ts +243 -0
- package/lib/keymap.js +245 -0
- package/lib/keymap.js.map +1 -0
- package/lib/live-widgets.d.ts +59 -49
- package/lib/live-widgets.js +174 -117
- package/lib/live-widgets.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/selectors.d.ts +12 -1
- package/lib/selectors.js +1 -1
- package/lib/selectors.js.map +1 -1
- package/lib/session.d.ts +25 -0
- package/lib/session.js +173 -12
- package/lib/session.js.map +1 -1
- package/lib/settings.d.ts +1 -2
- package/lib/subagent-policy.d.ts +88 -0
- package/lib/subagent-policy.js +189 -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 +71 -9
- package/lib/theme-settings.js +145 -21
- package/lib/theme-settings.js.map +1 -1
- package/lib/tui.d.ts +23 -9
- package/lib/tui.js +110 -7
- 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/templates/agents/ArtyDuck.md +11 -0
- package/templates/agents/oldfox.md +33 -0
- 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
|
|
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,
|
|
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'
|
|
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
|
|
38
|
-
// creates
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
103
|
-
* a thin wrapper: the whole build runs inside the
|
|
104
|
-
* failure disposes the TUI handle before the error
|
|
105
|
-
* the effect disposer handed back to cordis on
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
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:
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
|
|
635
|
-
|
|
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
|
}
|