@sagmans/dsh-tui 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -21
- 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/status.d.ts +10 -0
- package/lib/agent/status.d.ts.map +1 -1
- package/lib/agent/status.js +2 -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/index.d.ts.map +1 -1
- package/lib/index.js +383 -102
- 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 +740 -0
- package/lib/input/actions.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 +170 -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 +6 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +6 -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/theme-settings.d.ts +43 -8
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +66 -3
- 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 +31 -5
- package/lib/theme-tokens.js.map +1 -1
- package/lib/ui/editor.d.ts +18 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +80 -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/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 +30 -8
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +63 -23
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/prompt.d.ts +30 -0
- package/lib/ui/prompt.d.ts.map +1 -0
- package/lib/ui/prompt.js +45 -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/status.d.ts +4 -0
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +8 -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 +48 -0
- package/lib/work.d.ts.map +1 -1
- package/lib/work.js +44 -0
- package/lib/work.js.map +1 -1
- package/package.json +3 -2
package/lib/index.js
CHANGED
|
@@ -9,13 +9,16 @@ import { createToolPresenter } from "./agent/present.js";
|
|
|
9
9
|
import { forkPoint } from "./agent/fork.js";
|
|
10
10
|
import { PROFILE_NAME, resumeHint } from "./identity.js";
|
|
11
11
|
import { createStatusFacts } from "./agent/status.js";
|
|
12
|
-
import { ModelSwitch, createModelCatalog, parseModelArgument } from "./agent/model.js";
|
|
12
|
+
import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
|
|
13
13
|
import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
|
|
14
14
|
import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
|
|
15
15
|
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
16
16
|
import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
|
|
17
17
|
import { createCompletionProvider } from "./input/completion.js";
|
|
18
18
|
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
19
|
+
import { createDeferredNotice } from "./settings-notice.js";
|
|
20
|
+
import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
|
|
21
|
+
import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
|
|
19
22
|
import { resolveConfig } from "./config.js";
|
|
20
23
|
import { FoldCursor } from "./fold-cursor.js";
|
|
21
24
|
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
@@ -26,16 +29,21 @@ import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
|
26
29
|
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
27
30
|
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
28
31
|
import { detectColourMode } from "./theme-capability.js";
|
|
29
|
-
import { defaultSettings, readScope, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
32
|
+
import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
33
|
+
import { pendingPrompts } from "./queue.js";
|
|
30
34
|
import { renderThemeTable } from "./theme-command.js";
|
|
35
|
+
import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
|
|
31
36
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
32
37
|
import { formatTokens } from "./tokens.js";
|
|
33
38
|
import { TranscriptModel } from "./transcript.js";
|
|
34
|
-
import { WorkFold, describeTodos } from "./work.js";
|
|
39
|
+
import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
|
|
35
40
|
import { WorkDock } from "./ui/dock.js";
|
|
36
|
-
import {
|
|
41
|
+
import { GateInputBar } from "./ui/gate-input.js";
|
|
42
|
+
import { PromptBar } from "./ui/prompt.js";
|
|
37
43
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
38
|
-
import {
|
|
44
|
+
import { createMermaidTransform } from "./ui/mermaid.js";
|
|
45
|
+
import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
|
|
46
|
+
import { QueueBar } from "./ui/queue.js";
|
|
39
47
|
import { StatusBar } from "./ui/status.js";
|
|
40
48
|
import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
|
|
41
49
|
export const name = 'tui';
|
|
@@ -47,10 +55,12 @@ export const name = 'tui';
|
|
|
47
55
|
* every card to a bare generic row.
|
|
48
56
|
*/
|
|
49
57
|
export const inject = ['agents', 'tools'];
|
|
50
|
-
/**
|
|
51
|
-
const
|
|
52
|
-
/**
|
|
53
|
-
const
|
|
58
|
+
/** One second in the unit a chord window is scheduled in. */
|
|
59
|
+
const MS_PER_SECOND = 1000;
|
|
60
|
+
/** What the back hint names when the reader has unbound the key it would advertise. */
|
|
61
|
+
const BACK_HINT_FALLBACK = 'ctrl+b';
|
|
62
|
+
/** What the reader presses to leave a view they did not open. */
|
|
63
|
+
const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
|
|
54
64
|
/** Stored sessions titled at once when the picker opens. */
|
|
55
65
|
const TITLE_CONCURRENCY = 4;
|
|
56
66
|
/** How often the running-state clock repaints while a turn is open. */
|
|
@@ -108,11 +118,14 @@ export function apply(ctx, config) {
|
|
|
108
118
|
* late-bound read that the injection point and the change event both use.
|
|
109
119
|
*/
|
|
110
120
|
let readSection = () => defaultSettings();
|
|
111
|
-
/**
|
|
112
|
-
|
|
121
|
+
/**
|
|
122
|
+
* A refused settings edit, kept until the surface can show it: stderr is
|
|
123
|
+
* behind the alt screen, and the section is read on a schedule of its own.
|
|
124
|
+
*/
|
|
125
|
+
const settingsNotice = createDeferredNotice();
|
|
113
126
|
let current = createTheme(themeMode());
|
|
114
|
-
const applyTheme = () => {
|
|
115
|
-
current = createTheme(themeMode(), toOverrides(
|
|
127
|
+
const applyTheme = (section) => {
|
|
128
|
+
current = createTheme(themeMode(), toOverrides(section));
|
|
116
129
|
};
|
|
117
130
|
const theme = {
|
|
118
131
|
get revision() { return current.revision; },
|
|
@@ -137,13 +150,56 @@ export function apply(ctx, config) {
|
|
|
137
150
|
*/
|
|
138
151
|
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
139
152
|
/**
|
|
140
|
-
*
|
|
153
|
+
* How a reply's mermaid fences draw, seeded from the reader's section.
|
|
154
|
+
*
|
|
155
|
+
* The transform reads this per render instead of capturing it, because the
|
|
156
|
+
* settings document is hot-reloaded and a session already on screen has to
|
|
157
|
+
* follow the edit.
|
|
158
|
+
*/
|
|
159
|
+
let mermaidMode = defaultSettings().mermaid;
|
|
160
|
+
/** The keys that start a chord, and how long one waits; the settings document owns all of it. */
|
|
161
|
+
let prefixKeys = DEFAULT_PREFIX_KEYS;
|
|
162
|
+
let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
|
|
163
|
+
/** Every action's keys in force; the settings document owns it and a press reads it live. */
|
|
164
|
+
let keymap = defaultKeymap();
|
|
165
|
+
/**
|
|
166
|
+
* The chord between a prefix and the action that follows it.
|
|
167
|
+
*
|
|
168
|
+
* Built here, before the terminal exists, because the settings scope applies
|
|
169
|
+
* first and has to be able to end a chord armed under the keymap it replaced.
|
|
170
|
+
* The repaint the window also wants is late-bound: only a key press reaches
|
|
171
|
+
* it, and no key can arrive before the surface has started.
|
|
172
|
+
*/
|
|
173
|
+
const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
|
|
174
|
+
/**
|
|
175
|
+
* Seed the display the reader configured.
|
|
176
|
+
*
|
|
177
|
+
* The key toggles nested calls for one session, but a settings edit is a
|
|
178
|
+
* deliberate act, so it re-seeds and becomes the new starting point; the
|
|
179
|
+
* mermaid mode has no key of its own and only ever comes from the document.
|
|
180
|
+
*/
|
|
181
|
+
const applyDisplay = (section) => {
|
|
182
|
+
viewState.expandSubCalls = section.subcalls === 'inline';
|
|
183
|
+
mermaidMode = section.mermaid;
|
|
184
|
+
prefixKeys = section.prefixes;
|
|
185
|
+
prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
|
|
186
|
+
keymap = section.keymap;
|
|
187
|
+
// Installed where the library reads it, so a remap lands on the next press
|
|
188
|
+
// rather than at the next restart.
|
|
189
|
+
installKeybindings(keymap);
|
|
190
|
+
// A chord armed under the keymap the reader just replaced is not their chord.
|
|
191
|
+
keyChord.disarm();
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Read the reader's section once and apply everything it configures.
|
|
141
195
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
196
|
+
* One read per change, because a refused section is reported on the way past:
|
|
197
|
+
* reading it once per field would show the reader the same refusal twice.
|
|
144
198
|
*/
|
|
145
|
-
const
|
|
146
|
-
|
|
199
|
+
const applySettings = () => {
|
|
200
|
+
const section = readSection();
|
|
201
|
+
applyTheme(section);
|
|
202
|
+
applyDisplay(section);
|
|
147
203
|
};
|
|
148
204
|
/**
|
|
149
205
|
* Own the section, so the harness validates and persists it for the reader.
|
|
@@ -154,10 +210,20 @@ export function apply(ctx, config) {
|
|
|
154
210
|
* may be touched in.
|
|
155
211
|
*/
|
|
156
212
|
ctx.inject(['settings'], settingsCtx => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
// Registration parses the document against the schema, so a section the
|
|
214
|
+
// schema itself refuses throws here — inside a fiber whose failure the
|
|
215
|
+
// screen never shows. Reporting it through the same holder keeps a typo
|
|
216
|
+
// from costing the reader every setting they wrote, silently.
|
|
217
|
+
let scope;
|
|
218
|
+
try {
|
|
219
|
+
scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
settingsNotice.post(settingsProblemMessage(error));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
readSection = () => readScope(scope, message => settingsNotice.post(message));
|
|
226
|
+
applySettings();
|
|
161
227
|
});
|
|
162
228
|
/**
|
|
163
229
|
* The agent scope the tool presenter resolves against.
|
|
@@ -197,7 +263,7 @@ export function apply(ctx, config) {
|
|
|
197
263
|
let jobs = [];
|
|
198
264
|
const roster = new SubagentRoster();
|
|
199
265
|
const subagentControl = createSubagentControl(ctx);
|
|
200
|
-
const markdown = new MarkdownRenderer(theme.markdown);
|
|
266
|
+
const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
|
|
201
267
|
const restore = createRestoreRegistry();
|
|
202
268
|
const terminal = new ProcessTerminal();
|
|
203
269
|
const tui = new WarningSafeTui(terminal);
|
|
@@ -207,14 +273,22 @@ export function apply(ctx, config) {
|
|
|
207
273
|
let viewedSession = resolved.sessionId;
|
|
208
274
|
let pending;
|
|
209
275
|
let pendingPicker;
|
|
210
|
-
/** A pick being vetted owns the keyboard:
|
|
276
|
+
/** A pick being vetted owns the list, not the keyboard: filtering stays live while its verdict is read. */
|
|
211
277
|
let vetting = false;
|
|
212
278
|
const view = new TranscriptView(model, theme, markdown, {
|
|
213
279
|
state: () => viewState,
|
|
214
280
|
gate: () => pending?.gate.card(),
|
|
215
281
|
picker: () => pendingPicker?.picker.card(),
|
|
282
|
+
keys: () => keymap,
|
|
216
283
|
});
|
|
217
|
-
|
|
284
|
+
// The key map goes in before the bar exists, so no press can be read as the
|
|
285
|
+
// send the library submits on by default. A settings document read after this
|
|
286
|
+
// point installs over it, which is why the bar reads the map per press.
|
|
287
|
+
installKeybindings(keymap);
|
|
288
|
+
const editor = new GateInputBar(tui, theme.editor, () => keymap);
|
|
289
|
+
// Answers are written in the reader's own editor, which is why a question
|
|
290
|
+
// borrows the bar instead of drawing a second one beside it.
|
|
291
|
+
const promptBar = new PromptBar(editor);
|
|
218
292
|
const disposers = [];
|
|
219
293
|
// The presenter closure outlives the composition's own teardown, so it must
|
|
220
294
|
// not keep an agent alive after its world unwinds.
|
|
@@ -237,15 +311,25 @@ export function apply(ctx, config) {
|
|
|
237
311
|
activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
|
|
238
312
|
override: () => modelSwitch.current(),
|
|
239
313
|
home: process.env.HOME,
|
|
314
|
+
chord: () => keyChord.hint(),
|
|
315
|
+
// Read per paint rather than written into the marker the view left behind:
|
|
316
|
+
// a hint stored with the transcript would keep naming the key of the day it
|
|
317
|
+
// was written, and the reader may remap it with the row already on screen.
|
|
318
|
+
back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
|
|
240
319
|
});
|
|
241
320
|
const statusBar = new StatusBar(statusFacts, theme);
|
|
242
321
|
const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
|
|
322
|
+
// The queue is read from the agent this terminal drives rather than from the
|
|
323
|
+
// session on screen, because it sits on the editor that submits to that agent.
|
|
324
|
+
const queue = new QueueBar(() => pendingPrompts(ctx, liveSession(activeSession)), theme);
|
|
243
325
|
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
244
326
|
const statusTicker = setInterval(() => {
|
|
245
327
|
if (turnOpen)
|
|
246
328
|
tui.requestRender();
|
|
247
329
|
}, STATUS_TICK_MS);
|
|
248
330
|
disposers.push(() => clearInterval(statusTicker));
|
|
331
|
+
// A window that outlived the surface would repaint a screen that is gone.
|
|
332
|
+
disposers.push(() => keyChord.disarm());
|
|
249
333
|
restore.add(() => tui.stop());
|
|
250
334
|
ctx.effect(() => () => {
|
|
251
335
|
restore.restore();
|
|
@@ -262,7 +346,11 @@ export function apply(ctx, config) {
|
|
|
262
346
|
// Work state earns rows only when there is some: a dock that always
|
|
263
347
|
// occupied a row would cost every conversation one line of transcript.
|
|
264
348
|
{ component: dock, basis: 'auto', shrink: 0, minSize: 0 },
|
|
265
|
-
|
|
349
|
+
// Queued input earns rows only while something is waiting, and it gives
|
|
350
|
+
// them up before the editor does: the bar being typed in outranks what is
|
|
351
|
+
// waiting behind it.
|
|
352
|
+
{ component: queue, basis: 'auto', shrink: 2, minSize: 0 },
|
|
353
|
+
{ component: new VStack([{ component: promptBar, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 0 },
|
|
266
354
|
{ component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
|
|
267
355
|
]));
|
|
268
356
|
tui.setFocus(editor);
|
|
@@ -294,14 +382,65 @@ export function apply(ctx, config) {
|
|
|
294
382
|
// A gate owns the keyboard: the editor must not collect the decision keys.
|
|
295
383
|
editor.disableSubmit = true;
|
|
296
384
|
tui.setFocus(null);
|
|
385
|
+
// A question is answered in this editor, which the gate draws under the row
|
|
386
|
+
// being answered, so the hardware cursor belongs to it while it is borrowed.
|
|
387
|
+
// It is set after the focus is cleared, which unmarks the component it left.
|
|
388
|
+
editor.focused = next.kind === 'question';
|
|
297
389
|
tui.requestRender();
|
|
298
390
|
};
|
|
299
391
|
const closeGate = () => {
|
|
300
392
|
pending = undefined;
|
|
301
393
|
editor.disableSubmit = false;
|
|
394
|
+
// The question is answered or skipped, so the reader gets their prompt back
|
|
395
|
+
// in the bar they left it in.
|
|
396
|
+
promptBar.giveBack();
|
|
302
397
|
tui.setFocus(editor);
|
|
303
398
|
tui.requestRender();
|
|
304
399
|
};
|
|
400
|
+
/**
|
|
401
|
+
* What each key the surface answers itself does; false hands the press back.
|
|
402
|
+
*
|
|
403
|
+
* Keyed by the table's own ids, so a key added to {@link SURFACE_KEYS}
|
|
404
|
+
* without a handler here fails to compile rather than doing nothing.
|
|
405
|
+
*/
|
|
406
|
+
const surfaceActions = {
|
|
407
|
+
toolDetail: () => {
|
|
408
|
+
viewState.expandCards = !viewState.expandCards;
|
|
409
|
+
tui.requestRender();
|
|
410
|
+
return true;
|
|
411
|
+
},
|
|
412
|
+
subCalls: () => {
|
|
413
|
+
viewState.expandSubCalls = !viewState.expandSubCalls;
|
|
414
|
+
tui.requestRender();
|
|
415
|
+
return true;
|
|
416
|
+
},
|
|
417
|
+
reasoning: () => {
|
|
418
|
+
viewState.expandReasoning = !viewState.expandReasoning;
|
|
419
|
+
tui.requestRender();
|
|
420
|
+
return true;
|
|
421
|
+
},
|
|
422
|
+
effort: () => {
|
|
423
|
+
void openEffortPicker();
|
|
424
|
+
return true;
|
|
425
|
+
},
|
|
426
|
+
back: () => {
|
|
427
|
+
if (viewedSession !== activeSession)
|
|
428
|
+
void showAgentSession();
|
|
429
|
+
return true;
|
|
430
|
+
},
|
|
431
|
+
interrupt: () => {
|
|
432
|
+
// In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
|
|
433
|
+
// decides: stop the work in flight, or leave when there is none.
|
|
434
|
+
if (!turnOpen) {
|
|
435
|
+
requestExit(0);
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
agent?.interrupt();
|
|
439
|
+
model.notice('interrupt requested');
|
|
440
|
+
tui.requestRender();
|
|
441
|
+
return true;
|
|
442
|
+
},
|
|
443
|
+
};
|
|
305
444
|
disposers.push(tui.addInputListener(data => {
|
|
306
445
|
// A key arrives as a press and a release once the surface asks the terminal
|
|
307
446
|
// to report key events, and this library drops the release only for the
|
|
@@ -333,13 +472,19 @@ export function apply(ctx, config) {
|
|
|
333
472
|
return { consume: true };
|
|
334
473
|
}
|
|
335
474
|
if (pendingPicker !== undefined) {
|
|
336
|
-
if (vetting)
|
|
337
|
-
return { consume: true };
|
|
338
475
|
const action = pendingPicker.picker.handleKey(data);
|
|
339
476
|
if (action === undefined) {
|
|
340
477
|
tui.requestRender();
|
|
341
478
|
return { consume: true };
|
|
342
479
|
}
|
|
480
|
+
if (vetting) {
|
|
481
|
+
// A refusal check must not take the keyboard with it: the reader keeps
|
|
482
|
+
// filtering and can still leave, while a second pick waits for the
|
|
483
|
+
// first verdict rather than racing it.
|
|
484
|
+
if (action.kind === 'cancel')
|
|
485
|
+
settlePicker(undefined);
|
|
486
|
+
return { consume: true };
|
|
487
|
+
}
|
|
343
488
|
if (action.kind === 'cancel') {
|
|
344
489
|
settlePicker(undefined);
|
|
345
490
|
return { consume: true };
|
|
@@ -370,44 +515,23 @@ export function apply(ctx, config) {
|
|
|
370
515
|
})();
|
|
371
516
|
return { consume: true };
|
|
372
517
|
}
|
|
373
|
-
//
|
|
518
|
+
// A chord is the surface's second key: the prefix is consumed and the
|
|
519
|
+
// footer names what may follow, while a key that finishes nothing is handed
|
|
520
|
+
// on. Detail the reader asked for is always available, even mid-turn: the
|
|
374
521
|
// collapsed view is a default, not the only state.
|
|
375
|
-
|
|
376
|
-
|
|
522
|
+
const chorded = keyChord.handle(data);
|
|
523
|
+
if (chorded !== undefined) {
|
|
524
|
+
if (chorded.kind === 'action')
|
|
525
|
+
runSubmission(chorded.binding.submission);
|
|
377
526
|
tui.requestRender();
|
|
378
527
|
return { consume: true };
|
|
379
528
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return { consume: true };
|
|
529
|
+
for (const binding of surfaceBindings(keymap)) {
|
|
530
|
+
if (!matchesKey(data, binding.key))
|
|
531
|
+
continue;
|
|
532
|
+
return surfaceActions[binding.action]() ? { consume: true } : undefined;
|
|
384
533
|
}
|
|
385
|
-
|
|
386
|
-
viewState.expandSubCalls = !viewState.expandSubCalls;
|
|
387
|
-
tui.requestRender();
|
|
388
|
-
return { consume: true };
|
|
389
|
-
}
|
|
390
|
-
if (matchesKey(data, 'ctrl+t')) {
|
|
391
|
-
void openEffortPicker();
|
|
392
|
-
return { consume: true };
|
|
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 };
|
|
408
|
-
}
|
|
409
|
-
requestExit(0);
|
|
410
|
-
return { consume: true };
|
|
534
|
+
return undefined;
|
|
411
535
|
}));
|
|
412
536
|
const registry = () => ctx.get('commands');
|
|
413
537
|
/**
|
|
@@ -433,7 +557,7 @@ export function apply(ctx, config) {
|
|
|
433
557
|
const askForSession = async (history, sessions) => {
|
|
434
558
|
const titles = new Map();
|
|
435
559
|
void loadTitles(history, sessions, titles);
|
|
436
|
-
const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
|
|
560
|
+
const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
|
|
437
561
|
return picked === undefined ? undefined : SessionId(picked);
|
|
438
562
|
};
|
|
439
563
|
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
@@ -477,7 +601,7 @@ export function apply(ctx, config) {
|
|
|
477
601
|
if (agentPresets === undefined)
|
|
478
602
|
return undefined;
|
|
479
603
|
presetRows = await agentPresets.list();
|
|
480
|
-
return await openPicker(new PresetPicker(() => presetRows, () => currentId));
|
|
604
|
+
return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
|
|
481
605
|
};
|
|
482
606
|
/** Title the listed sessions without making the reader wait for the slowest log. */
|
|
483
607
|
const loadTitles = async (history, sessions, titles) => {
|
|
@@ -604,9 +728,7 @@ export function apply(ctx, config) {
|
|
|
604
728
|
model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
|
|
605
729
|
}
|
|
606
730
|
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}`}`);
|
|
731
|
+
model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
|
|
610
732
|
tui.requestRender();
|
|
611
733
|
};
|
|
612
734
|
const showAgentSession = async () => {
|
|
@@ -966,19 +1088,11 @@ export function apply(ctx, config) {
|
|
|
966
1088
|
}
|
|
967
1089
|
const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
|
|
968
1090
|
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();
|
|
1091
|
+
case 'current':
|
|
1092
|
+
// Choosing by eye is the point of a terminal selector; the picker
|
|
1093
|
+
// heads itself with the route the next step will actually use.
|
|
1094
|
+
void openModelPicker();
|
|
980
1095
|
return;
|
|
981
|
-
}
|
|
982
1096
|
case 'list-models':
|
|
983
1097
|
void catalog.models(command.provider).then(entries => {
|
|
984
1098
|
model.notice(entries.length === 0
|
|
@@ -1038,6 +1152,124 @@ export function apply(ctx, config) {
|
|
|
1038
1152
|
model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
|
|
1039
1153
|
tui.requestRender();
|
|
1040
1154
|
};
|
|
1155
|
+
/** Whether a model picker's catalog is being read, so a second key cannot race it. */
|
|
1156
|
+
let openingModels = false;
|
|
1157
|
+
/**
|
|
1158
|
+
* The route the next step would actually use.
|
|
1159
|
+
*
|
|
1160
|
+
* Without a choice of its own the surface reports the composition default,
|
|
1161
|
+
* not that it has no opinion: the picker's heading and its marked row have to
|
|
1162
|
+
* agree with the status line about the route in force.
|
|
1163
|
+
*/
|
|
1164
|
+
const effectiveRoute = () => {
|
|
1165
|
+
const chosen = modelSwitch.current();
|
|
1166
|
+
if (chosen !== undefined)
|
|
1167
|
+
return chosen;
|
|
1168
|
+
const facts = statusFacts();
|
|
1169
|
+
if (facts.provider === undefined || facts.model === undefined)
|
|
1170
|
+
return undefined;
|
|
1171
|
+
return {
|
|
1172
|
+
provider: facts.provider,
|
|
1173
|
+
model: facts.model,
|
|
1174
|
+
...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
|
|
1175
|
+
};
|
|
1176
|
+
};
|
|
1177
|
+
/** Put the reader's route choice in force for the next step. */
|
|
1178
|
+
const applyRoute = (route) => {
|
|
1179
|
+
const current = effectiveRoute();
|
|
1180
|
+
// The levels belong to the route, so a switch clears an explicit effort
|
|
1181
|
+
// while re-picking the route already in force is not a switch.
|
|
1182
|
+
const keep = current !== undefined && current.provider === route.provider && current.model === route.model
|
|
1183
|
+
? current.reasoningEffort
|
|
1184
|
+
: undefined;
|
|
1185
|
+
modelSwitch.choose({
|
|
1186
|
+
provider: route.provider,
|
|
1187
|
+
model: route.model,
|
|
1188
|
+
...keep === undefined ? {} : { reasoningEffort: keep },
|
|
1189
|
+
});
|
|
1190
|
+
model.notice(`model set to ${route.provider}/${route.model} for the next step`);
|
|
1191
|
+
tui.requestRender();
|
|
1192
|
+
};
|
|
1193
|
+
/**
|
|
1194
|
+
* Offer the levels a route advertises, after the route is already in force.
|
|
1195
|
+
*
|
|
1196
|
+
* Cancelling the list is a real choice — the reader keeps the model with the
|
|
1197
|
+
* provider's own default — which is why the route is applied first. The list
|
|
1198
|
+
* is read before the picker opens because the rows are the route's own
|
|
1199
|
+
* metadata; a menu painted before that arrived could offer a level the
|
|
1200
|
+
* request would then be refused for.
|
|
1201
|
+
*/
|
|
1202
|
+
const offerRouteEfforts = async (route) => {
|
|
1203
|
+
if (catalog === undefined)
|
|
1204
|
+
return;
|
|
1205
|
+
try {
|
|
1206
|
+
const info = await catalog.efforts(route.provider, route.model);
|
|
1207
|
+
const efforts = info?.efforts ?? [];
|
|
1208
|
+
if (efforts.length === 0)
|
|
1209
|
+
return;
|
|
1210
|
+
const current = effectiveRoute();
|
|
1211
|
+
const effective = current !== undefined && current.provider === route.provider && current.model === route.model
|
|
1212
|
+
? current.reasoningEffort
|
|
1213
|
+
: undefined;
|
|
1214
|
+
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
|
|
1215
|
+
if (picked !== undefined)
|
|
1216
|
+
applyEffort(route.provider, route.model, picked);
|
|
1217
|
+
}
|
|
1218
|
+
catch (error) {
|
|
1219
|
+
model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1220
|
+
}
|
|
1221
|
+
tui.requestRender();
|
|
1222
|
+
};
|
|
1223
|
+
/**
|
|
1224
|
+
* Offer every model the configured routes advertise.
|
|
1225
|
+
*
|
|
1226
|
+
* Rows come from the routes the llm service registered — the providers this
|
|
1227
|
+
* deployment configured — and each provider's models join the open list as
|
|
1228
|
+
* its catalog resolves, so the picker is filterable before the slowest
|
|
1229
|
+
* adapter answers. A route whose catalog cannot be read stays reachable by
|
|
1230
|
+
* name through the text form rather than by an explanation in the list.
|
|
1231
|
+
*/
|
|
1232
|
+
const openModelPicker = async () => {
|
|
1233
|
+
if (catalog === undefined) {
|
|
1234
|
+
model.notice('this profile has no llm service, so models cannot be listed or switched');
|
|
1235
|
+
tui.requestRender();
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const providers = catalog.providers();
|
|
1239
|
+
if (providers.length === 0) {
|
|
1240
|
+
model.notice('no provider is configured; add one before choosing a model');
|
|
1241
|
+
tui.requestRender();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (openingModels)
|
|
1245
|
+
return;
|
|
1246
|
+
openingModels = true;
|
|
1247
|
+
try {
|
|
1248
|
+
const routes = [];
|
|
1249
|
+
for (const provider of providers) {
|
|
1250
|
+
void catalog.models(provider.id).then(entries => {
|
|
1251
|
+
if (entries.length === 0)
|
|
1252
|
+
return;
|
|
1253
|
+
routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
|
|
1254
|
+
tui.requestRender();
|
|
1255
|
+
}).catch(() => {
|
|
1256
|
+
// One adapter's discovery failure is not the list's to explain.
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
|
|
1260
|
+
if (picked === undefined)
|
|
1261
|
+
return;
|
|
1262
|
+
const route = readModelRouteKey(picked);
|
|
1263
|
+
if (route === undefined)
|
|
1264
|
+
return;
|
|
1265
|
+
applyRoute(route);
|
|
1266
|
+
await offerRouteEfforts(route);
|
|
1267
|
+
}
|
|
1268
|
+
finally {
|
|
1269
|
+
openingModels = false;
|
|
1270
|
+
tui.requestRender();
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1041
1273
|
/**
|
|
1042
1274
|
* Offer the efforts the route in force advertises.
|
|
1043
1275
|
*
|
|
@@ -1067,7 +1299,7 @@ export function apply(ctx, config) {
|
|
|
1067
1299
|
model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
|
|
1068
1300
|
return;
|
|
1069
1301
|
}
|
|
1070
|
-
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}
|
|
1302
|
+
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
|
|
1071
1303
|
if (picked !== undefined)
|
|
1072
1304
|
applyEffort(facts.provider, facts.model, picked);
|
|
1073
1305
|
}
|
|
@@ -1136,8 +1368,26 @@ export function apply(ctx, config) {
|
|
|
1136
1368
|
? []
|
|
1137
1369
|
: registry()?.list(current).map(command => `/${command.name}`) ?? [];
|
|
1138
1370
|
const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
|
|
1139
|
-
return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${
|
|
1371
|
+
return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
|
|
1372
|
+
};
|
|
1373
|
+
/**
|
|
1374
|
+
* Whether the agent this surface is driving is in plan mode.
|
|
1375
|
+
*
|
|
1376
|
+
* The dock's fold is the fallback, for a composition without the plan
|
|
1377
|
+
* package: with the controller present its answer is the agent's own state
|
|
1378
|
+
* rather than a replay of the events this surface happened to see.
|
|
1379
|
+
*/
|
|
1380
|
+
const planState = () => {
|
|
1381
|
+
const current = agent?.agent;
|
|
1382
|
+
if (current === undefined)
|
|
1383
|
+
return undefined;
|
|
1384
|
+
const presets = ctx.get('agentPresets');
|
|
1385
|
+
return readPlanState({
|
|
1386
|
+
direct: name => ctx.get(name),
|
|
1387
|
+
forAgent: (target, name) => presets?.serviceFor(target, name),
|
|
1388
|
+
}, current);
|
|
1140
1389
|
};
|
|
1390
|
+
const planActive = () => planSelectedActive(planState(), work.state().planMode);
|
|
1141
1391
|
const runCommand = (name, line) => {
|
|
1142
1392
|
const current = agent;
|
|
1143
1393
|
const commands = registry();
|
|
@@ -1183,8 +1433,13 @@ export function apply(ctx, config) {
|
|
|
1183
1433
|
...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
|
|
1184
1434
|
});
|
|
1185
1435
|
};
|
|
1186
|
-
|
|
1187
|
-
|
|
1436
|
+
/**
|
|
1437
|
+
* Carry out one classified line, wherever it was asked for.
|
|
1438
|
+
*
|
|
1439
|
+
* A chord asks for the same things the command line does, so both arrive
|
|
1440
|
+
* here: a chord cannot behave differently from the command it stands for.
|
|
1441
|
+
*/
|
|
1442
|
+
const runSubmission = (submission) => {
|
|
1188
1443
|
switch (submission.kind) {
|
|
1189
1444
|
case 'empty':
|
|
1190
1445
|
return;
|
|
@@ -1223,6 +1478,18 @@ export function apply(ctx, config) {
|
|
|
1223
1478
|
model.notice(line);
|
|
1224
1479
|
tui.requestRender();
|
|
1225
1480
|
return;
|
|
1481
|
+
case 'keys': {
|
|
1482
|
+
const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
|
|
1483
|
+
if (submission.argument !== '' && layer === undefined) {
|
|
1484
|
+
model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
|
|
1485
|
+
}
|
|
1486
|
+
else {
|
|
1487
|
+
for (const line of renderKeymap(keymap, layer))
|
|
1488
|
+
model.notice(line);
|
|
1489
|
+
}
|
|
1490
|
+
tui.requestRender();
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1226
1493
|
case 'copy':
|
|
1227
1494
|
runCopyCommand();
|
|
1228
1495
|
return;
|
|
@@ -1263,6 +1530,11 @@ export function apply(ctx, config) {
|
|
|
1263
1530
|
tui.requestRender();
|
|
1264
1531
|
});
|
|
1265
1532
|
return;
|
|
1533
|
+
// Plan mode is a command the harness owns, so the chord asks the host for
|
|
1534
|
+
// the state and names the command for the other one: one key, both ways.
|
|
1535
|
+
case 'plan':
|
|
1536
|
+
runCommand('plan', planToggleLine(planActive()));
|
|
1537
|
+
return;
|
|
1266
1538
|
case 'command':
|
|
1267
1539
|
runCommand(submission.name, submission.line);
|
|
1268
1540
|
return;
|
|
@@ -1281,6 +1553,7 @@ export function apply(ctx, config) {
|
|
|
1281
1553
|
}
|
|
1282
1554
|
}
|
|
1283
1555
|
};
|
|
1556
|
+
editor.onSubmit = text => runSubmission(classifySubmission(text));
|
|
1284
1557
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
1285
1558
|
// The surface state — activity, timer, title, bell, job board — belongs to
|
|
1286
1559
|
// the agent this terminal drives, even while a child is on screen.
|
|
@@ -1301,6 +1574,10 @@ export function apply(ctx, config) {
|
|
|
1301
1574
|
// something else, and nothing else refreshes a live board.
|
|
1302
1575
|
refreshJobs();
|
|
1303
1576
|
}
|
|
1577
|
+
// A claim or a discard changes what is queued, and that belongs to this
|
|
1578
|
+
// session even while the transcript shows a child's conversation.
|
|
1579
|
+
if (event.type === 'agent/inbox/spliced')
|
|
1580
|
+
tui.requestRender();
|
|
1304
1581
|
}
|
|
1305
1582
|
if (session.id !== viewedSession)
|
|
1306
1583
|
return;
|
|
@@ -1338,7 +1615,7 @@ export function apply(ctx, config) {
|
|
|
1338
1615
|
if (request.agent.id !== activeSession)
|
|
1339
1616
|
return next();
|
|
1340
1617
|
return new Promise(resolve => {
|
|
1341
|
-
const gate = new ApprovalGate(request.toolName, request.reason);
|
|
1618
|
+
const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
|
|
1342
1619
|
request.signal?.addEventListener('abort', () => {
|
|
1343
1620
|
gate.cancel();
|
|
1344
1621
|
if (pending?.gate === gate)
|
|
@@ -1356,20 +1633,33 @@ export function apply(ctx, config) {
|
|
|
1356
1633
|
if (questions.length === 0)
|
|
1357
1634
|
return next();
|
|
1358
1635
|
return new Promise(resolve => {
|
|
1359
|
-
const gate = new QuestionGate(questions);
|
|
1636
|
+
const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
|
|
1360
1637
|
// The seam takes mutable selection arrays and an optional custom field, so
|
|
1361
1638
|
// the read-only gate answer is copied into that exact shape here.
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
settle: answers => resolve({
|
|
1639
|
+
const settle = (answers) => {
|
|
1640
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
1641
|
+
resolve({
|
|
1366
1642
|
answers: answers.map(answer => ({
|
|
1367
1643
|
id: answer.id,
|
|
1368
1644
|
selected: [...answer.selected],
|
|
1369
1645
|
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
1370
1646
|
})),
|
|
1371
|
-
})
|
|
1372
|
-
}
|
|
1647
|
+
});
|
|
1648
|
+
};
|
|
1649
|
+
// A question whose caller is gone has no reader, so the gate must release
|
|
1650
|
+
// the keyboard instead of collecting an answer the aborted call discards.
|
|
1651
|
+
const onAbort = () => {
|
|
1652
|
+
gate.cancel();
|
|
1653
|
+
if (pending?.gate === gate)
|
|
1654
|
+
closeGate();
|
|
1655
|
+
settle([]);
|
|
1656
|
+
};
|
|
1657
|
+
openGate({ kind: 'question', gate, settle });
|
|
1658
|
+
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
1659
|
+
// A signal that aborted before the listener existed never emits, so the
|
|
1660
|
+
// state has to be read once after subscribing.
|
|
1661
|
+
if (request.signal?.aborted === true)
|
|
1662
|
+
onAbort();
|
|
1373
1663
|
});
|
|
1374
1664
|
}));
|
|
1375
1665
|
disposers.push(ctx.on('commands/change', () => installCompletion()));
|
|
@@ -1404,17 +1694,11 @@ export function apply(ctx, config) {
|
|
|
1404
1694
|
disposers.push(ctx.on('settings/updated', ns => {
|
|
1405
1695
|
if (String(ns) !== TUI_SETTINGS_NAMESPACE)
|
|
1406
1696
|
return;
|
|
1407
|
-
|
|
1408
|
-
applyTheme();
|
|
1409
|
-
applyDisplay();
|
|
1697
|
+
applySettings();
|
|
1410
1698
|
// Both caches hold rows under the old table, so they have to be told the
|
|
1411
1699
|
// table moved; a repaint alone would reuse what they already stored.
|
|
1412
1700
|
markdown.invalidate();
|
|
1413
1701
|
view.invalidate();
|
|
1414
|
-
if (pendingSettingsProblem !== undefined) {
|
|
1415
|
-
model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
|
|
1416
|
-
pendingSettingsProblem = undefined;
|
|
1417
|
-
}
|
|
1418
1702
|
tui.requestRender();
|
|
1419
1703
|
}));
|
|
1420
1704
|
const degraded = describeMissingOptional(probe);
|
|
@@ -1460,11 +1744,8 @@ export function apply(ctx, config) {
|
|
|
1460
1744
|
tui.start();
|
|
1461
1745
|
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1462
1746
|
// 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
|
-
}
|
|
1747
|
+
// screen; whatever the scope found before this point prints here instead.
|
|
1748
|
+
settingsNotice.open(message => model.notice(message));
|
|
1468
1749
|
await boot();
|
|
1469
1750
|
};
|
|
1470
1751
|
void start().catch((error) => {
|