@sagmans/dsh-tui 0.1.2 → 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 +196 -17
- package/cordis.patch.yml +4 -2
- package/lib/agent/host.d.ts +14 -0
- package/lib/agent/host.d.ts.map +1 -1
- package/lib/agent/host.js +20 -4
- package/lib/agent/host.js.map +1 -1
- package/lib/agent/model.d.ts +50 -0
- package/lib/agent/model.d.ts.map +1 -1
- package/lib/agent/model.js +87 -1
- package/lib/agent/model.js.map +1 -1
- package/lib/agent/present.d.ts +7 -1
- package/lib/agent/present.d.ts.map +1 -1
- package/lib/agent/present.js +33 -4
- package/lib/agent/present.js.map +1 -1
- package/lib/agent/presets.d.ts +7 -1
- package/lib/agent/presets.d.ts.map +1 -1
- package/lib/agent/presets.js +7 -3
- package/lib/agent/presets.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 +127 -7
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +381 -52
- package/lib/cards.js.map +1 -1
- package/lib/export.d.ts +5 -4
- package/lib/export.d.ts.map +1 -1
- package/lib/export.js +62 -13
- package/lib/export.js.map +1 -1
- package/lib/fold-cursor.d.ts +23 -0
- package/lib/fold-cursor.d.ts.map +1 -0
- package/lib/fold-cursor.js +31 -0
- package/lib/fold-cursor.js.map +1 -0
- 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 +7 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +651 -92
- 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 +8 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +9 -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/terminal/warning-screen.d.ts +8 -0
- package/lib/terminal/warning-screen.d.ts.map +1 -0
- package/lib/terminal/warning-screen.js +66 -0
- package/lib/terminal/warning-screen.js.map +1 -0
- package/lib/theme-capability.d.ts +36 -0
- package/lib/theme-capability.d.ts.map +1 -0
- package/lib/theme-capability.js +190 -0
- package/lib/theme-capability.js.map +1 -0
- package/lib/theme-command.d.ts +10 -0
- package/lib/theme-command.d.ts.map +1 -0
- package/lib/theme-command.js +61 -0
- package/lib/theme-command.js.map +1 -0
- package/lib/theme-settings.d.ts +196 -0
- package/lib/theme-settings.d.ts.map +1 -0
- package/lib/theme-settings.js +229 -0
- package/lib/theme-settings.js.map +1 -0
- package/lib/theme-tokens.d.ts +110 -0
- package/lib/theme-tokens.d.ts.map +1 -0
- package/lib/theme-tokens.js +427 -0
- package/lib/theme-tokens.js.map +1 -0
- package/lib/theme.d.ts +32 -35
- package/lib/theme.d.ts.map +1 -1
- package/lib/theme.js +112 -63
- package/lib/theme.js.map +1 -1
- package/lib/tokens.d.ts +15 -0
- package/lib/tokens.d.ts.map +1 -0
- package/lib/tokens.js +29 -0
- package/lib/tokens.js.map +1 -0
- package/lib/transcript.d.ts +26 -1
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +153 -25
- package/lib/transcript.js.map +1 -1
- package/lib/ui/dock.d.ts.map +1 -1
- package/lib/ui/dock.js +67 -28
- package/lib/ui/dock.js.map +1 -1
- package/lib/ui/editor.d.ts +51 -0
- package/lib/ui/editor.d.ts.map +1 -0
- package/lib/ui/editor.js +159 -0
- package/lib/ui/editor.js.map +1 -0
- 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 +19 -2
- package/lib/ui/markdown.d.ts.map +1 -1
- package/lib/ui/markdown.js +37 -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 +66 -7
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +112 -19
- 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 +7 -1
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +84 -24
- package/lib/ui/status.js.map +1 -1
- package/lib/ui/view.d.ts +65 -2
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +319 -59
- 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 +5 -2
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { writeFileSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
|
-
import {
|
|
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
7
|
import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
|
|
@@ -9,35 +9,58 @@ 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";
|
|
23
|
+
import { FoldCursor } from "./fold-cursor.js";
|
|
20
24
|
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
25
|
+
import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
21
26
|
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
22
27
|
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
23
28
|
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
24
29
|
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
25
|
-
import {
|
|
30
|
+
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
31
|
+
import { detectColourMode } from "./theme-capability.js";
|
|
32
|
+
import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
33
|
+
import { pendingPrompts } from "./queue.js";
|
|
34
|
+
import { renderThemeTable } from "./theme-command.js";
|
|
35
|
+
import { KEYMAP_LAYERS, keymapLayer, renderKeymap } from "./keys-command.js";
|
|
26
36
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
37
|
+
import { formatTokens } from "./tokens.js";
|
|
27
38
|
import { TranscriptModel } from "./transcript.js";
|
|
28
|
-
import { WorkFold, describeTodos } from "./work.js";
|
|
39
|
+
import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
|
|
29
40
|
import { WorkDock } from "./ui/dock.js";
|
|
41
|
+
import { GateInputBar } from "./ui/gate-input.js";
|
|
42
|
+
import { PromptBar } from "./ui/prompt.js";
|
|
30
43
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
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";
|
|
47
|
+
import { StatusBar } from "./ui/status.js";
|
|
48
|
+
import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
|
|
34
49
|
export const name = 'tui';
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Services the surface cannot run without.
|
|
52
|
+
*
|
|
53
|
+
* `tools` is what lets a card read its tool's own render intent; a context that
|
|
54
|
+
* has not injected it throws on the property read, which silently degraded
|
|
55
|
+
* every card to a bare generic row.
|
|
56
|
+
*/
|
|
57
|
+
export const inject = ['agents', 'tools'];
|
|
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`;
|
|
41
64
|
/** Stored sessions titled at once when the picker opens. */
|
|
42
65
|
const TITLE_CONCURRENCY = 4;
|
|
43
66
|
/** How often the running-state clock repaints while a turn is open. */
|
|
@@ -78,47 +101,200 @@ export function apply(ctx, config) {
|
|
|
78
101
|
if (appExit === undefined) {
|
|
79
102
|
throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
|
|
80
103
|
}
|
|
81
|
-
|
|
82
|
-
|
|
104
|
+
/**
|
|
105
|
+
* The surface's appearance, rebuilt whenever the reader's settings change.
|
|
106
|
+
*
|
|
107
|
+
* Renderers hold this object for the life of the session, so the current
|
|
108
|
+
* theme is swapped *behind* a stable delegate rather than reassigned: every
|
|
109
|
+
* row then reads one whole table, and a repaint can never observe a
|
|
110
|
+
* half-applied one. `--no-color` still outranks anything configured.
|
|
111
|
+
*/
|
|
112
|
+
const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
|
|
113
|
+
/**
|
|
114
|
+
* The reader's section, or nothing when the service is not mounted.
|
|
115
|
+
*
|
|
116
|
+
* The service is only readable inside an `inject` scope — asking for it
|
|
117
|
+
* outside one is a composition error, not a missing value — so this stays a
|
|
118
|
+
* late-bound read that the injection point and the change event both use.
|
|
119
|
+
*/
|
|
120
|
+
let readSection = () => defaultSettings();
|
|
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();
|
|
126
|
+
let current = createTheme(themeMode());
|
|
127
|
+
const applyTheme = (section) => {
|
|
128
|
+
current = createTheme(themeMode(), toOverrides(section));
|
|
129
|
+
};
|
|
130
|
+
const theme = {
|
|
131
|
+
get revision() { return current.revision; },
|
|
132
|
+
get color() { return current.color; },
|
|
133
|
+
style: (token, text) => current.style(token, text),
|
|
134
|
+
cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
|
|
135
|
+
glyph: token => current.glyph(token),
|
|
136
|
+
visible: token => current.visible(token),
|
|
137
|
+
// Forwarded rather than read, because the editor and the markdown view keep
|
|
138
|
+
// the theme object they were built with: a settings change has to reach them
|
|
139
|
+
// through a stable delegate or they would keep the boot appearance.
|
|
140
|
+
editor: forwardEditorTheme(() => current.editor),
|
|
141
|
+
markdown: forwardMarkdownTheme(() => current.markdown),
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Rows the reader has opened. The model stays untouched; only the view reads
|
|
145
|
+
* this. A thought starts folded: it is the longest, least scannable row in the
|
|
146
|
+
* transcript, so leaving it open pushes the answer a reader came for off the
|
|
147
|
+
* screen, and a folded row still names itself and its key. A PTC card's calls
|
|
148
|
+
* start open for the opposite reason: each is one clipped line under a header
|
|
149
|
+
* that already names the program.
|
|
150
|
+
*/
|
|
151
|
+
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
152
|
+
/**
|
|
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.
|
|
195
|
+
*
|
|
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.
|
|
198
|
+
*/
|
|
199
|
+
const applySettings = () => {
|
|
200
|
+
const section = readSection();
|
|
201
|
+
applyTheme(section);
|
|
202
|
+
applyDisplay(section);
|
|
203
|
+
};
|
|
204
|
+
/**
|
|
205
|
+
* Own the section, so the harness validates and persists it for the reader.
|
|
206
|
+
*
|
|
207
|
+
* Registration is how the document learns the section exists at all; without
|
|
208
|
+
* it a hand-written `dsh-tui:` block would be dropped on the next save. The
|
|
209
|
+
* first read happens here too, because this is the only scope the service
|
|
210
|
+
* may be touched in.
|
|
211
|
+
*/
|
|
212
|
+
ctx.inject(['settings'], settingsCtx => {
|
|
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();
|
|
227
|
+
});
|
|
228
|
+
/**
|
|
229
|
+
* The agent scope the tool presenter resolves against.
|
|
230
|
+
*
|
|
231
|
+
* Tools are registered in the scoped world the session's preset mounts, so a
|
|
232
|
+
* card can only read its tool's own render intent while this names that
|
|
233
|
+
* agent. It follows whatever session the transcript is folding, because a
|
|
234
|
+
* child on screen reads through the child's scope, not the parent's.
|
|
235
|
+
*/
|
|
236
|
+
let presentScope;
|
|
237
|
+
const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
|
|
83
238
|
const work = new WorkFold();
|
|
84
239
|
const modelSwitch = new ModelSwitch();
|
|
85
240
|
const agentPresets = createPresetRoster(ctx);
|
|
241
|
+
/** The mode named on the command line, which is the only one that may conflict. */
|
|
242
|
+
const requestedPreset = resolved.preset;
|
|
86
243
|
/**
|
|
87
244
|
* The mode a session the reader starts from here on joins.
|
|
88
245
|
*
|
|
89
246
|
* A launch flag seeds it and a successful switch updates it, so `/new` and
|
|
90
247
|
* `/fork` land in the mode the reader last chose; nothing is written to
|
|
91
248
|
* settings, because the mode of a session is not a property of the machine.
|
|
249
|
+
* Unset means nobody chose, which is where the roster's default applies.
|
|
92
250
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
251
|
+
let seat = requestedPreset;
|
|
252
|
+
/**
|
|
253
|
+
* The mode a session takes its seat in: the reader's latest choice, or the
|
|
254
|
+
* roster's default read at this moment.
|
|
255
|
+
*
|
|
256
|
+
* The default cannot be captured when this row applies: it comes from the
|
|
257
|
+
* settings document, which may be read after that, so a captured copy would
|
|
258
|
+
* pin every later session to the mode the bundle happened to ship.
|
|
259
|
+
*/
|
|
260
|
+
const seatMode = () => seat ?? agentPresets?.defaultId;
|
|
96
261
|
const catalog = createModelCatalog(ctx);
|
|
97
262
|
const jobDirectory = createJobDirectory(ctx);
|
|
98
263
|
let jobs = [];
|
|
99
264
|
const roster = new SubagentRoster();
|
|
100
265
|
const subagentControl = createSubagentControl(ctx);
|
|
101
|
-
const markdown = new MarkdownRenderer(theme.markdown);
|
|
102
|
-
/** Rows the reader has opened. The model stays untouched; only the view reads this. */
|
|
103
|
-
const viewState = { expandCards: false, expandReasoning: false };
|
|
266
|
+
const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
|
|
104
267
|
const restore = createRestoreRegistry();
|
|
105
268
|
const terminal = new ProcessTerminal();
|
|
106
|
-
const tui = new
|
|
269
|
+
const tui = new WarningSafeTui(terminal);
|
|
107
270
|
/** The session this surface drives: commands, approvals, and the bell belong to it. */
|
|
108
271
|
let activeSession = resolved.sessionId;
|
|
109
272
|
/** The session the transcript is showing, which can be one of its children. */
|
|
110
273
|
let viewedSession = resolved.sessionId;
|
|
111
274
|
let pending;
|
|
112
275
|
let pendingPicker;
|
|
113
|
-
/** 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. */
|
|
114
277
|
let vetting = false;
|
|
115
278
|
const view = new TranscriptView(model, theme, markdown, {
|
|
116
279
|
state: () => viewState,
|
|
117
280
|
gate: () => pending?.gate.card(),
|
|
118
281
|
picker: () => pendingPicker?.picker.card(),
|
|
282
|
+
keys: () => keymap,
|
|
119
283
|
});
|
|
120
|
-
|
|
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);
|
|
121
292
|
const disposers = [];
|
|
293
|
+
// The presenter closure outlives the composition's own teardown, so it must
|
|
294
|
+
// not keep an agent alive after its world unwinds.
|
|
295
|
+
disposers.push(() => {
|
|
296
|
+
presentScope = undefined;
|
|
297
|
+
});
|
|
122
298
|
let agent;
|
|
123
299
|
let turnOpen = false;
|
|
124
300
|
let turnStartedAt;
|
|
@@ -135,15 +311,25 @@ export function apply(ctx, config) {
|
|
|
135
311
|
activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
|
|
136
312
|
override: () => modelSwitch.current(),
|
|
137
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)),
|
|
138
319
|
});
|
|
139
320
|
const statusBar = new StatusBar(statusFacts, theme);
|
|
140
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);
|
|
141
325
|
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
142
326
|
const statusTicker = setInterval(() => {
|
|
143
327
|
if (turnOpen)
|
|
144
328
|
tui.requestRender();
|
|
145
329
|
}, STATUS_TICK_MS);
|
|
146
330
|
disposers.push(() => clearInterval(statusTicker));
|
|
331
|
+
// A window that outlived the surface would repaint a screen that is gone.
|
|
332
|
+
disposers.push(() => keyChord.disarm());
|
|
147
333
|
restore.add(() => tui.stop());
|
|
148
334
|
ctx.effect(() => () => {
|
|
149
335
|
restore.restore();
|
|
@@ -160,7 +346,11 @@ export function apply(ctx, config) {
|
|
|
160
346
|
// Work state earns rows only when there is some: a dock that always
|
|
161
347
|
// occupied a row would cost every conversation one line of transcript.
|
|
162
348
|
{ component: dock, basis: 'auto', shrink: 0, minSize: 0 },
|
|
163
|
-
|
|
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 },
|
|
164
354
|
{ component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
|
|
165
355
|
]));
|
|
166
356
|
tui.setFocus(editor);
|
|
@@ -192,14 +382,65 @@ export function apply(ctx, config) {
|
|
|
192
382
|
// A gate owns the keyboard: the editor must not collect the decision keys.
|
|
193
383
|
editor.disableSubmit = true;
|
|
194
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';
|
|
195
389
|
tui.requestRender();
|
|
196
390
|
};
|
|
197
391
|
const closeGate = () => {
|
|
198
392
|
pending = undefined;
|
|
199
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();
|
|
200
397
|
tui.setFocus(editor);
|
|
201
398
|
tui.requestRender();
|
|
202
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
|
+
};
|
|
203
444
|
disposers.push(tui.addInputListener(data => {
|
|
204
445
|
// A key arrives as a press and a release once the surface asks the terminal
|
|
205
446
|
// to report key events, and this library drops the release only for the
|
|
@@ -231,13 +472,19 @@ export function apply(ctx, config) {
|
|
|
231
472
|
return { consume: true };
|
|
232
473
|
}
|
|
233
474
|
if (pendingPicker !== undefined) {
|
|
234
|
-
if (vetting)
|
|
235
|
-
return { consume: true };
|
|
236
475
|
const action = pendingPicker.picker.handleKey(data);
|
|
237
476
|
if (action === undefined) {
|
|
238
477
|
tui.requestRender();
|
|
239
478
|
return { consume: true };
|
|
240
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
|
+
}
|
|
241
488
|
if (action.kind === 'cancel') {
|
|
242
489
|
settlePicker(undefined);
|
|
243
490
|
return { consume: true };
|
|
@@ -268,35 +515,23 @@ export function apply(ctx, config) {
|
|
|
268
515
|
})();
|
|
269
516
|
return { consume: true };
|
|
270
517
|
}
|
|
271
|
-
//
|
|
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
|
|
272
521
|
// collapsed view is a default, not the only state.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
if (matchesKey(data, 'ctrl+t')) {
|
|
279
|
-
viewState.expandReasoning = !viewState.expandReasoning;
|
|
522
|
+
const chorded = keyChord.handle(data);
|
|
523
|
+
if (chorded !== undefined) {
|
|
524
|
+
if (chorded.kind === 'action')
|
|
525
|
+
runSubmission(chorded.binding.submission);
|
|
280
526
|
tui.requestRender();
|
|
281
527
|
return { consume: true };
|
|
282
528
|
}
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
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;
|
|
287
533
|
}
|
|
288
|
-
|
|
289
|
-
// decides: stop the work in flight, or leave when there is none.
|
|
290
|
-
if (!matchesKey(data, 'ctrl+c'))
|
|
291
|
-
return undefined;
|
|
292
|
-
if (turnOpen) {
|
|
293
|
-
agent?.interrupt();
|
|
294
|
-
model.notice('interrupt requested');
|
|
295
|
-
tui.requestRender();
|
|
296
|
-
return { consume: true };
|
|
297
|
-
}
|
|
298
|
-
requestExit(0);
|
|
299
|
-
return { consume: true };
|
|
534
|
+
return undefined;
|
|
300
535
|
}));
|
|
301
536
|
const registry = () => ctx.get('commands');
|
|
302
537
|
/**
|
|
@@ -322,7 +557,7 @@ export function apply(ctx, config) {
|
|
|
322
557
|
const askForSession = async (history, sessions) => {
|
|
323
558
|
const titles = new Map();
|
|
324
559
|
void loadTitles(history, sessions, titles);
|
|
325
|
-
const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
|
|
560
|
+
const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
|
|
326
561
|
return picked === undefined ? undefined : SessionId(picked);
|
|
327
562
|
};
|
|
328
563
|
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
@@ -366,7 +601,7 @@ export function apply(ctx, config) {
|
|
|
366
601
|
if (agentPresets === undefined)
|
|
367
602
|
return undefined;
|
|
368
603
|
presetRows = await agentPresets.list();
|
|
369
|
-
return await openPicker(new PresetPicker(() => presetRows, () => currentId));
|
|
604
|
+
return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
|
|
370
605
|
};
|
|
371
606
|
/** Title the listed sessions without making the reader wait for the slowest log. */
|
|
372
607
|
const loadTitles = async (history, sessions, titles) => {
|
|
@@ -429,11 +664,31 @@ export function apply(ctx, config) {
|
|
|
429
664
|
* materialized; a session this process is not running falls back to storage.
|
|
430
665
|
*/
|
|
431
666
|
const liveSession = (id) => ctx.get('sessions')?.get?.(id);
|
|
667
|
+
/**
|
|
668
|
+
* The fold's place in the viewed session's durable sequence.
|
|
669
|
+
*
|
|
670
|
+
* A resumed session is folded while its agent's loop is already live, so the
|
|
671
|
+
* same event can reach the surface twice: once on the stream and once from the
|
|
672
|
+
* log the fold is reading. The sequence number every durable event carries is
|
|
673
|
+
* what tells the two apart.
|
|
674
|
+
*/
|
|
675
|
+
const foldCursor = new FoldCursor();
|
|
676
|
+
/** Feed one durable event to the model, unless a fold has already folded it. */
|
|
677
|
+
const applyDurable = (event) => {
|
|
678
|
+
if (foldCursor.accept(event))
|
|
679
|
+
applyEvent(event);
|
|
680
|
+
};
|
|
432
681
|
const foldHistory = async (id) => {
|
|
682
|
+
// Resolved before the fold so every card reads its tool through the scope
|
|
683
|
+
// that actually registered it; a stored session nobody runs has none.
|
|
684
|
+
presentScope = ctx.agents?.get(id);
|
|
685
|
+
// Every fold starts a cleared transcript, so this session's own numbering is
|
|
686
|
+
// where the cursor begins rather than the previous session's.
|
|
687
|
+
foldCursor.reset();
|
|
433
688
|
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
434
689
|
if (inMemory !== undefined) {
|
|
435
690
|
for (const event of inMemory)
|
|
436
|
-
|
|
691
|
+
applyDurable(event);
|
|
437
692
|
return inMemory.length;
|
|
438
693
|
}
|
|
439
694
|
const history = createSessionHistory(ctx);
|
|
@@ -441,7 +696,7 @@ export function apply(ctx, config) {
|
|
|
441
696
|
return 0;
|
|
442
697
|
const events = await history.read(id);
|
|
443
698
|
for (const event of events)
|
|
444
|
-
|
|
699
|
+
applyDurable(event);
|
|
445
700
|
return events.length;
|
|
446
701
|
};
|
|
447
702
|
const replayHistory = async (id) => {
|
|
@@ -473,9 +728,7 @@ export function apply(ctx, config) {
|
|
|
473
728
|
model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
|
|
474
729
|
}
|
|
475
730
|
const returned = id === activeSession && previous !== activeSession;
|
|
476
|
-
model.marker(returned
|
|
477
|
-
? 'back to the session this terminal drives'
|
|
478
|
-
: `viewing ${id}${id === activeSession ? '' : ` — ${LOCAL_KEYS_BACK}`}`);
|
|
731
|
+
model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
|
|
479
732
|
tui.requestRender();
|
|
480
733
|
};
|
|
481
734
|
const showAgentSession = async () => {
|
|
@@ -507,13 +760,13 @@ export function apply(ctx, config) {
|
|
|
507
760
|
if (agentPresets === undefined)
|
|
508
761
|
return undefined;
|
|
509
762
|
if (fork !== undefined)
|
|
510
|
-
return agentPresets.current(liveSession(fork.from)) ??
|
|
763
|
+
return agentPresets.current(liveSession(fork.from)) ?? seatMode();
|
|
511
764
|
if (!resume)
|
|
512
|
-
return
|
|
765
|
+
return seatMode();
|
|
513
766
|
const history = createSessionHistory(ctx);
|
|
514
767
|
const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
|
|
515
768
|
if (stored === undefined)
|
|
516
|
-
return
|
|
769
|
+
return seatMode();
|
|
517
770
|
const resolvedStored = await resolveStored(id, stored);
|
|
518
771
|
if (resolvedStored === undefined) {
|
|
519
772
|
// The composition this session recorded is gone. Naming one explicitly is
|
|
@@ -541,8 +794,6 @@ export function apply(ctx, config) {
|
|
|
541
794
|
// Settled before the transcript is touched, so a refusal leaves neither a
|
|
542
795
|
// half-replayed session nor a half-composed agent behind.
|
|
543
796
|
const preset = await presetFor(id, resume, fork);
|
|
544
|
-
if (resume)
|
|
545
|
-
await replayHistory(id);
|
|
546
797
|
const handle = await startAgent(ctx, {
|
|
547
798
|
sessionId: id,
|
|
548
799
|
resume,
|
|
@@ -561,6 +812,16 @@ export function apply(ctx, config) {
|
|
|
561
812
|
activeSession = id;
|
|
562
813
|
viewedSession = id;
|
|
563
814
|
agent = handle;
|
|
815
|
+
// A session with no history to fold still has to present its first live
|
|
816
|
+
// card through the right scope, so the scope is set before any event can.
|
|
817
|
+
presentScope = handle.agent;
|
|
818
|
+
// Replayed only once the agent exists, because the fold reads every card
|
|
819
|
+
// through the scope the preset mounted; a fold before that scope existed
|
|
820
|
+
// degraded each replayed card to a bare generic row. The agent's loop is
|
|
821
|
+
// live by now, so the fold and the stream race over the same events; the
|
|
822
|
+
// durable sequence number is what keeps one event from landing twice.
|
|
823
|
+
if (resume)
|
|
824
|
+
await replayHistory(id);
|
|
564
825
|
// A branch inherits the conversation the reader was already reading, so it
|
|
565
826
|
// opens on that history rather than on an empty screen.
|
|
566
827
|
if (fork !== undefined)
|
|
@@ -576,6 +837,9 @@ export function apply(ctx, config) {
|
|
|
576
837
|
const switchSession = async (id) => {
|
|
577
838
|
const previous = agent;
|
|
578
839
|
agent = undefined;
|
|
840
|
+
// Drop the outgoing scope before its agent is disposed, so no card folded
|
|
841
|
+
// during the transition can read a torn-down world.
|
|
842
|
+
presentScope = undefined;
|
|
579
843
|
turnOpen = false;
|
|
580
844
|
model.reset();
|
|
581
845
|
work.reset();
|
|
@@ -824,19 +1088,11 @@ export function apply(ctx, config) {
|
|
|
824
1088
|
}
|
|
825
1089
|
const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
|
|
826
1090
|
switch (command.kind) {
|
|
827
|
-
case 'current':
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
// Without a choice of its own the surface reports what the next step
|
|
832
|
-
// would actually use, not that it has no opinion.
|
|
833
|
-
? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
|
|
834
|
-
: `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
|
|
835
|
-
const providers = catalog.providers().map(provider => provider.id);
|
|
836
|
-
model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model> switches, /model <provider> lists its models`);
|
|
837
|
-
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();
|
|
838
1095
|
return;
|
|
839
|
-
}
|
|
840
1096
|
case 'list-models':
|
|
841
1097
|
void catalog.models(command.provider).then(entries => {
|
|
842
1098
|
model.notice(entries.length === 0
|
|
@@ -848,17 +1104,213 @@ export function apply(ctx, config) {
|
|
|
848
1104
|
tui.requestRender();
|
|
849
1105
|
});
|
|
850
1106
|
return;
|
|
851
|
-
case 'switch':
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1107
|
+
case 'switch': {
|
|
1108
|
+
const choice = command.choice;
|
|
1109
|
+
if (choice.reasoningEffort === undefined) {
|
|
1110
|
+
modelSwitch.choose(choice);
|
|
1111
|
+
model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
|
|
1112
|
+
tui.requestRender();
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
// The route decides which efforts exist, so an explicit one is checked
|
|
1116
|
+
// against the adapter before it is put in force: a typo must not become
|
|
1117
|
+
// a request the provider rejects.
|
|
1118
|
+
void (async () => {
|
|
1119
|
+
try {
|
|
1120
|
+
const info = await catalog.efforts(choice.provider, choice.model);
|
|
1121
|
+
const efforts = info?.efforts ?? [];
|
|
1122
|
+
if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
|
|
1123
|
+
model.notice(efforts.length === 0
|
|
1124
|
+
? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
|
|
1125
|
+
: `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
|
|
1126
|
+
tui.requestRender();
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
modelSwitch.choose(choice);
|
|
1130
|
+
model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1134
|
+
}
|
|
1135
|
+
tui.requestRender();
|
|
1136
|
+
})();
|
|
855
1137
|
return;
|
|
1138
|
+
}
|
|
856
1139
|
case 'invalid':
|
|
857
1140
|
model.notice(`/model: ${command.reason}`);
|
|
858
1141
|
tui.requestRender();
|
|
859
1142
|
return;
|
|
860
1143
|
}
|
|
861
1144
|
};
|
|
1145
|
+
/** Whether a route's effort list is being read, so a second key cannot race it. */
|
|
1146
|
+
let openingEfforts = false;
|
|
1147
|
+
/** Put the reader's effort choice in force for the next step. */
|
|
1148
|
+
const applyEffort = (provider, modelId, effortId) => {
|
|
1149
|
+
modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
|
|
1150
|
+
? { provider, model: modelId }
|
|
1151
|
+
: { provider, model: modelId, reasoningEffort: effortId });
|
|
1152
|
+
model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
|
|
1153
|
+
tui.requestRender();
|
|
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
|
+
};
|
|
1273
|
+
/**
|
|
1274
|
+
* Offer the efforts the route in force advertises.
|
|
1275
|
+
*
|
|
1276
|
+
* The list is read before the picker opens because the rows are the route's
|
|
1277
|
+
* own metadata; a menu painted before that arrived could offer a level the
|
|
1278
|
+
* request would then be refused for.
|
|
1279
|
+
*/
|
|
1280
|
+
const openEffortPicker = async () => {
|
|
1281
|
+
if (catalog === undefined) {
|
|
1282
|
+
model.notice('this profile has no llm service, so reasoning efforts cannot be read');
|
|
1283
|
+
tui.requestRender();
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const facts = statusFacts();
|
|
1287
|
+
if (facts.provider === undefined || facts.model === undefined) {
|
|
1288
|
+
model.notice('no model route is in use; /model <provider>/<model> picks one first');
|
|
1289
|
+
tui.requestRender();
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (openingEfforts)
|
|
1293
|
+
return;
|
|
1294
|
+
openingEfforts = true;
|
|
1295
|
+
try {
|
|
1296
|
+
const info = await catalog.efforts(facts.provider, facts.model);
|
|
1297
|
+
const efforts = info?.efforts ?? [];
|
|
1298
|
+
if (efforts.length === 0) {
|
|
1299
|
+
model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
|
|
1303
|
+
if (picked !== undefined)
|
|
1304
|
+
applyEffort(facts.provider, facts.model, picked);
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1308
|
+
}
|
|
1309
|
+
finally {
|
|
1310
|
+
openingEfforts = false;
|
|
1311
|
+
tui.requestRender();
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
862
1314
|
/**
|
|
863
1315
|
* Show or choose the mode this session runs.
|
|
864
1316
|
*
|
|
@@ -916,8 +1368,26 @@ export function apply(ctx, config) {
|
|
|
916
1368
|
? []
|
|
917
1369
|
: registry()?.list(current).map(command => `/${command.name}`) ?? [];
|
|
918
1370
|
const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
|
|
919
|
-
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);
|
|
920
1389
|
};
|
|
1390
|
+
const planActive = () => planSelectedActive(planState(), work.state().planMode);
|
|
921
1391
|
const runCommand = (name, line) => {
|
|
922
1392
|
const current = agent;
|
|
923
1393
|
const commands = registry();
|
|
@@ -943,8 +1413,33 @@ export function apply(ctx, config) {
|
|
|
943
1413
|
tui.requestRender();
|
|
944
1414
|
});
|
|
945
1415
|
};
|
|
946
|
-
|
|
947
|
-
|
|
1416
|
+
/**
|
|
1417
|
+
* Put the deployment default in force before the first turn.
|
|
1418
|
+
*
|
|
1419
|
+
* The agent is created before the settings file has been read, so the route
|
|
1420
|
+
* its loop captured is the composition placeholder; the reader's default —
|
|
1421
|
+
* effort included — only exists by the time they can type. Adopting it here
|
|
1422
|
+
* is what makes the status line's route the one the request actually uses.
|
|
1423
|
+
*/
|
|
1424
|
+
const adoptDefaultRoute = () => {
|
|
1425
|
+
if (modelSwitch.current() !== undefined)
|
|
1426
|
+
return;
|
|
1427
|
+
const facts = statusFacts();
|
|
1428
|
+
if (facts.provider === undefined || facts.model === undefined)
|
|
1429
|
+
return;
|
|
1430
|
+
modelSwitch.adopt({
|
|
1431
|
+
provider: facts.provider,
|
|
1432
|
+
model: facts.model,
|
|
1433
|
+
...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
|
|
1434
|
+
});
|
|
1435
|
+
};
|
|
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) => {
|
|
948
1443
|
switch (submission.kind) {
|
|
949
1444
|
case 'empty':
|
|
950
1445
|
return;
|
|
@@ -978,6 +1473,23 @@ export function apply(ctx, config) {
|
|
|
978
1473
|
case 'todo':
|
|
979
1474
|
runTodoCommand();
|
|
980
1475
|
return;
|
|
1476
|
+
case 'theme':
|
|
1477
|
+
for (const line of renderThemeTable(toOverrides(readSection())))
|
|
1478
|
+
model.notice(line);
|
|
1479
|
+
tui.requestRender();
|
|
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
|
+
}
|
|
981
1493
|
case 'copy':
|
|
982
1494
|
runCopyCommand();
|
|
983
1495
|
return;
|
|
@@ -1018,6 +1530,11 @@ export function apply(ctx, config) {
|
|
|
1018
1530
|
tui.requestRender();
|
|
1019
1531
|
});
|
|
1020
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;
|
|
1021
1538
|
case 'command':
|
|
1022
1539
|
runCommand(submission.name, submission.line);
|
|
1023
1540
|
return;
|
|
@@ -1030,10 +1547,13 @@ export function apply(ctx, config) {
|
|
|
1030
1547
|
// While a turn is running the human is steering it, not opening another.
|
|
1031
1548
|
if (turnOpen)
|
|
1032
1549
|
agent.steer(submission.text);
|
|
1033
|
-
else
|
|
1550
|
+
else {
|
|
1551
|
+
adoptDefaultRoute();
|
|
1034
1552
|
agent.submit(submission.text);
|
|
1553
|
+
}
|
|
1035
1554
|
}
|
|
1036
1555
|
};
|
|
1556
|
+
editor.onSubmit = text => runSubmission(classifySubmission(text));
|
|
1037
1557
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
1038
1558
|
// The surface state — activity, timer, title, bell, job board — belongs to
|
|
1039
1559
|
// the agent this terminal drives, even while a child is on screen.
|
|
@@ -1054,10 +1574,15 @@ export function apply(ctx, config) {
|
|
|
1054
1574
|
// something else, and nothing else refreshes a live board.
|
|
1055
1575
|
refreshJobs();
|
|
1056
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();
|
|
1057
1581
|
}
|
|
1058
1582
|
if (session.id !== viewedSession)
|
|
1059
1583
|
return;
|
|
1060
|
-
|
|
1584
|
+
presentScope = ctx.agents?.get(session.id);
|
|
1585
|
+
applyDurable(event);
|
|
1061
1586
|
tui.requestRender();
|
|
1062
1587
|
}));
|
|
1063
1588
|
/**
|
|
@@ -1090,7 +1615,7 @@ export function apply(ctx, config) {
|
|
|
1090
1615
|
if (request.agent.id !== activeSession)
|
|
1091
1616
|
return next();
|
|
1092
1617
|
return new Promise(resolve => {
|
|
1093
|
-
const gate = new ApprovalGate(request.toolName, request.reason);
|
|
1618
|
+
const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
|
|
1094
1619
|
request.signal?.addEventListener('abort', () => {
|
|
1095
1620
|
gate.cancel();
|
|
1096
1621
|
if (pending?.gate === gate)
|
|
@@ -1108,20 +1633,33 @@ export function apply(ctx, config) {
|
|
|
1108
1633
|
if (questions.length === 0)
|
|
1109
1634
|
return next();
|
|
1110
1635
|
return new Promise(resolve => {
|
|
1111
|
-
const gate = new QuestionGate(questions);
|
|
1636
|
+
const gate = promptBar.borrow(() => new QuestionGate(questions, editor, () => keymap));
|
|
1112
1637
|
// The seam takes mutable selection arrays and an optional custom field, so
|
|
1113
1638
|
// the read-only gate answer is copied into that exact shape here.
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
settle: answers => resolve({
|
|
1639
|
+
const settle = (answers) => {
|
|
1640
|
+
request.signal?.removeEventListener('abort', onAbort);
|
|
1641
|
+
resolve({
|
|
1118
1642
|
answers: answers.map(answer => ({
|
|
1119
1643
|
id: answer.id,
|
|
1120
1644
|
selected: [...answer.selected],
|
|
1121
1645
|
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
1122
1646
|
})),
|
|
1123
|
-
})
|
|
1124
|
-
}
|
|
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();
|
|
1125
1663
|
});
|
|
1126
1664
|
}));
|
|
1127
1665
|
disposers.push(ctx.on('commands/change', () => installCompletion()));
|
|
@@ -1145,6 +1683,24 @@ export function apply(ctx, config) {
|
|
|
1145
1683
|
model.applyStreamChunk(payload.frame.chunk);
|
|
1146
1684
|
tui.requestRender();
|
|
1147
1685
|
}));
|
|
1686
|
+
/**
|
|
1687
|
+
* Restyle a running session when the reader's section changes.
|
|
1688
|
+
*
|
|
1689
|
+
* The settings document is hot-reloaded by the host, so a reader watching a
|
|
1690
|
+
* shade land never has to leave the session to see it — which is what makes
|
|
1691
|
+
* tuning one bearable instead of a restart per attempt. The event is
|
|
1692
|
+
* namespace-filtered: another surface's preferences are not our repaint.
|
|
1693
|
+
*/
|
|
1694
|
+
disposers.push(ctx.on('settings/updated', ns => {
|
|
1695
|
+
if (String(ns) !== TUI_SETTINGS_NAMESPACE)
|
|
1696
|
+
return;
|
|
1697
|
+
applySettings();
|
|
1698
|
+
// Both caches hold rows under the old table, so they have to be told the
|
|
1699
|
+
// table moved; a repaint alone would reuse what they already stored.
|
|
1700
|
+
markdown.invalidate();
|
|
1701
|
+
view.invalidate();
|
|
1702
|
+
tui.requestRender();
|
|
1703
|
+
}));
|
|
1148
1704
|
const degraded = describeMissingOptional(probe);
|
|
1149
1705
|
if (degraded !== undefined)
|
|
1150
1706
|
model.notice(degraded);
|
|
@@ -1187,6 +1743,9 @@ export function apply(ctx, config) {
|
|
|
1187
1743
|
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
1188
1744
|
tui.start();
|
|
1189
1745
|
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1746
|
+
// A refused settings edit is only visible now that the surface owns the
|
|
1747
|
+
// screen; whatever the scope found before this point prints here instead.
|
|
1748
|
+
settingsNotice.open(message => model.notice(message));
|
|
1190
1749
|
await boot();
|
|
1191
1750
|
};
|
|
1192
1751
|
void start().catch((error) => {
|