@sagmans/dsh-tui 0.2.0 → 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 +282 -25
- package/cordis.patch.yml +14 -0
- package/lib/agent/model.d.ts +26 -0
- package/lib/agent/model.d.ts.map +1 -1
- package/lib/agent/model.js +41 -0
- package/lib/agent/model.js.map +1 -1
- package/lib/agent/projections.d.ts +22 -6
- package/lib/agent/projections.d.ts.map +1 -1
- package/lib/agent/projections.js +21 -10
- package/lib/agent/projections.js.map +1 -1
- package/lib/agent/prompt-history.d.ts +89 -0
- package/lib/agent/prompt-history.d.ts.map +1 -0
- package/lib/agent/prompt-history.js +380 -0
- package/lib/agent/prompt-history.js.map +1 -0
- package/lib/agent/status.d.ts +16 -0
- package/lib/agent/status.d.ts.map +1 -1
- package/lib/agent/status.js +3 -0
- package/lib/agent/status.js.map +1 -1
- package/lib/cards.d.ts +1 -1
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +4 -3
- package/lib/cards.js.map +1 -1
- package/lib/gates.d.ts +145 -2
- package/lib/gates.d.ts.map +1 -1
- package/lib/gates.js +335 -40
- package/lib/gates.js.map +1 -1
- package/lib/herdr/client.d.ts +57 -0
- package/lib/herdr/client.d.ts.map +1 -0
- package/lib/herdr/client.js +218 -0
- package/lib/herdr/client.js.map +1 -0
- package/lib/herdr/constants.d.ts +93 -0
- package/lib/herdr/constants.d.ts.map +1 -0
- package/lib/herdr/constants.js +79 -0
- package/lib/herdr/constants.js.map +1 -0
- package/lib/herdr/reporter.d.ts +60 -0
- package/lib/herdr/reporter.d.ts.map +1 -0
- package/lib/herdr/reporter.js +217 -0
- package/lib/herdr/reporter.js.map +1 -0
- package/lib/herdr/state.d.ts +57 -0
- package/lib/herdr/state.d.ts.map +1 -0
- package/lib/herdr/state.js +62 -0
- package/lib/herdr/state.js.map +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +631 -118
- package/lib/index.js.map +1 -1
- package/lib/input/actions.d.ts +136 -0
- package/lib/input/actions.d.ts.map +1 -0
- package/lib/input/actions.js +743 -0
- package/lib/input/actions.js.map +1 -0
- package/lib/input/ghost.d.ts +46 -0
- package/lib/input/ghost.d.ts.map +1 -0
- package/lib/input/ghost.js +58 -0
- package/lib/input/ghost.js.map +1 -0
- package/lib/input/keymap-settings.d.ts +21 -0
- package/lib/input/keymap-settings.d.ts.map +1 -0
- package/lib/input/keymap-settings.js +28 -0
- package/lib/input/keymap-settings.js.map +1 -0
- package/lib/input/keymap.d.ts +91 -0
- package/lib/input/keymap.d.ts.map +1 -0
- package/lib/input/keymap.js +172 -0
- package/lib/input/keymap.js.map +1 -0
- package/lib/input/match.d.ts +10 -0
- package/lib/input/match.d.ts.map +1 -0
- package/lib/input/match.js +83 -0
- package/lib/input/match.js.map +1 -0
- package/lib/input/submission.d.ts +29 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +33 -2
- package/lib/input/submission.js.map +1 -1
- package/lib/input.d.ts +17 -0
- package/lib/input.d.ts.map +1 -0
- package/lib/input.js +27 -0
- package/lib/input.js.map +1 -0
- package/lib/keys-command.d.ts +16 -0
- package/lib/keys-command.d.ts.map +1 -0
- package/lib/keys-command.js +60 -0
- package/lib/keys-command.js.map +1 -0
- package/lib/queue.d.ts +6 -0
- package/lib/queue.d.ts.map +1 -0
- package/lib/queue.js +68 -0
- package/lib/queue.js.map +1 -0
- package/lib/settings-notice.d.ts +19 -0
- package/lib/settings-notice.d.ts.map +1 -0
- package/lib/settings-notice.js +30 -0
- package/lib/settings-notice.js.map +1 -0
- package/lib/stash/lock.d.ts +72 -0
- package/lib/stash/lock.d.ts.map +1 -0
- package/lib/stash/lock.js +374 -0
- package/lib/stash/lock.js.map +1 -0
- package/lib/stash/paths.d.ts +22 -0
- package/lib/stash/paths.d.ts.map +1 -0
- package/lib/stash/paths.js +83 -0
- package/lib/stash/paths.js.map +1 -0
- package/lib/stash/private-fs.d.ts +61 -0
- package/lib/stash/private-fs.d.ts.map +1 -0
- package/lib/stash/private-fs.js +379 -0
- package/lib/stash/private-fs.js.map +1 -0
- package/lib/stash/schema.d.ts +57 -0
- package/lib/stash/schema.d.ts.map +1 -0
- package/lib/stash/schema.js +127 -0
- package/lib/stash/schema.js.map +1 -0
- package/lib/stash/store.d.ts +92 -0
- package/lib/stash/store.d.ts.map +1 -0
- package/lib/stash/store.js +262 -0
- package/lib/stash/store.js.map +1 -0
- package/lib/stash.d.ts +91 -0
- package/lib/stash.d.ts.map +1 -0
- package/lib/stash.js +296 -0
- package/lib/stash.js.map +1 -0
- package/lib/theme-settings.d.ts +72 -8
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +125 -5
- package/lib/theme-settings.js.map +1 -1
- package/lib/theme-tokens.d.ts +4 -4
- package/lib/theme-tokens.d.ts.map +1 -1
- package/lib/theme-tokens.js +39 -5
- package/lib/theme-tokens.js.map +1 -1
- package/lib/todo-guard.d.ts +116 -0
- package/lib/todo-guard.d.ts.map +1 -0
- package/lib/todo-guard.js +259 -0
- package/lib/todo-guard.js.map +1 -0
- package/lib/ui/editor.d.ts +58 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +174 -19
- package/lib/ui/editor.js.map +1 -1
- package/lib/ui/frame.d.ts +54 -0
- package/lib/ui/frame.d.ts.map +1 -0
- package/lib/ui/frame.js +79 -0
- package/lib/ui/frame.js.map +1 -0
- package/lib/ui/gate-input.d.ts +20 -0
- package/lib/ui/gate-input.d.ts.map +1 -0
- package/lib/ui/gate-input.js +96 -0
- package/lib/ui/gate-input.js.map +1 -0
- package/lib/ui/history-picker.d.ts +15 -0
- package/lib/ui/history-picker.d.ts.map +1 -0
- package/lib/ui/history-picker.js +26 -0
- package/lib/ui/history-picker.js.map +1 -0
- package/lib/ui/markdown.d.ts +11 -2
- package/lib/ui/markdown.d.ts.map +1 -1
- package/lib/ui/markdown.js +25 -6
- package/lib/ui/markdown.js.map +1 -1
- package/lib/ui/mermaid.d.ts +44 -0
- package/lib/ui/mermaid.d.ts.map +1 -0
- package/lib/ui/mermaid.js +178 -0
- package/lib/ui/mermaid.js.map +1 -0
- package/lib/ui/picker.d.ts +40 -8
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +79 -24
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/prompt.d.ts +32 -0
- package/lib/ui/prompt.d.ts.map +1 -0
- package/lib/ui/prompt.js +49 -0
- package/lib/ui/prompt.js.map +1 -0
- package/lib/ui/queue.d.ts +24 -0
- package/lib/ui/queue.d.ts.map +1 -0
- package/lib/ui/queue.js +57 -0
- package/lib/ui/queue.js.map +1 -0
- package/lib/ui/stash-picker.d.ts +43 -0
- package/lib/ui/stash-picker.d.ts.map +1 -0
- package/lib/ui/stash-picker.js +78 -0
- package/lib/ui/stash-picker.js.map +1 -0
- package/lib/ui/status.d.ts +6 -0
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +15 -0
- package/lib/ui/status.js.map +1 -1
- package/lib/ui/view.d.ts +22 -2
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +84 -24
- package/lib/ui/view.js.map +1 -1
- package/lib/work.d.ts +51 -1
- package/lib/work.d.ts.map +1 -1
- package/lib/work.js +53 -1
- package/lib/work.js.map +1 -1
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -4,18 +4,23 @@ import { resolve } from 'node:path';
|
|
|
4
4
|
import { ProcessTerminal, ScrollView, VStack, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
|
|
5
5
|
import { startAgent } from "./agent/host.js";
|
|
6
6
|
import { PICKER_LIMIT, createSessionHistory, presetOfStoredSession, readSessionTitle, } from "./agent/history.js";
|
|
7
|
+
import { createPromptHistory } from "./agent/prompt-history.js";
|
|
7
8
|
import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
|
|
8
9
|
import { createToolPresenter } from "./agent/present.js";
|
|
9
10
|
import { forkPoint } from "./agent/fork.js";
|
|
10
11
|
import { PROFILE_NAME, resumeHint } from "./identity.js";
|
|
11
12
|
import { createStatusFacts } from "./agent/status.js";
|
|
12
|
-
import { ModelSwitch, createModelCatalog, parseModelArgument } from "./agent/model.js";
|
|
13
|
+
import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
|
|
13
14
|
import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
|
|
14
15
|
import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
|
|
15
16
|
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
16
17
|
import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
|
|
17
18
|
import { createCompletionProvider } from "./input/completion.js";
|
|
19
|
+
import { ghostSuffix } from "./input/ghost.js";
|
|
18
20
|
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
21
|
+
import { createDeferredNotice } from "./settings-notice.js";
|
|
22
|
+
import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
|
|
23
|
+
import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
|
|
19
24
|
import { resolveConfig } from "./config.js";
|
|
20
25
|
import { FoldCursor } from "./fold-cursor.js";
|
|
21
26
|
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
@@ -23,21 +28,32 @@ import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
|
23
28
|
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
24
29
|
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
25
30
|
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
31
|
+
import { sessionStartReason } from "./herdr/state.js";
|
|
32
|
+
import { createHerdrReporter } from "./herdr/reporter.js";
|
|
26
33
|
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
27
34
|
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
28
35
|
import { detectColourMode } from "./theme-capability.js";
|
|
29
|
-
import { defaultSettings, readScope, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
36
|
+
import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
37
|
+
import { pendingPrompts } from "./queue.js";
|
|
30
38
|
import { renderThemeTable } from "./theme-command.js";
|
|
39
|
+
import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
|
|
40
|
+
import { resetSequence } from "./theme-tokens.js";
|
|
31
41
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
32
42
|
import { formatTokens } from "./tokens.js";
|
|
33
43
|
import { TranscriptModel } from "./transcript.js";
|
|
34
|
-
import { WorkFold, describeTodos } from "./work.js";
|
|
44
|
+
import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
|
|
35
45
|
import { WorkDock } from "./ui/dock.js";
|
|
36
|
-
import {
|
|
46
|
+
import { GateInputBar } from "./ui/gate-input.js";
|
|
47
|
+
import { HistoryPicker } from "./ui/history-picker.js";
|
|
48
|
+
import { PromptBar } from "./ui/prompt.js";
|
|
37
49
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
50
|
+
import { createMermaidTransform } from "./ui/mermaid.js";
|
|
51
|
+
import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
|
|
52
|
+
import { QueueBar } from "./ui/queue.js";
|
|
53
|
+
import { shortPath, StatusBar } from "./ui/status.js";
|
|
40
54
|
import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
|
|
55
|
+
import { PromptStash } from "./stash.js";
|
|
56
|
+
import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
|
|
41
57
|
export const name = 'tui';
|
|
42
58
|
/**
|
|
43
59
|
* Services the surface cannot run without.
|
|
@@ -47,14 +63,18 @@ export const name = 'tui';
|
|
|
47
63
|
* every card to a bare generic row.
|
|
48
64
|
*/
|
|
49
65
|
export const inject = ['agents', 'tools'];
|
|
50
|
-
/**
|
|
51
|
-
const
|
|
52
|
-
/**
|
|
53
|
-
const
|
|
66
|
+
/** One second in the unit a chord window is scheduled in. */
|
|
67
|
+
const MS_PER_SECOND = 1000;
|
|
68
|
+
/** What the back hint names when the reader has unbound the key it would advertise. */
|
|
69
|
+
const BACK_HINT_FALLBACK = 'ctrl+b';
|
|
70
|
+
/** What the reader presses to leave a view they did not open. */
|
|
71
|
+
const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
|
|
54
72
|
/** Stored sessions titled at once when the picker opens. */
|
|
55
73
|
const TITLE_CONCURRENCY = 4;
|
|
56
74
|
/** How often the running-state clock repaints while a turn is open. */
|
|
57
75
|
const STATUS_TICK_MS = 1000;
|
|
76
|
+
/** Reverse video for the cell the cursor occupies, so a ghost keeps the cursor visible. */
|
|
77
|
+
const GHOST_CURSOR_PREFIX = '\u001b[7m';
|
|
58
78
|
function asRecord(value) {
|
|
59
79
|
return typeof value === 'object' && value !== null ? value : undefined;
|
|
60
80
|
}
|
|
@@ -108,11 +128,14 @@ export function apply(ctx, config) {
|
|
|
108
128
|
* late-bound read that the injection point and the change event both use.
|
|
109
129
|
*/
|
|
110
130
|
let readSection = () => defaultSettings();
|
|
111
|
-
/**
|
|
112
|
-
|
|
131
|
+
/**
|
|
132
|
+
* A refused settings edit, kept until the surface can show it: stderr is
|
|
133
|
+
* behind the alt screen, and the section is read on a schedule of its own.
|
|
134
|
+
*/
|
|
135
|
+
const settingsNotice = createDeferredNotice();
|
|
113
136
|
let current = createTheme(themeMode());
|
|
114
|
-
const applyTheme = () => {
|
|
115
|
-
current = createTheme(themeMode(), toOverrides(
|
|
137
|
+
const applyTheme = (section) => {
|
|
138
|
+
current = createTheme(themeMode(), toOverrides(section));
|
|
116
139
|
};
|
|
117
140
|
const theme = {
|
|
118
141
|
get revision() { return current.revision; },
|
|
@@ -137,13 +160,63 @@ export function apply(ctx, config) {
|
|
|
137
160
|
*/
|
|
138
161
|
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
139
162
|
/**
|
|
140
|
-
*
|
|
163
|
+
* How a reply's mermaid fences draw, seeded from the reader's section.
|
|
141
164
|
*
|
|
142
|
-
* The
|
|
143
|
-
*
|
|
165
|
+
* The transform reads this per render instead of capturing it, because the
|
|
166
|
+
* settings document is hot-reloaded and a session already on screen has to
|
|
167
|
+
* follow the edit.
|
|
144
168
|
*/
|
|
145
|
-
|
|
146
|
-
|
|
169
|
+
let mermaidMode = defaultSettings().mermaid;
|
|
170
|
+
/** The keys that start a chord, and how long one waits; the settings document owns all of it. */
|
|
171
|
+
let prefixKeys = DEFAULT_PREFIX_KEYS;
|
|
172
|
+
let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
|
|
173
|
+
/** Every action's keys in force; the settings document owns it and a press reads it live. */
|
|
174
|
+
let keymap = defaultKeymap();
|
|
175
|
+
/** Whether prompts are recorded and offered, and the cap on how many; the settings document owns all three. */
|
|
176
|
+
let historyEnabled = defaultSettings().history.enabled;
|
|
177
|
+
let historyGhost = defaultSettings().history.ghost;
|
|
178
|
+
let historyMaxEntries = defaultSettings().history.maxEntries;
|
|
179
|
+
/**
|
|
180
|
+
* The chord between a prefix and the action that follows it.
|
|
181
|
+
*
|
|
182
|
+
* Built here, before the terminal exists, because the settings scope applies
|
|
183
|
+
* first and has to be able to end a chord armed under the keymap it replaced.
|
|
184
|
+
* The repaint the window also wants is late-bound: only a key press reaches
|
|
185
|
+
* it, and no key can arrive before the surface has started.
|
|
186
|
+
*/
|
|
187
|
+
const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
|
|
188
|
+
/**
|
|
189
|
+
* Seed the display the reader configured.
|
|
190
|
+
*
|
|
191
|
+
* The key toggles nested calls for one session, but a settings edit is a
|
|
192
|
+
* deliberate act, so it re-seeds and becomes the new starting point; the
|
|
193
|
+
* mermaid mode has no key of its own and only ever comes from the document.
|
|
194
|
+
*/
|
|
195
|
+
const applyDisplay = (section) => {
|
|
196
|
+
viewState.expandSubCalls = section.subcalls === 'inline';
|
|
197
|
+
mermaidMode = section.mermaid;
|
|
198
|
+
prefixKeys = section.prefixes;
|
|
199
|
+
prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
|
|
200
|
+
keymap = section.keymap;
|
|
201
|
+
// Installed where the library reads it, so a remap lands on the next press
|
|
202
|
+
// rather than at the next restart.
|
|
203
|
+
installKeybindings(keymap);
|
|
204
|
+
historyEnabled = section.history.enabled;
|
|
205
|
+
historyGhost = section.history.ghost;
|
|
206
|
+
historyMaxEntries = section.history.maxEntries;
|
|
207
|
+
// A chord armed under the keymap the reader just replaced is not their chord.
|
|
208
|
+
keyChord.disarm();
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Read the reader's section once and apply everything it configures.
|
|
212
|
+
*
|
|
213
|
+
* One read per change, because a refused section is reported on the way past:
|
|
214
|
+
* reading it once per field would show the reader the same refusal twice.
|
|
215
|
+
*/
|
|
216
|
+
const applySettings = () => {
|
|
217
|
+
const section = readSection();
|
|
218
|
+
applyTheme(section);
|
|
219
|
+
applyDisplay(section);
|
|
147
220
|
};
|
|
148
221
|
/**
|
|
149
222
|
* Own the section, so the harness validates and persists it for the reader.
|
|
@@ -154,10 +227,23 @@ export function apply(ctx, config) {
|
|
|
154
227
|
* may be touched in.
|
|
155
228
|
*/
|
|
156
229
|
ctx.inject(['settings'], settingsCtx => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
230
|
+
// Registration parses the document against the schema, so a section the
|
|
231
|
+
// schema itself refuses throws here — inside a fiber whose failure the
|
|
232
|
+
// screen never shows. Reporting it through the same holder keeps a typo
|
|
233
|
+
// from costing the reader every setting they wrote, silently.
|
|
234
|
+
let scope;
|
|
235
|
+
try {
|
|
236
|
+
scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
// Nothing registered means nothing to read, so the reader's switch cannot
|
|
240
|
+
// be confirmed: recording stays off rather than falling back to on.
|
|
241
|
+
historyEnabled = false;
|
|
242
|
+
settingsNotice.post(settingsProblemMessage(error) + ' · prompt history stays off until the section parses');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
readSection = () => readScope(scope, message => settingsNotice.post(message));
|
|
246
|
+
applySettings();
|
|
161
247
|
});
|
|
162
248
|
/**
|
|
163
249
|
* The agent scope the tool presenter resolves against.
|
|
@@ -169,6 +255,33 @@ export function apply(ctx, config) {
|
|
|
169
255
|
*/
|
|
170
256
|
let presentScope;
|
|
171
257
|
const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
|
|
258
|
+
/**
|
|
259
|
+
* The reader's prompt history: global across projects, recorded from every
|
|
260
|
+
* submitted line, and offered back as they type. Built before the bar so its
|
|
261
|
+
* first load cannot race the first suggestion.
|
|
262
|
+
*/
|
|
263
|
+
const promptHistory = createPromptHistory({
|
|
264
|
+
cap: () => historyMaxEntries,
|
|
265
|
+
warn: message => {
|
|
266
|
+
model.notice(message);
|
|
267
|
+
tui.requestRender();
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
/**
|
|
271
|
+
* The dimmed completion drawn from recorded prompts.
|
|
272
|
+
*
|
|
273
|
+
* Colour is the affordance: with styling off the suggestion would be
|
|
274
|
+
* unreadable text the reader could still accept, which is worse than none.
|
|
275
|
+
* The cursor cell is also reversed so the cursor stays visible on the ghost.
|
|
276
|
+
*/
|
|
277
|
+
const ghostBrush = {
|
|
278
|
+
enabled: () => historyEnabled && historyGhost && theme.color && theme.visible('editor.ghost'),
|
|
279
|
+
suggestion: input => ghostSuffix({ entries: promptHistory.entries(), ...input }),
|
|
280
|
+
paint: (text, cell) => {
|
|
281
|
+
const styled = theme.style('editor.ghost', text);
|
|
282
|
+
return cell === 'cursor' ? GHOST_CURSOR_PREFIX + styled + resetSequence() : styled;
|
|
283
|
+
},
|
|
284
|
+
};
|
|
172
285
|
const work = new WorkFold();
|
|
173
286
|
const modelSwitch = new ModelSwitch();
|
|
174
287
|
const agentPresets = createPresetRoster(ctx);
|
|
@@ -197,7 +310,7 @@ export function apply(ctx, config) {
|
|
|
197
310
|
let jobs = [];
|
|
198
311
|
const roster = new SubagentRoster();
|
|
199
312
|
const subagentControl = createSubagentControl(ctx);
|
|
200
|
-
const markdown = new MarkdownRenderer(theme.markdown);
|
|
313
|
+
const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
|
|
201
314
|
const restore = createRestoreRegistry();
|
|
202
315
|
const terminal = new ProcessTerminal();
|
|
203
316
|
const tui = new WarningSafeTui(terminal);
|
|
@@ -207,14 +320,22 @@ export function apply(ctx, config) {
|
|
|
207
320
|
let viewedSession = resolved.sessionId;
|
|
208
321
|
let pending;
|
|
209
322
|
let pendingPicker;
|
|
210
|
-
/** A pick being vetted owns the keyboard:
|
|
323
|
+
/** A pick being vetted owns the list, not the keyboard: filtering stays live while its verdict is read. */
|
|
211
324
|
let vetting = false;
|
|
212
325
|
const view = new TranscriptView(model, theme, markdown, {
|
|
213
326
|
state: () => viewState,
|
|
214
327
|
gate: () => pending?.gate.card(),
|
|
215
328
|
picker: () => pendingPicker?.picker.card(),
|
|
329
|
+
keys: () => keymap,
|
|
216
330
|
});
|
|
217
|
-
|
|
331
|
+
// The key map goes in before the bar exists, so no press can be read as the
|
|
332
|
+
// send the library submits on by default. A settings document read after this
|
|
333
|
+
// point installs over it, which is why the bar reads the map per press.
|
|
334
|
+
installKeybindings(keymap);
|
|
335
|
+
const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
|
|
336
|
+
// Answers are written in the reader's own editor, which is why a question
|
|
337
|
+
// borrows the bar instead of drawing a second one beside it.
|
|
338
|
+
const promptBar = new PromptBar(editor);
|
|
218
339
|
const disposers = [];
|
|
219
340
|
// The presenter closure outlives the composition's own teardown, so it must
|
|
220
341
|
// not keep an agent alive after its world unwinds.
|
|
@@ -232,25 +353,56 @@ export function apply(ctx, config) {
|
|
|
232
353
|
jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
|
|
233
354
|
tui.requestRender();
|
|
234
355
|
};
|
|
356
|
+
// The bank is built once the picker exists to answer for it, so the status
|
|
357
|
+
// source is late-bound: the footer must not read a half-constructed stash.
|
|
358
|
+
let stash;
|
|
235
359
|
const statusFacts = createStatusFacts(ctx, {
|
|
236
360
|
sessionId: () => activeSession,
|
|
237
361
|
activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
|
|
238
362
|
override: () => modelSwitch.current(),
|
|
239
363
|
home: process.env.HOME,
|
|
364
|
+
chord: () => keyChord.hint(),
|
|
365
|
+
// Read per paint rather than written into the marker the view left behind:
|
|
366
|
+
// a hint stored with the transcript would keep naming the key of the day it
|
|
367
|
+
// was written, and the reader may remap it with the row already on screen.
|
|
368
|
+
back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
|
|
369
|
+
stash: () => stash?.entryCount,
|
|
240
370
|
});
|
|
241
371
|
const statusBar = new StatusBar(statusFacts, theme);
|
|
242
372
|
const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
|
|
373
|
+
// The queue is read from the agent this terminal drives rather than from the
|
|
374
|
+
// session on screen, because it sits on the editor that submits to that agent.
|
|
375
|
+
const queue = new QueueBar(() => pendingPrompts(ctx, liveSession(activeSession)), theme);
|
|
243
376
|
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
244
377
|
const statusTicker = setInterval(() => {
|
|
245
378
|
if (turnOpen)
|
|
246
379
|
tui.requestRender();
|
|
247
380
|
}, STATUS_TICK_MS);
|
|
248
381
|
disposers.push(() => clearInterval(statusTicker));
|
|
382
|
+
// A window that outlived the surface would repaint a screen that is gone.
|
|
383
|
+
disposers.push(() => keyChord.disarm());
|
|
384
|
+
// A containing Herdr is told what this pane is doing; away from one the
|
|
385
|
+
// reporter is inert, so the surface never depends on being multiplexed.
|
|
386
|
+
const herdr = createHerdrReporter();
|
|
387
|
+
// Kept out of the disposal list on purpose: the row is handed back before
|
|
388
|
+
// this stops guarding it, so a host that leaves during the release still
|
|
389
|
+
// releases synchronously.
|
|
390
|
+
const unregisterExit = herdr.registerExitRelease();
|
|
249
391
|
restore.add(() => tui.stop());
|
|
250
392
|
ctx.effect(() => () => {
|
|
393
|
+
// The pane stops being an agent before the process that claimed it unwinds:
|
|
394
|
+
// a release that ran after the reports were unregistered would race them,
|
|
395
|
+
// and one that never ran would leave a row that reads as a live agent. The
|
|
396
|
+
// reports already on the wire are settled first, because Herdr ignores a
|
|
397
|
+
// release for a pane nothing has claimed yet — the report that followed it
|
|
398
|
+
// would otherwise claim the row back. The promise is returned so a host that
|
|
399
|
+
// waits for teardown waits for the row too, and the exit listener outlives
|
|
400
|
+
// the wait, so one that does not still hands the row back synchronously.
|
|
401
|
+
const released = herdr.release().catch(() => undefined);
|
|
251
402
|
restore.restore();
|
|
252
403
|
for (const dispose of disposers.reverse())
|
|
253
404
|
dispose();
|
|
405
|
+
return released.finally(unregisterExit);
|
|
254
406
|
});
|
|
255
407
|
tui.setLayoutRoot(new VStack([
|
|
256
408
|
{
|
|
@@ -262,7 +414,11 @@ export function apply(ctx, config) {
|
|
|
262
414
|
// Work state earns rows only when there is some: a dock that always
|
|
263
415
|
// occupied a row would cost every conversation one line of transcript.
|
|
264
416
|
{ component: dock, basis: 'auto', shrink: 0, minSize: 0 },
|
|
265
|
-
|
|
417
|
+
// Queued input earns rows only while something is waiting, and it gives
|
|
418
|
+
// them up before the editor does: the bar being typed in outranks what is
|
|
419
|
+
// waiting behind it.
|
|
420
|
+
{ component: queue, basis: 'auto', shrink: 2, minSize: 0 },
|
|
421
|
+
{ component: new VStack([{ component: promptBar, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 0 },
|
|
266
422
|
{ component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
|
|
267
423
|
]));
|
|
268
424
|
tui.setFocus(editor);
|
|
@@ -271,37 +427,104 @@ export function apply(ctx, config) {
|
|
|
271
427
|
return;
|
|
272
428
|
exited = true;
|
|
273
429
|
clearInterval(statusTicker);
|
|
274
|
-
//
|
|
275
|
-
//
|
|
430
|
+
// The screen goes back at once, so leaving feels like leaving; the row goes
|
|
431
|
+
// back behind it. Reports already on the wire are settled first, because
|
|
432
|
+
// Herdr ignores the release of a pane nothing has claimed yet — the report
|
|
433
|
+
// that followed it would otherwise claim the row back during shutdown.
|
|
276
434
|
terminal.write(CLEAR_TITLE);
|
|
277
435
|
restore.restore();
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
436
|
+
void herdr
|
|
437
|
+
.release()
|
|
438
|
+
// Nobody is left to report a release that failed on the way out, and a
|
|
439
|
+
// row that could not be cleared is not a reason to keep the process.
|
|
440
|
+
.catch(() => undefined)
|
|
441
|
+
.finally(() => {
|
|
442
|
+
// Everything below is written after the release: a failure nobody can
|
|
443
|
+
// read is not a failure that was reported.
|
|
444
|
+
if (reason !== undefined)
|
|
445
|
+
terminal.write(`\ndsh-tui: ${reason}\n`);
|
|
446
|
+
// The hint is computed here rather than read from the context because
|
|
447
|
+
// only the surface knows which session it is leaving: a fork or a
|
|
448
|
+
// switch moves it. A run that opened nothing has nothing to offer back:
|
|
449
|
+
// the identity it was launched with names no log, so pointing at it
|
|
450
|
+
// would send the reader to a conversation that does not exist.
|
|
451
|
+
if (sessionOpened)
|
|
452
|
+
terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
|
|
453
|
+
appExit(code);
|
|
454
|
+
});
|
|
291
455
|
};
|
|
292
456
|
const openGate = (next) => {
|
|
293
457
|
pending = next;
|
|
458
|
+
// The card's own title names the decision, which is what a reader glancing
|
|
459
|
+
// at a wall of panes needs in order to know which one to open.
|
|
460
|
+
herdr.block(next.gate.card().title);
|
|
294
461
|
// A gate owns the keyboard: the editor must not collect the decision keys.
|
|
295
462
|
editor.disableSubmit = true;
|
|
296
463
|
tui.setFocus(null);
|
|
464
|
+
// A question is answered in this editor, which the gate draws under the row
|
|
465
|
+
// being answered, so the hardware cursor belongs to it while it is borrowed.
|
|
466
|
+
// It is set after the focus is cleared, which unmarks the component it left.
|
|
467
|
+
editor.focused = next.kind === 'question';
|
|
297
468
|
tui.requestRender();
|
|
298
469
|
};
|
|
299
470
|
const closeGate = () => {
|
|
300
471
|
pending = undefined;
|
|
472
|
+
herdr.unblock();
|
|
301
473
|
editor.disableSubmit = false;
|
|
474
|
+
// The question is answered or skipped, so the reader gets their prompt back
|
|
475
|
+
// in the bar they left it in.
|
|
476
|
+
promptBar.giveBack();
|
|
302
477
|
tui.setFocus(editor);
|
|
303
478
|
tui.requestRender();
|
|
304
479
|
};
|
|
480
|
+
/**
|
|
481
|
+
* What each key the surface answers itself does; false hands the press back.
|
|
482
|
+
*
|
|
483
|
+
* Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
|
|
484
|
+
* without a handler here fails to compile rather than doing nothing.
|
|
485
|
+
*/
|
|
486
|
+
const surfaceActions = {
|
|
487
|
+
toolDetail: () => {
|
|
488
|
+
viewState.expandCards = !viewState.expandCards;
|
|
489
|
+
tui.requestRender();
|
|
490
|
+
return true;
|
|
491
|
+
},
|
|
492
|
+
subCalls: () => {
|
|
493
|
+
viewState.expandSubCalls = !viewState.expandSubCalls;
|
|
494
|
+
tui.requestRender();
|
|
495
|
+
return true;
|
|
496
|
+
},
|
|
497
|
+
reasoning: () => {
|
|
498
|
+
viewState.expandReasoning = !viewState.expandReasoning;
|
|
499
|
+
tui.requestRender();
|
|
500
|
+
return true;
|
|
501
|
+
},
|
|
502
|
+
effort: () => {
|
|
503
|
+
void openEffortPicker();
|
|
504
|
+
return true;
|
|
505
|
+
},
|
|
506
|
+
history: () => {
|
|
507
|
+
void openHistoryPicker();
|
|
508
|
+
return true;
|
|
509
|
+
},
|
|
510
|
+
back: () => {
|
|
511
|
+
if (viewedSession !== activeSession)
|
|
512
|
+
void showAgentSession();
|
|
513
|
+
return true;
|
|
514
|
+
},
|
|
515
|
+
interrupt: () => {
|
|
516
|
+
// In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
|
|
517
|
+
// decides: stop the work in flight, or leave when there is none.
|
|
518
|
+
if (!turnOpen) {
|
|
519
|
+
requestExit(0);
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
agent?.interrupt();
|
|
523
|
+
model.notice('interrupt requested');
|
|
524
|
+
tui.requestRender();
|
|
525
|
+
return true;
|
|
526
|
+
},
|
|
527
|
+
};
|
|
305
528
|
disposers.push(tui.addInputListener(data => {
|
|
306
529
|
// A key arrives as a press and a release once the surface asks the terminal
|
|
307
530
|
// to report key events, and this library drops the release only for the
|
|
@@ -333,13 +556,19 @@ export function apply(ctx, config) {
|
|
|
333
556
|
return { consume: true };
|
|
334
557
|
}
|
|
335
558
|
if (pendingPicker !== undefined) {
|
|
336
|
-
if (vetting)
|
|
337
|
-
return { consume: true };
|
|
338
559
|
const action = pendingPicker.picker.handleKey(data);
|
|
339
560
|
if (action === undefined) {
|
|
340
561
|
tui.requestRender();
|
|
341
562
|
return { consume: true };
|
|
342
563
|
}
|
|
564
|
+
if (vetting) {
|
|
565
|
+
// A refusal check must not take the keyboard with it: the reader keeps
|
|
566
|
+
// filtering and can still leave, while a second pick waits for the
|
|
567
|
+
// first verdict rather than racing it.
|
|
568
|
+
if (action.kind === 'cancel')
|
|
569
|
+
settlePicker(undefined);
|
|
570
|
+
return { consume: true };
|
|
571
|
+
}
|
|
343
572
|
if (action.kind === 'cancel') {
|
|
344
573
|
settlePicker(undefined);
|
|
345
574
|
return { consume: true };
|
|
@@ -370,44 +599,23 @@ export function apply(ctx, config) {
|
|
|
370
599
|
})();
|
|
371
600
|
return { consume: true };
|
|
372
601
|
}
|
|
373
|
-
//
|
|
602
|
+
// A chord is the surface's second key: the prefix is consumed and the
|
|
603
|
+
// footer names what may follow, while a key that finishes nothing is handed
|
|
604
|
+
// on. Detail the reader asked for is always available, even mid-turn: the
|
|
374
605
|
// collapsed view is a default, not the only state.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
if (matchesKey(data, 'shift+tab')) {
|
|
381
|
-
viewState.expandReasoning = !viewState.expandReasoning;
|
|
382
|
-
tui.requestRender();
|
|
383
|
-
return { consume: true };
|
|
384
|
-
}
|
|
385
|
-
if (matchesKey(data, 'ctrl+y')) {
|
|
386
|
-
viewState.expandSubCalls = !viewState.expandSubCalls;
|
|
606
|
+
const chorded = keyChord.handle(data);
|
|
607
|
+
if (chorded !== undefined) {
|
|
608
|
+
if (chorded.kind === 'action')
|
|
609
|
+
runSubmission(chorded.binding.submission);
|
|
387
610
|
tui.requestRender();
|
|
388
611
|
return { consume: true };
|
|
389
612
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (matchesKey(data, 'ctrl+b')) {
|
|
395
|
-
if (viewedSession !== activeSession)
|
|
396
|
-
void showAgentSession();
|
|
397
|
-
return { consume: true };
|
|
398
|
-
}
|
|
399
|
-
// In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
|
|
400
|
-
// decides: stop the work in flight, or leave when there is none.
|
|
401
|
-
if (!matchesKey(data, 'ctrl+c'))
|
|
402
|
-
return undefined;
|
|
403
|
-
if (turnOpen) {
|
|
404
|
-
agent?.interrupt();
|
|
405
|
-
model.notice('interrupt requested');
|
|
406
|
-
tui.requestRender();
|
|
407
|
-
return { consume: true };
|
|
613
|
+
for (const binding of surfaceBindings(keymap)) {
|
|
614
|
+
if (!matchesKey(data, binding.key))
|
|
615
|
+
continue;
|
|
616
|
+
return surfaceActions[binding.action]() ? { consume: true } : undefined;
|
|
408
617
|
}
|
|
409
|
-
|
|
410
|
-
return { consume: true };
|
|
618
|
+
return undefined;
|
|
411
619
|
}));
|
|
412
620
|
const registry = () => ctx.get('commands');
|
|
413
621
|
/**
|
|
@@ -433,13 +641,14 @@ export function apply(ctx, config) {
|
|
|
433
641
|
const askForSession = async (history, sessions) => {
|
|
434
642
|
const titles = new Map();
|
|
435
643
|
void loadTitles(history, sessions, titles);
|
|
436
|
-
const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
|
|
644
|
+
const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
|
|
437
645
|
return picked === undefined ? undefined : SessionId(picked);
|
|
438
646
|
};
|
|
439
647
|
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
440
648
|
const settlePicker = (id) => {
|
|
441
649
|
const settle = pendingPicker?.settle;
|
|
442
650
|
pendingPicker = undefined;
|
|
651
|
+
herdr.unblock();
|
|
443
652
|
editor.disableSubmit = false;
|
|
444
653
|
tui.setFocus(editor);
|
|
445
654
|
settle?.(id);
|
|
@@ -466,10 +675,39 @@ export function apply(ctx, config) {
|
|
|
466
675
|
/** Take the keyboard for a picker and answer with the id it settled on. */
|
|
467
676
|
const openPicker = (picker, vet) => new Promise(resolve => {
|
|
468
677
|
pendingPicker = { picker, settle: resolve, vet };
|
|
678
|
+
// A picker owns the keyboard exactly as a gate does: nothing moves until
|
|
679
|
+
// the reader chooses, so it is the same kind of wait.
|
|
680
|
+
herdr.block(picker.card().title);
|
|
469
681
|
editor.disableSubmit = true;
|
|
470
682
|
tui.setFocus(null);
|
|
471
683
|
tui.requestRender();
|
|
472
684
|
});
|
|
685
|
+
/**
|
|
686
|
+
* Reverse search over recorded prompts, seeded with whatever is in the bar.
|
|
687
|
+
*
|
|
688
|
+
* A pick replaces the draft; a cancel leaves it exactly as it was, because the
|
|
689
|
+
* list was opened to look rather than to lose what is already typed.
|
|
690
|
+
*/
|
|
691
|
+
const openHistoryPicker = async () => {
|
|
692
|
+
if (!historyEnabled) {
|
|
693
|
+
model.notice('prompt history is disabled in ' + TUI_SETTINGS_NAMESPACE + ' settings');
|
|
694
|
+
tui.requestRender();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (promptHistory.entries().length === 0) {
|
|
698
|
+
const blocked = promptHistory.blockedReason();
|
|
699
|
+
model.notice(blocked === undefined ? 'no prompt history yet' : 'prompt history is unavailable: ' + blocked);
|
|
700
|
+
tui.requestRender();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
// The expanded text is what the reader wrote; a large paste sits in the bar
|
|
704
|
+
// as a marker, and seeding with it would filter out the prompt it came from.
|
|
705
|
+
const picked = await openPicker(new HistoryPicker(() => promptHistory.entries(), editor.getExpandedText(), () => keymap));
|
|
706
|
+
if (picked === undefined)
|
|
707
|
+
return;
|
|
708
|
+
editor.setText(picked);
|
|
709
|
+
tui.requestRender();
|
|
710
|
+
};
|
|
473
711
|
/** The roster as the picker paints it, refreshed when the picker opens. */
|
|
474
712
|
let presetRows = [];
|
|
475
713
|
/** Choose the mode a session that has not started yet will run. */
|
|
@@ -477,8 +715,30 @@ export function apply(ctx, config) {
|
|
|
477
715
|
if (agentPresets === undefined)
|
|
478
716
|
return undefined;
|
|
479
717
|
presetRows = await agentPresets.list();
|
|
480
|
-
return await openPicker(new PresetPicker(() => presetRows, () => currentId));
|
|
718
|
+
return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
|
|
481
719
|
};
|
|
720
|
+
/**
|
|
721
|
+
* The prompt bank for this working directory.
|
|
722
|
+
*
|
|
723
|
+
* The surface owns the editor, the picker, and the notices, so the bank is
|
|
724
|
+
* handed the few things it needs to reach them and nothing else: the commands
|
|
725
|
+
* stay free of terminal state and are exercised without one in the tests.
|
|
726
|
+
*/
|
|
727
|
+
const stashCwdLabel = shortPath(process.cwd(), process.env.HOME);
|
|
728
|
+
stash = new PromptStash({
|
|
729
|
+
getEditorText: () => editor.getExpandedText(),
|
|
730
|
+
setEditorText: text => {
|
|
731
|
+
editor.setText(text);
|
|
732
|
+
tui.requestRender();
|
|
733
|
+
},
|
|
734
|
+
// A question answers in this editor, so a draft written into a borrowed bar
|
|
735
|
+
// would become somebody's answer instead of a parked prompt.
|
|
736
|
+
editorIsAvailable: () => !promptBar.isBorrowed(),
|
|
737
|
+
notice: message => model.notice(message),
|
|
738
|
+
pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
|
|
739
|
+
confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
|
|
740
|
+
render: () => tui.requestRender(),
|
|
741
|
+
}, { cwd: process.cwd() });
|
|
482
742
|
/** Title the listed sessions without making the reader wait for the slowest log. */
|
|
483
743
|
const loadTitles = async (history, sessions, titles) => {
|
|
484
744
|
const queue = [...sessions];
|
|
@@ -604,9 +864,7 @@ export function apply(ctx, config) {
|
|
|
604
864
|
model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
|
|
605
865
|
}
|
|
606
866
|
const returned = id === activeSession && previous !== activeSession;
|
|
607
|
-
model.marker(returned
|
|
608
|
-
? 'back to the session this terminal drives'
|
|
609
|
-
: `viewing ${id}${id === activeSession ? '' : ` — ${LOCAL_KEYS_BACK}`}`);
|
|
867
|
+
model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
|
|
610
868
|
tui.requestRender();
|
|
611
869
|
};
|
|
612
870
|
const showAgentSession = async () => {
|
|
@@ -690,6 +948,13 @@ export function apply(ctx, config) {
|
|
|
690
948
|
activeSession = id;
|
|
691
949
|
viewedSession = id;
|
|
692
950
|
agent = handle;
|
|
951
|
+
// The agent's own id is reported rather than the requested one: a resume can
|
|
952
|
+
// be answered by the session the log actually holds.
|
|
953
|
+
herdr.session({
|
|
954
|
+
id: String(handle.sessionId),
|
|
955
|
+
cwd: process.cwd(),
|
|
956
|
+
reason: sessionStartReason({ forked: fork !== undefined, resumed: resume }),
|
|
957
|
+
});
|
|
693
958
|
// A session with no history to fold still has to present its first live
|
|
694
959
|
// card through the right scope, so the scope is set before any event can.
|
|
695
960
|
presentScope = handle.agent;
|
|
@@ -966,19 +1231,11 @@ export function apply(ctx, config) {
|
|
|
966
1231
|
}
|
|
967
1232
|
const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
|
|
968
1233
|
switch (command.kind) {
|
|
969
|
-
case 'current':
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
// Without a choice of its own the surface reports what the next step
|
|
974
|
-
// would actually use, not that it has no opinion.
|
|
975
|
-
? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
|
|
976
|
-
: `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
|
|
977
|
-
const providers = catalog.providers().map(provider => provider.id);
|
|
978
|
-
model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model>[/<effort>] switches, /model <provider> lists its models`);
|
|
979
|
-
tui.requestRender();
|
|
1234
|
+
case 'current':
|
|
1235
|
+
// Choosing by eye is the point of a terminal selector; the picker
|
|
1236
|
+
// heads itself with the route the next step will actually use.
|
|
1237
|
+
void openModelPicker();
|
|
980
1238
|
return;
|
|
981
|
-
}
|
|
982
1239
|
case 'list-models':
|
|
983
1240
|
void catalog.models(command.provider).then(entries => {
|
|
984
1241
|
model.notice(entries.length === 0
|
|
@@ -1038,6 +1295,124 @@ export function apply(ctx, config) {
|
|
|
1038
1295
|
model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
|
|
1039
1296
|
tui.requestRender();
|
|
1040
1297
|
};
|
|
1298
|
+
/** Whether a model picker's catalog is being read, so a second key cannot race it. */
|
|
1299
|
+
let openingModels = false;
|
|
1300
|
+
/**
|
|
1301
|
+
* The route the next step would actually use.
|
|
1302
|
+
*
|
|
1303
|
+
* Without a choice of its own the surface reports the composition default,
|
|
1304
|
+
* not that it has no opinion: the picker's heading and its marked row have to
|
|
1305
|
+
* agree with the status line about the route in force.
|
|
1306
|
+
*/
|
|
1307
|
+
const effectiveRoute = () => {
|
|
1308
|
+
const chosen = modelSwitch.current();
|
|
1309
|
+
if (chosen !== undefined)
|
|
1310
|
+
return chosen;
|
|
1311
|
+
const facts = statusFacts();
|
|
1312
|
+
if (facts.provider === undefined || facts.model === undefined)
|
|
1313
|
+
return undefined;
|
|
1314
|
+
return {
|
|
1315
|
+
provider: facts.provider,
|
|
1316
|
+
model: facts.model,
|
|
1317
|
+
...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
|
|
1318
|
+
};
|
|
1319
|
+
};
|
|
1320
|
+
/** Put the reader's route choice in force for the next step. */
|
|
1321
|
+
const applyRoute = (route) => {
|
|
1322
|
+
const current = effectiveRoute();
|
|
1323
|
+
// The levels belong to the route, so a switch clears an explicit effort
|
|
1324
|
+
// while re-picking the route already in force is not a switch.
|
|
1325
|
+
const keep = current !== undefined && current.provider === route.provider && current.model === route.model
|
|
1326
|
+
? current.reasoningEffort
|
|
1327
|
+
: undefined;
|
|
1328
|
+
modelSwitch.choose({
|
|
1329
|
+
provider: route.provider,
|
|
1330
|
+
model: route.model,
|
|
1331
|
+
...keep === undefined ? {} : { reasoningEffort: keep },
|
|
1332
|
+
});
|
|
1333
|
+
model.notice(`model set to ${route.provider}/${route.model} for the next step`);
|
|
1334
|
+
tui.requestRender();
|
|
1335
|
+
};
|
|
1336
|
+
/**
|
|
1337
|
+
* Offer the levels a route advertises, after the route is already in force.
|
|
1338
|
+
*
|
|
1339
|
+
* Cancelling the list is a real choice — the reader keeps the model with the
|
|
1340
|
+
* provider's own default — which is why the route is applied first. The list
|
|
1341
|
+
* is read before the picker opens because the rows are the route's own
|
|
1342
|
+
* metadata; a menu painted before that arrived could offer a level the
|
|
1343
|
+
* request would then be refused for.
|
|
1344
|
+
*/
|
|
1345
|
+
const offerRouteEfforts = async (route) => {
|
|
1346
|
+
if (catalog === undefined)
|
|
1347
|
+
return;
|
|
1348
|
+
try {
|
|
1349
|
+
const info = await catalog.efforts(route.provider, route.model);
|
|
1350
|
+
const efforts = info?.efforts ?? [];
|
|
1351
|
+
if (efforts.length === 0)
|
|
1352
|
+
return;
|
|
1353
|
+
const current = effectiveRoute();
|
|
1354
|
+
const effective = current !== undefined && current.provider === route.provider && current.model === route.model
|
|
1355
|
+
? current.reasoningEffort
|
|
1356
|
+
: undefined;
|
|
1357
|
+
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
|
|
1358
|
+
if (picked !== undefined)
|
|
1359
|
+
applyEffort(route.provider, route.model, picked);
|
|
1360
|
+
}
|
|
1361
|
+
catch (error) {
|
|
1362
|
+
model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1363
|
+
}
|
|
1364
|
+
tui.requestRender();
|
|
1365
|
+
};
|
|
1366
|
+
/**
|
|
1367
|
+
* Offer every model the configured routes advertise.
|
|
1368
|
+
*
|
|
1369
|
+
* Rows come from the routes the llm service registered — the providers this
|
|
1370
|
+
* deployment configured — and each provider's models join the open list as
|
|
1371
|
+
* its catalog resolves, so the picker is filterable before the slowest
|
|
1372
|
+
* adapter answers. A route whose catalog cannot be read stays reachable by
|
|
1373
|
+
* name through the text form rather than by an explanation in the list.
|
|
1374
|
+
*/
|
|
1375
|
+
const openModelPicker = async () => {
|
|
1376
|
+
if (catalog === undefined) {
|
|
1377
|
+
model.notice('this profile has no llm service, so models cannot be listed or switched');
|
|
1378
|
+
tui.requestRender();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const providers = catalog.providers();
|
|
1382
|
+
if (providers.length === 0) {
|
|
1383
|
+
model.notice('no provider is configured; add one before choosing a model');
|
|
1384
|
+
tui.requestRender();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
if (openingModels)
|
|
1388
|
+
return;
|
|
1389
|
+
openingModels = true;
|
|
1390
|
+
try {
|
|
1391
|
+
const routes = [];
|
|
1392
|
+
for (const provider of providers) {
|
|
1393
|
+
void catalog.models(provider.id).then(entries => {
|
|
1394
|
+
if (entries.length === 0)
|
|
1395
|
+
return;
|
|
1396
|
+
routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
|
|
1397
|
+
tui.requestRender();
|
|
1398
|
+
}).catch(() => {
|
|
1399
|
+
// One adapter's discovery failure is not the list's to explain.
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
|
|
1403
|
+
if (picked === undefined)
|
|
1404
|
+
return;
|
|
1405
|
+
const route = readModelRouteKey(picked);
|
|
1406
|
+
if (route === undefined)
|
|
1407
|
+
return;
|
|
1408
|
+
applyRoute(route);
|
|
1409
|
+
await offerRouteEfforts(route);
|
|
1410
|
+
}
|
|
1411
|
+
finally {
|
|
1412
|
+
openingModels = false;
|
|
1413
|
+
tui.requestRender();
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1041
1416
|
/**
|
|
1042
1417
|
* Offer the efforts the route in force advertises.
|
|
1043
1418
|
*
|
|
@@ -1067,7 +1442,7 @@ export function apply(ctx, config) {
|
|
|
1067
1442
|
model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
|
|
1068
1443
|
return;
|
|
1069
1444
|
}
|
|
1070
|
-
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}
|
|
1445
|
+
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
|
|
1071
1446
|
if (picked !== undefined)
|
|
1072
1447
|
applyEffort(facts.provider, facts.model, picked);
|
|
1073
1448
|
}
|
|
@@ -1136,8 +1511,26 @@ export function apply(ctx, config) {
|
|
|
1136
1511
|
? []
|
|
1137
1512
|
: registry()?.list(current).map(command => `/${command.name}`) ?? [];
|
|
1138
1513
|
const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
|
|
1139
|
-
return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${
|
|
1514
|
+
return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
|
|
1515
|
+
};
|
|
1516
|
+
/**
|
|
1517
|
+
* Whether the agent this surface is driving is in plan mode.
|
|
1518
|
+
*
|
|
1519
|
+
* The dock's fold is the fallback, for a composition without the plan
|
|
1520
|
+
* package: with the controller present its answer is the agent's own state
|
|
1521
|
+
* rather than a replay of the events this surface happened to see.
|
|
1522
|
+
*/
|
|
1523
|
+
const planState = () => {
|
|
1524
|
+
const current = agent?.agent;
|
|
1525
|
+
if (current === undefined)
|
|
1526
|
+
return undefined;
|
|
1527
|
+
const presets = ctx.get('agentPresets');
|
|
1528
|
+
return readPlanState({
|
|
1529
|
+
direct: name => ctx.get(name),
|
|
1530
|
+
forAgent: (target, name) => presets?.serviceFor(target, name),
|
|
1531
|
+
}, current);
|
|
1140
1532
|
};
|
|
1533
|
+
const planActive = () => planSelectedActive(planState(), work.state().planMode);
|
|
1141
1534
|
const runCommand = (name, line) => {
|
|
1142
1535
|
const current = agent;
|
|
1143
1536
|
const commands = registry();
|
|
@@ -1183,8 +1576,54 @@ export function apply(ctx, config) {
|
|
|
1183
1576
|
...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
|
|
1184
1577
|
});
|
|
1185
1578
|
};
|
|
1186
|
-
|
|
1187
|
-
|
|
1579
|
+
/** Show where history is kept, or forget it; the file is global to this machine. */
|
|
1580
|
+
const runHistoryCommand = (argument) => {
|
|
1581
|
+
// The line that asked for this is recorded before the command runs, but that
|
|
1582
|
+
// write rides the store's queue; waiting for it lets the count describe the
|
|
1583
|
+
// file the reader has, not the state before their own line landed.
|
|
1584
|
+
void promptHistory.flush().then(() => {
|
|
1585
|
+
if (argument === '') {
|
|
1586
|
+
const count = promptHistory.entries().length;
|
|
1587
|
+
const blocked = promptHistory.blockedReason();
|
|
1588
|
+
model.notice([
|
|
1589
|
+
count + (count === 1 ? ' prompt recorded' : ' prompts recorded'),
|
|
1590
|
+
promptHistory.path(),
|
|
1591
|
+
blocked === undefined ? undefined : 'writes disabled: ' + blocked,
|
|
1592
|
+
].filter(part => part !== undefined).join(' · '));
|
|
1593
|
+
tui.requestRender();
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
if (argument !== 'clear') {
|
|
1597
|
+
model.notice('usage: /history shows where history is kept · /history clear forgets every prompt');
|
|
1598
|
+
tui.requestRender();
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
// A refused write cannot remove anything, so saying "forgot 0 prompts"
|
|
1602
|
+
// would describe a successful clear the file never had.
|
|
1603
|
+
const blocked = promptHistory.blockedReason();
|
|
1604
|
+
if (blocked !== undefined) {
|
|
1605
|
+
model.notice('prompt history is unavailable: ' + blocked);
|
|
1606
|
+
tui.requestRender();
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
return promptHistory.clear().then(removed => {
|
|
1610
|
+
model.notice('forgot ' + removed + (removed === 1 ? ' prompt' : ' prompts'));
|
|
1611
|
+
tui.requestRender();
|
|
1612
|
+
});
|
|
1613
|
+
}).catch(error => {
|
|
1614
|
+
// The store keeps what the file still holds, so the reader is told the
|
|
1615
|
+
// clear failed rather than being shown a count that never landed.
|
|
1616
|
+
model.notice('could not clear prompt history: ' + (error instanceof Error ? error.message : String(error)));
|
|
1617
|
+
tui.requestRender();
|
|
1618
|
+
});
|
|
1619
|
+
};
|
|
1620
|
+
/**
|
|
1621
|
+
* Carry out one classified line, wherever it was asked for.
|
|
1622
|
+
*
|
|
1623
|
+
* A chord asks for the same things the command line does, so both arrive
|
|
1624
|
+
* here: a chord cannot behave differently from the command it stands for.
|
|
1625
|
+
*/
|
|
1626
|
+
const runSubmission = (submission) => {
|
|
1188
1627
|
switch (submission.kind) {
|
|
1189
1628
|
case 'empty':
|
|
1190
1629
|
return;
|
|
@@ -1223,9 +1662,51 @@ export function apply(ctx, config) {
|
|
|
1223
1662
|
model.notice(line);
|
|
1224
1663
|
tui.requestRender();
|
|
1225
1664
|
return;
|
|
1665
|
+
case 'keys': {
|
|
1666
|
+
const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
|
|
1667
|
+
if (submission.argument !== '' && layer === undefined) {
|
|
1668
|
+
model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
|
|
1669
|
+
}
|
|
1670
|
+
else {
|
|
1671
|
+
for (const line of renderKeymap(keymap, layer))
|
|
1672
|
+
model.notice(line);
|
|
1673
|
+
}
|
|
1674
|
+
tui.requestRender();
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1226
1677
|
case 'copy':
|
|
1227
1678
|
runCopyCommand();
|
|
1228
1679
|
return;
|
|
1680
|
+
case 'history':
|
|
1681
|
+
runHistoryCommand(submission.argument);
|
|
1682
|
+
return;
|
|
1683
|
+
case 'stash':
|
|
1684
|
+
// Submitting a command consumes the line it was typed on, so this path
|
|
1685
|
+
// can only carry a draft it was given; parking the bar's own draft is
|
|
1686
|
+
// what the chord is for.
|
|
1687
|
+
if (submission.argument.trim() === '')
|
|
1688
|
+
model.notice('usage: /stash <draft>, or ctrl+x then s to park the editor');
|
|
1689
|
+
else
|
|
1690
|
+
void stash?.stashEditor(submission.argument);
|
|
1691
|
+
return;
|
|
1692
|
+
case 'stash-draft':
|
|
1693
|
+
void stash?.stashEditor();
|
|
1694
|
+
return;
|
|
1695
|
+
case 'stash-pop':
|
|
1696
|
+
void stash?.pop(submission.selector);
|
|
1697
|
+
return;
|
|
1698
|
+
case 'stash-apply':
|
|
1699
|
+
void stash?.apply(submission.selector);
|
|
1700
|
+
return;
|
|
1701
|
+
case 'stash-list':
|
|
1702
|
+
void stash?.list(stashCwdLabel);
|
|
1703
|
+
return;
|
|
1704
|
+
case 'stash-drop':
|
|
1705
|
+
void stash?.drop(submission.selector);
|
|
1706
|
+
return;
|
|
1707
|
+
case 'stash-clear':
|
|
1708
|
+
void stash?.clear();
|
|
1709
|
+
return;
|
|
1229
1710
|
case 'status': {
|
|
1230
1711
|
const facts = statusFacts();
|
|
1231
1712
|
const context = facts.contextTokens === undefined
|
|
@@ -1263,6 +1744,11 @@ export function apply(ctx, config) {
|
|
|
1263
1744
|
tui.requestRender();
|
|
1264
1745
|
});
|
|
1265
1746
|
return;
|
|
1747
|
+
// Plan mode is a command the harness owns, so the chord asks the host for
|
|
1748
|
+
// the state and names the command for the other one: one key, both ways.
|
|
1749
|
+
case 'plan':
|
|
1750
|
+
runCommand('plan', planToggleLine(planActive()));
|
|
1751
|
+
return;
|
|
1266
1752
|
case 'command':
|
|
1267
1753
|
runCommand(submission.name, submission.line);
|
|
1268
1754
|
return;
|
|
@@ -1281,6 +1767,17 @@ export function apply(ctx, config) {
|
|
|
1281
1767
|
}
|
|
1282
1768
|
}
|
|
1283
1769
|
};
|
|
1770
|
+
editor.onSubmit = text => {
|
|
1771
|
+
const submission = classifySubmission(text);
|
|
1772
|
+
if (submission.kind === 'empty')
|
|
1773
|
+
return;
|
|
1774
|
+
// The library's own history feeds the up/down keys; the store below feeds
|
|
1775
|
+
// ghost completion and reverse search, and is global rather than per-session.
|
|
1776
|
+
editor.addToHistory(text);
|
|
1777
|
+
if (historyEnabled)
|
|
1778
|
+
promptHistory.record(text);
|
|
1779
|
+
runSubmission(submission);
|
|
1780
|
+
};
|
|
1284
1781
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
1285
1782
|
// The surface state — activity, timer, title, bell, job board — belongs to
|
|
1286
1783
|
// the agent this terminal drives, even while a child is on screen.
|
|
@@ -1289,18 +1786,24 @@ export function apply(ctx, config) {
|
|
|
1289
1786
|
turnOpen = true;
|
|
1290
1787
|
turnStartedAt = Date.now();
|
|
1291
1788
|
terminal.write(windowTitle(process.cwd(), 'working'));
|
|
1789
|
+
herdr.working();
|
|
1292
1790
|
}
|
|
1293
1791
|
if (event.type === 'turn/end') {
|
|
1294
1792
|
const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
|
|
1295
1793
|
turnOpen = false;
|
|
1296
1794
|
turnStartedAt = undefined;
|
|
1297
1795
|
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1796
|
+
herdr.idle();
|
|
1298
1797
|
if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
|
|
1299
1798
|
terminal.write(BELL);
|
|
1300
1799
|
// A job the turn started may have settled while the reader was watching
|
|
1301
1800
|
// something else, and nothing else refreshes a live board.
|
|
1302
1801
|
refreshJobs();
|
|
1303
1802
|
}
|
|
1803
|
+
// A claim or a discard changes what is queued, and that belongs to this
|
|
1804
|
+
// session even while the transcript shows a child's conversation.
|
|
1805
|
+
if (event.type === 'agent/inbox/spliced')
|
|
1806
|
+
tui.requestRender();
|
|
1304
1807
|
}
|
|
1305
1808
|
if (session.id !== viewedSession)
|
|
1306
1809
|
return;
|
|
@@ -1338,7 +1841,7 @@ export function apply(ctx, config) {
|
|
|
1338
1841
|
if (request.agent.id !== activeSession)
|
|
1339
1842
|
return next();
|
|
1340
1843
|
return new Promise(resolve => {
|
|
1341
|
-
const gate = new ApprovalGate(request.toolName, request.reason);
|
|
1844
|
+
const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
|
|
1342
1845
|
request.signal?.addEventListener('abort', () => {
|
|
1343
1846
|
gate.cancel();
|
|
1344
1847
|
if (pending?.gate === gate)
|
|
@@ -1356,20 +1859,33 @@ export function apply(ctx, config) {
|
|
|
1356
1859
|
if (questions.length === 0)
|
|
1357
1860
|
return next();
|
|
1358
1861
|
return new Promise(resolve => {
|
|
1359
|
-
const gate = new QuestionGate(questions);
|
|
1862
|
+
const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
|
|
1360
1863
|
// The seam takes mutable selection arrays and an optional custom field, so
|
|
1361
1864
|
// the read-only gate answer is copied into that exact shape here.
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
settle: answers => resolve({
|
|
1865
|
+
const settle = (answers) => {
|
|
1866
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
1867
|
+
resolve({
|
|
1366
1868
|
answers: answers.map(answer => ({
|
|
1367
1869
|
id: answer.id,
|
|
1368
1870
|
selected: [...answer.selected],
|
|
1369
1871
|
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
1370
1872
|
})),
|
|
1371
|
-
})
|
|
1372
|
-
}
|
|
1873
|
+
});
|
|
1874
|
+
};
|
|
1875
|
+
// A question whose caller is gone has no reader, so the gate must release
|
|
1876
|
+
// the keyboard instead of collecting an answer the aborted call discards.
|
|
1877
|
+
const onAbort = () => {
|
|
1878
|
+
gate.cancel();
|
|
1879
|
+
if (pending?.gate === gate)
|
|
1880
|
+
closeGate();
|
|
1881
|
+
settle([]);
|
|
1882
|
+
};
|
|
1883
|
+
openGate({ kind: 'question', gate, settle });
|
|
1884
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1885
|
+
// A signal that aborted before the listener existed never emits, so the
|
|
1886
|
+
// state has to be read once after subscribing.
|
|
1887
|
+
if (request.signal?.aborted === true)
|
|
1888
|
+
onAbort();
|
|
1373
1889
|
});
|
|
1374
1890
|
}));
|
|
1375
1891
|
disposers.push(ctx.on('commands/change', () => installCompletion()));
|
|
@@ -1404,17 +1920,11 @@ export function apply(ctx, config) {
|
|
|
1404
1920
|
disposers.push(ctx.on('settings/updated', ns => {
|
|
1405
1921
|
if (String(ns) !== TUI_SETTINGS_NAMESPACE)
|
|
1406
1922
|
return;
|
|
1407
|
-
|
|
1408
|
-
applyTheme();
|
|
1409
|
-
applyDisplay();
|
|
1923
|
+
applySettings();
|
|
1410
1924
|
// Both caches hold rows under the old table, so they have to be told the
|
|
1411
1925
|
// table moved; a repaint alone would reuse what they already stored.
|
|
1412
1926
|
markdown.invalidate();
|
|
1413
1927
|
view.invalidate();
|
|
1414
|
-
if (pendingSettingsProblem !== undefined) {
|
|
1415
|
-
model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
|
|
1416
|
-
pendingSettingsProblem = undefined;
|
|
1417
|
-
}
|
|
1418
1928
|
tui.requestRender();
|
|
1419
1929
|
}));
|
|
1420
1930
|
const degraded = describeMissingOptional(probe);
|
|
@@ -1458,13 +1968,16 @@ export function apply(ctx, config) {
|
|
|
1458
1968
|
if (!resolved.resumePicker)
|
|
1459
1969
|
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
1460
1970
|
tui.start();
|
|
1971
|
+
// Loaded now so the footer's count is real from the first paint, and because
|
|
1972
|
+
// a bank that cannot be read should say so before a reader trusts it.
|
|
1973
|
+
void stash?.open();
|
|
1461
1974
|
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1975
|
+
// Claiming the pane's agent row does not wait for a session: the pane is
|
|
1976
|
+
// already on screen and already idle, and a session may still be chosen.
|
|
1977
|
+
herdr.publish();
|
|
1462
1978
|
// A refused settings edit is only visible now that the surface owns the
|
|
1463
|
-
// screen;
|
|
1464
|
-
|
|
1465
|
-
model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
|
|
1466
|
-
pendingSettingsProblem = undefined;
|
|
1467
|
-
}
|
|
1979
|
+
// screen; whatever the scope found before this point prints here instead.
|
|
1980
|
+
settingsNotice.open(message => model.notice(message));
|
|
1468
1981
|
await boot();
|
|
1469
1982
|
};
|
|
1470
1983
|
void start().catch((error) => {
|