@sagmans/dsh-tui 0.1.1 → 0.2.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 +84 -10
- 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 +24 -0
- package/lib/agent/model.d.ts.map +1 -1
- package/lib/agent/model.js +46 -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/cards.d.ts +127 -7
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +380 -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/index.d.ts +7 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +310 -32
- package/lib/index.js.map +1 -1
- package/lib/input/submission.d.ts +3 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +5 -2
- package/lib/input/submission.js.map +1 -1
- 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 +161 -0
- package/lib/theme-settings.d.ts.map +1 -0
- package/lib/theme-settings.js +166 -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 +401 -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 +34 -0
- package/lib/ui/editor.d.ts.map +1 -0
- package/lib/ui/editor.js +98 -0
- package/lib/ui/editor.js.map +1 -0
- package/lib/ui/markdown.d.ts +8 -0
- package/lib/ui/markdown.d.ts.map +1 -1
- package/lib/ui/markdown.js +12 -0
- package/lib/ui/markdown.js.map +1 -1
- package/lib/ui/picker.d.ts +37 -0
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +53 -0
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/status.d.ts +3 -1
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +76 -24
- package/lib/ui/status.js.map +1 -1
- package/lib/ui/view.d.ts +43 -0
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +254 -54
- package/lib/ui/view.js.map +1 -1
- package/package.json +3 -1
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";
|
|
@@ -17,25 +17,38 @@ 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
19
|
import { resolveConfig } from "./config.js";
|
|
20
|
+
import { FoldCursor } from "./fold-cursor.js";
|
|
20
21
|
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
22
|
+
import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
21
23
|
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
22
24
|
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
23
25
|
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
24
26
|
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
25
|
-
import {
|
|
27
|
+
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
28
|
+
import { detectColourMode } from "./theme-capability.js";
|
|
29
|
+
import { defaultSettings, readScope, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
30
|
+
import { renderThemeTable } from "./theme-command.js";
|
|
26
31
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
32
|
+
import { formatTokens } from "./tokens.js";
|
|
27
33
|
import { TranscriptModel } from "./transcript.js";
|
|
28
34
|
import { WorkFold, describeTodos } from "./work.js";
|
|
29
35
|
import { WorkDock } from "./ui/dock.js";
|
|
36
|
+
import { BoxedEditor } from "./ui/editor.js";
|
|
30
37
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
31
|
-
import { PresetPicker, SessionPicker } from "./ui/picker.js";
|
|
32
|
-
import { StatusBar
|
|
33
|
-
import { TranscriptView } from "./ui/view.js";
|
|
38
|
+
import { EffortPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
|
|
39
|
+
import { StatusBar } from "./ui/status.js";
|
|
40
|
+
import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
|
|
34
41
|
export const name = 'tui';
|
|
35
|
-
/**
|
|
36
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Services the surface cannot run without.
|
|
44
|
+
*
|
|
45
|
+
* `tools` is what lets a card read its tool's own render intent; a context that
|
|
46
|
+
* has not injected it throws on the property read, which silently degraded
|
|
47
|
+
* every card to a bare generic row.
|
|
48
|
+
*/
|
|
49
|
+
export const inject = ['agents', 'tools'];
|
|
37
50
|
/** Keys the surface answers itself, listed wherever the reader asks for help. */
|
|
38
|
-
const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+t reasoning · ctrl+b back to this session · ctrl+c interrupt or exit';
|
|
51
|
+
const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+y nested calls · shift+tab reasoning · ctrl+t reasoning effort · ctrl+b back to this session · ctrl+c interrupt or exit';
|
|
39
52
|
/** The one thing to say about a view a reader did not open. */
|
|
40
53
|
const LOCAL_KEYS_BACK = 'ctrl+b returns';
|
|
41
54
|
/** Stored sessions titled at once when the picker opens. */
|
|
@@ -78,32 +91,116 @@ export function apply(ctx, config) {
|
|
|
78
91
|
if (appExit === undefined) {
|
|
79
92
|
throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
|
|
80
93
|
}
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The surface's appearance, rebuilt whenever the reader's settings change.
|
|
96
|
+
*
|
|
97
|
+
* Renderers hold this object for the life of the session, so the current
|
|
98
|
+
* theme is swapped *behind* a stable delegate rather than reassigned: every
|
|
99
|
+
* row then reads one whole table, and a repaint can never observe a
|
|
100
|
+
* half-applied one. `--no-color` still outranks anything configured.
|
|
101
|
+
*/
|
|
102
|
+
const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
|
|
103
|
+
/**
|
|
104
|
+
* The reader's section, or nothing when the service is not mounted.
|
|
105
|
+
*
|
|
106
|
+
* The service is only readable inside an `inject` scope — asking for it
|
|
107
|
+
* outside one is a composition error, not a missing value — so this stays a
|
|
108
|
+
* late-bound read that the injection point and the change event both use.
|
|
109
|
+
*/
|
|
110
|
+
let readSection = () => defaultSettings();
|
|
111
|
+
/** A refused settings edit, kept until the surface can show it: stderr is behind the alt screen. */
|
|
112
|
+
let pendingSettingsProblem;
|
|
113
|
+
let current = createTheme(themeMode());
|
|
114
|
+
const applyTheme = () => {
|
|
115
|
+
current = createTheme(themeMode(), toOverrides(readSection()));
|
|
116
|
+
};
|
|
117
|
+
const theme = {
|
|
118
|
+
get revision() { return current.revision; },
|
|
119
|
+
get color() { return current.color; },
|
|
120
|
+
style: (token, text) => current.style(token, text),
|
|
121
|
+
cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
|
|
122
|
+
glyph: token => current.glyph(token),
|
|
123
|
+
visible: token => current.visible(token),
|
|
124
|
+
// Forwarded rather than read, because the editor and the markdown view keep
|
|
125
|
+
// the theme object they were built with: a settings change has to reach them
|
|
126
|
+
// through a stable delegate or they would keep the boot appearance.
|
|
127
|
+
editor: forwardEditorTheme(() => current.editor),
|
|
128
|
+
markdown: forwardMarkdownTheme(() => current.markdown),
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Rows the reader has opened. The model stays untouched; only the view reads
|
|
132
|
+
* this. A thought starts folded: it is the longest, least scannable row in the
|
|
133
|
+
* transcript, so leaving it open pushes the answer a reader came for off the
|
|
134
|
+
* screen, and a folded row still names itself and its key. A PTC card's calls
|
|
135
|
+
* start open for the opposite reason: each is one clipped line under a header
|
|
136
|
+
* that already names the program.
|
|
137
|
+
*/
|
|
138
|
+
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
139
|
+
/**
|
|
140
|
+
* Seed the nested-call display the reader configured.
|
|
141
|
+
*
|
|
142
|
+
* The key toggles it for one session, but a settings edit is a deliberate act,
|
|
143
|
+
* so it re-seeds and becomes the new starting point.
|
|
144
|
+
*/
|
|
145
|
+
const applyDisplay = () => {
|
|
146
|
+
viewState.expandSubCalls = readSection().subcalls === 'inline';
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Own the section, so the harness validates and persists it for the reader.
|
|
150
|
+
*
|
|
151
|
+
* Registration is how the document learns the section exists at all; without
|
|
152
|
+
* it a hand-written `dsh-tui:` block would be dropped on the next save. The
|
|
153
|
+
* first read happens here too, because this is the only scope the service
|
|
154
|
+
* may be touched in.
|
|
155
|
+
*/
|
|
156
|
+
ctx.inject(['settings'], settingsCtx => {
|
|
157
|
+
const scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
|
|
158
|
+
readSection = () => readScope(scope, message => { pendingSettingsProblem = message; });
|
|
159
|
+
applyTheme();
|
|
160
|
+
applyDisplay();
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* The agent scope the tool presenter resolves against.
|
|
164
|
+
*
|
|
165
|
+
* Tools are registered in the scoped world the session's preset mounts, so a
|
|
166
|
+
* card can only read its tool's own render intent while this names that
|
|
167
|
+
* agent. It follows whatever session the transcript is folding, because a
|
|
168
|
+
* child on screen reads through the child's scope, not the parent's.
|
|
169
|
+
*/
|
|
170
|
+
let presentScope;
|
|
171
|
+
const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
|
|
83
172
|
const work = new WorkFold();
|
|
84
173
|
const modelSwitch = new ModelSwitch();
|
|
85
174
|
const agentPresets = createPresetRoster(ctx);
|
|
175
|
+
/** The mode named on the command line, which is the only one that may conflict. */
|
|
176
|
+
const requestedPreset = resolved.preset;
|
|
86
177
|
/**
|
|
87
178
|
* The mode a session the reader starts from here on joins.
|
|
88
179
|
*
|
|
89
180
|
* A launch flag seeds it and a successful switch updates it, so `/new` and
|
|
90
181
|
* `/fork` land in the mode the reader last chose; nothing is written to
|
|
91
182
|
* settings, because the mode of a session is not a property of the machine.
|
|
183
|
+
* Unset means nobody chose, which is where the roster's default applies.
|
|
92
184
|
*/
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
185
|
+
let seat = requestedPreset;
|
|
186
|
+
/**
|
|
187
|
+
* The mode a session takes its seat in: the reader's latest choice, or the
|
|
188
|
+
* roster's default read at this moment.
|
|
189
|
+
*
|
|
190
|
+
* The default cannot be captured when this row applies: it comes from the
|
|
191
|
+
* settings document, which may be read after that, so a captured copy would
|
|
192
|
+
* pin every later session to the mode the bundle happened to ship.
|
|
193
|
+
*/
|
|
194
|
+
const seatMode = () => seat ?? agentPresets?.defaultId;
|
|
96
195
|
const catalog = createModelCatalog(ctx);
|
|
97
196
|
const jobDirectory = createJobDirectory(ctx);
|
|
98
197
|
let jobs = [];
|
|
99
198
|
const roster = new SubagentRoster();
|
|
100
199
|
const subagentControl = createSubagentControl(ctx);
|
|
101
200
|
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 };
|
|
104
201
|
const restore = createRestoreRegistry();
|
|
105
202
|
const terminal = new ProcessTerminal();
|
|
106
|
-
const tui = new
|
|
203
|
+
const tui = new WarningSafeTui(terminal);
|
|
107
204
|
/** The session this surface drives: commands, approvals, and the bell belong to it. */
|
|
108
205
|
let activeSession = resolved.sessionId;
|
|
109
206
|
/** The session the transcript is showing, which can be one of its children. */
|
|
@@ -117,8 +214,13 @@ export function apply(ctx, config) {
|
|
|
117
214
|
gate: () => pending?.gate.card(),
|
|
118
215
|
picker: () => pendingPicker?.picker.card(),
|
|
119
216
|
});
|
|
120
|
-
const editor = new
|
|
217
|
+
const editor = new BoxedEditor(tui, theme.editor);
|
|
121
218
|
const disposers = [];
|
|
219
|
+
// The presenter closure outlives the composition's own teardown, so it must
|
|
220
|
+
// not keep an agent alive after its world unwinds.
|
|
221
|
+
disposers.push(() => {
|
|
222
|
+
presentScope = undefined;
|
|
223
|
+
});
|
|
122
224
|
let agent;
|
|
123
225
|
let turnOpen = false;
|
|
124
226
|
let turnStartedAt;
|
|
@@ -275,11 +377,20 @@ export function apply(ctx, config) {
|
|
|
275
377
|
tui.requestRender();
|
|
276
378
|
return { consume: true };
|
|
277
379
|
}
|
|
278
|
-
if (matchesKey(data, '
|
|
380
|
+
if (matchesKey(data, 'shift+tab')) {
|
|
279
381
|
viewState.expandReasoning = !viewState.expandReasoning;
|
|
280
382
|
tui.requestRender();
|
|
281
383
|
return { consume: true };
|
|
282
384
|
}
|
|
385
|
+
if (matchesKey(data, 'ctrl+y')) {
|
|
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
|
+
}
|
|
283
394
|
if (matchesKey(data, 'ctrl+b')) {
|
|
284
395
|
if (viewedSession !== activeSession)
|
|
285
396
|
void showAgentSession();
|
|
@@ -429,11 +540,31 @@ export function apply(ctx, config) {
|
|
|
429
540
|
* materialized; a session this process is not running falls back to storage.
|
|
430
541
|
*/
|
|
431
542
|
const liveSession = (id) => ctx.get('sessions')?.get?.(id);
|
|
543
|
+
/**
|
|
544
|
+
* The fold's place in the viewed session's durable sequence.
|
|
545
|
+
*
|
|
546
|
+
* A resumed session is folded while its agent's loop is already live, so the
|
|
547
|
+
* same event can reach the surface twice: once on the stream and once from the
|
|
548
|
+
* log the fold is reading. The sequence number every durable event carries is
|
|
549
|
+
* what tells the two apart.
|
|
550
|
+
*/
|
|
551
|
+
const foldCursor = new FoldCursor();
|
|
552
|
+
/** Feed one durable event to the model, unless a fold has already folded it. */
|
|
553
|
+
const applyDurable = (event) => {
|
|
554
|
+
if (foldCursor.accept(event))
|
|
555
|
+
applyEvent(event);
|
|
556
|
+
};
|
|
432
557
|
const foldHistory = async (id) => {
|
|
558
|
+
// Resolved before the fold so every card reads its tool through the scope
|
|
559
|
+
// that actually registered it; a stored session nobody runs has none.
|
|
560
|
+
presentScope = ctx.agents?.get(id);
|
|
561
|
+
// Every fold starts a cleared transcript, so this session's own numbering is
|
|
562
|
+
// where the cursor begins rather than the previous session's.
|
|
563
|
+
foldCursor.reset();
|
|
433
564
|
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
434
565
|
if (inMemory !== undefined) {
|
|
435
566
|
for (const event of inMemory)
|
|
436
|
-
|
|
567
|
+
applyDurable(event);
|
|
437
568
|
return inMemory.length;
|
|
438
569
|
}
|
|
439
570
|
const history = createSessionHistory(ctx);
|
|
@@ -441,7 +572,7 @@ export function apply(ctx, config) {
|
|
|
441
572
|
return 0;
|
|
442
573
|
const events = await history.read(id);
|
|
443
574
|
for (const event of events)
|
|
444
|
-
|
|
575
|
+
applyDurable(event);
|
|
445
576
|
return events.length;
|
|
446
577
|
};
|
|
447
578
|
const replayHistory = async (id) => {
|
|
@@ -507,13 +638,13 @@ export function apply(ctx, config) {
|
|
|
507
638
|
if (agentPresets === undefined)
|
|
508
639
|
return undefined;
|
|
509
640
|
if (fork !== undefined)
|
|
510
|
-
return agentPresets.current(liveSession(fork.from)) ??
|
|
641
|
+
return agentPresets.current(liveSession(fork.from)) ?? seatMode();
|
|
511
642
|
if (!resume)
|
|
512
|
-
return
|
|
643
|
+
return seatMode();
|
|
513
644
|
const history = createSessionHistory(ctx);
|
|
514
645
|
const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
|
|
515
646
|
if (stored === undefined)
|
|
516
|
-
return
|
|
647
|
+
return seatMode();
|
|
517
648
|
const resolvedStored = await resolveStored(id, stored);
|
|
518
649
|
if (resolvedStored === undefined) {
|
|
519
650
|
// The composition this session recorded is gone. Naming one explicitly is
|
|
@@ -541,8 +672,6 @@ export function apply(ctx, config) {
|
|
|
541
672
|
// Settled before the transcript is touched, so a refusal leaves neither a
|
|
542
673
|
// half-replayed session nor a half-composed agent behind.
|
|
543
674
|
const preset = await presetFor(id, resume, fork);
|
|
544
|
-
if (resume)
|
|
545
|
-
await replayHistory(id);
|
|
546
675
|
const handle = await startAgent(ctx, {
|
|
547
676
|
sessionId: id,
|
|
548
677
|
resume,
|
|
@@ -561,6 +690,16 @@ export function apply(ctx, config) {
|
|
|
561
690
|
activeSession = id;
|
|
562
691
|
viewedSession = id;
|
|
563
692
|
agent = handle;
|
|
693
|
+
// A session with no history to fold still has to present its first live
|
|
694
|
+
// card through the right scope, so the scope is set before any event can.
|
|
695
|
+
presentScope = handle.agent;
|
|
696
|
+
// Replayed only once the agent exists, because the fold reads every card
|
|
697
|
+
// through the scope the preset mounted; a fold before that scope existed
|
|
698
|
+
// degraded each replayed card to a bare generic row. The agent's loop is
|
|
699
|
+
// live by now, so the fold and the stream race over the same events; the
|
|
700
|
+
// durable sequence number is what keeps one event from landing twice.
|
|
701
|
+
if (resume)
|
|
702
|
+
await replayHistory(id);
|
|
564
703
|
// A branch inherits the conversation the reader was already reading, so it
|
|
565
704
|
// opens on that history rather than on an empty screen.
|
|
566
705
|
if (fork !== undefined)
|
|
@@ -576,6 +715,9 @@ export function apply(ctx, config) {
|
|
|
576
715
|
const switchSession = async (id) => {
|
|
577
716
|
const previous = agent;
|
|
578
717
|
agent = undefined;
|
|
718
|
+
// Drop the outgoing scope before its agent is disposed, so no card folded
|
|
719
|
+
// during the transition can read a torn-down world.
|
|
720
|
+
presentScope = undefined;
|
|
579
721
|
turnOpen = false;
|
|
580
722
|
model.reset();
|
|
581
723
|
work.reset();
|
|
@@ -833,7 +975,7 @@ export function apply(ctx, config) {
|
|
|
833
975
|
? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
|
|
834
976
|
: `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
|
|
835
977
|
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`);
|
|
978
|
+
model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model>[/<effort>] switches, /model <provider> lists its models`);
|
|
837
979
|
tui.requestRender();
|
|
838
980
|
return;
|
|
839
981
|
}
|
|
@@ -848,17 +990,95 @@ export function apply(ctx, config) {
|
|
|
848
990
|
tui.requestRender();
|
|
849
991
|
});
|
|
850
992
|
return;
|
|
851
|
-
case 'switch':
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
993
|
+
case 'switch': {
|
|
994
|
+
const choice = command.choice;
|
|
995
|
+
if (choice.reasoningEffort === undefined) {
|
|
996
|
+
modelSwitch.choose(choice);
|
|
997
|
+
model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
|
|
998
|
+
tui.requestRender();
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
// The route decides which efforts exist, so an explicit one is checked
|
|
1002
|
+
// against the adapter before it is put in force: a typo must not become
|
|
1003
|
+
// a request the provider rejects.
|
|
1004
|
+
void (async () => {
|
|
1005
|
+
try {
|
|
1006
|
+
const info = await catalog.efforts(choice.provider, choice.model);
|
|
1007
|
+
const efforts = info?.efforts ?? [];
|
|
1008
|
+
if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
|
|
1009
|
+
model.notice(efforts.length === 0
|
|
1010
|
+
? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
|
|
1011
|
+
: `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
|
|
1012
|
+
tui.requestRender();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
modelSwitch.choose(choice);
|
|
1016
|
+
model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
|
|
1017
|
+
}
|
|
1018
|
+
catch (error) {
|
|
1019
|
+
model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1020
|
+
}
|
|
1021
|
+
tui.requestRender();
|
|
1022
|
+
})();
|
|
855
1023
|
return;
|
|
1024
|
+
}
|
|
856
1025
|
case 'invalid':
|
|
857
1026
|
model.notice(`/model: ${command.reason}`);
|
|
858
1027
|
tui.requestRender();
|
|
859
1028
|
return;
|
|
860
1029
|
}
|
|
861
1030
|
};
|
|
1031
|
+
/** Whether a route's effort list is being read, so a second key cannot race it. */
|
|
1032
|
+
let openingEfforts = false;
|
|
1033
|
+
/** Put the reader's effort choice in force for the next step. */
|
|
1034
|
+
const applyEffort = (provider, modelId, effortId) => {
|
|
1035
|
+
modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
|
|
1036
|
+
? { provider, model: modelId }
|
|
1037
|
+
: { provider, model: modelId, reasoningEffort: effortId });
|
|
1038
|
+
model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
|
|
1039
|
+
tui.requestRender();
|
|
1040
|
+
};
|
|
1041
|
+
/**
|
|
1042
|
+
* Offer the efforts the route in force advertises.
|
|
1043
|
+
*
|
|
1044
|
+
* The list is read before the picker opens because the rows are the route's
|
|
1045
|
+
* own metadata; a menu painted before that arrived could offer a level the
|
|
1046
|
+
* request would then be refused for.
|
|
1047
|
+
*/
|
|
1048
|
+
const openEffortPicker = async () => {
|
|
1049
|
+
if (catalog === undefined) {
|
|
1050
|
+
model.notice('this profile has no llm service, so reasoning efforts cannot be read');
|
|
1051
|
+
tui.requestRender();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const facts = statusFacts();
|
|
1055
|
+
if (facts.provider === undefined || facts.model === undefined) {
|
|
1056
|
+
model.notice('no model route is in use; /model <provider>/<model> picks one first');
|
|
1057
|
+
tui.requestRender();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (openingEfforts)
|
|
1061
|
+
return;
|
|
1062
|
+
openingEfforts = true;
|
|
1063
|
+
try {
|
|
1064
|
+
const info = await catalog.efforts(facts.provider, facts.model);
|
|
1065
|
+
const efforts = info?.efforts ?? [];
|
|
1066
|
+
if (efforts.length === 0) {
|
|
1067
|
+
model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`));
|
|
1071
|
+
if (picked !== undefined)
|
|
1072
|
+
applyEffort(facts.provider, facts.model, picked);
|
|
1073
|
+
}
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1076
|
+
}
|
|
1077
|
+
finally {
|
|
1078
|
+
openingEfforts = false;
|
|
1079
|
+
tui.requestRender();
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
862
1082
|
/**
|
|
863
1083
|
* Show or choose the mode this session runs.
|
|
864
1084
|
*
|
|
@@ -943,6 +1163,26 @@ export function apply(ctx, config) {
|
|
|
943
1163
|
tui.requestRender();
|
|
944
1164
|
});
|
|
945
1165
|
};
|
|
1166
|
+
/**
|
|
1167
|
+
* Put the deployment default in force before the first turn.
|
|
1168
|
+
*
|
|
1169
|
+
* The agent is created before the settings file has been read, so the route
|
|
1170
|
+
* its loop captured is the composition placeholder; the reader's default —
|
|
1171
|
+
* effort included — only exists by the time they can type. Adopting it here
|
|
1172
|
+
* is what makes the status line's route the one the request actually uses.
|
|
1173
|
+
*/
|
|
1174
|
+
const adoptDefaultRoute = () => {
|
|
1175
|
+
if (modelSwitch.current() !== undefined)
|
|
1176
|
+
return;
|
|
1177
|
+
const facts = statusFacts();
|
|
1178
|
+
if (facts.provider === undefined || facts.model === undefined)
|
|
1179
|
+
return;
|
|
1180
|
+
modelSwitch.adopt({
|
|
1181
|
+
provider: facts.provider,
|
|
1182
|
+
model: facts.model,
|
|
1183
|
+
...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
|
|
1184
|
+
});
|
|
1185
|
+
};
|
|
946
1186
|
editor.onSubmit = text => {
|
|
947
1187
|
const submission = classifySubmission(text);
|
|
948
1188
|
switch (submission.kind) {
|
|
@@ -978,6 +1218,11 @@ export function apply(ctx, config) {
|
|
|
978
1218
|
case 'todo':
|
|
979
1219
|
runTodoCommand();
|
|
980
1220
|
return;
|
|
1221
|
+
case 'theme':
|
|
1222
|
+
for (const line of renderThemeTable(toOverrides(readSection())))
|
|
1223
|
+
model.notice(line);
|
|
1224
|
+
tui.requestRender();
|
|
1225
|
+
return;
|
|
981
1226
|
case 'copy':
|
|
982
1227
|
runCopyCommand();
|
|
983
1228
|
return;
|
|
@@ -1030,8 +1275,10 @@ export function apply(ctx, config) {
|
|
|
1030
1275
|
// While a turn is running the human is steering it, not opening another.
|
|
1031
1276
|
if (turnOpen)
|
|
1032
1277
|
agent.steer(submission.text);
|
|
1033
|
-
else
|
|
1278
|
+
else {
|
|
1279
|
+
adoptDefaultRoute();
|
|
1034
1280
|
agent.submit(submission.text);
|
|
1281
|
+
}
|
|
1035
1282
|
}
|
|
1036
1283
|
};
|
|
1037
1284
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
@@ -1057,7 +1304,8 @@ export function apply(ctx, config) {
|
|
|
1057
1304
|
}
|
|
1058
1305
|
if (session.id !== viewedSession)
|
|
1059
1306
|
return;
|
|
1060
|
-
|
|
1307
|
+
presentScope = ctx.agents?.get(session.id);
|
|
1308
|
+
applyDurable(event);
|
|
1061
1309
|
tui.requestRender();
|
|
1062
1310
|
}));
|
|
1063
1311
|
/**
|
|
@@ -1145,6 +1393,30 @@ export function apply(ctx, config) {
|
|
|
1145
1393
|
model.applyStreamChunk(payload.frame.chunk);
|
|
1146
1394
|
tui.requestRender();
|
|
1147
1395
|
}));
|
|
1396
|
+
/**
|
|
1397
|
+
* Restyle a running session when the reader's section changes.
|
|
1398
|
+
*
|
|
1399
|
+
* The settings document is hot-reloaded by the host, so a reader watching a
|
|
1400
|
+
* shade land never has to leave the session to see it — which is what makes
|
|
1401
|
+
* tuning one bearable instead of a restart per attempt. The event is
|
|
1402
|
+
* namespace-filtered: another surface's preferences are not our repaint.
|
|
1403
|
+
*/
|
|
1404
|
+
disposers.push(ctx.on('settings/updated', ns => {
|
|
1405
|
+
if (String(ns) !== TUI_SETTINGS_NAMESPACE)
|
|
1406
|
+
return;
|
|
1407
|
+
pendingSettingsProblem = undefined;
|
|
1408
|
+
applyTheme();
|
|
1409
|
+
applyDisplay();
|
|
1410
|
+
// Both caches hold rows under the old table, so they have to be told the
|
|
1411
|
+
// table moved; a repaint alone would reuse what they already stored.
|
|
1412
|
+
markdown.invalidate();
|
|
1413
|
+
view.invalidate();
|
|
1414
|
+
if (pendingSettingsProblem !== undefined) {
|
|
1415
|
+
model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
|
|
1416
|
+
pendingSettingsProblem = undefined;
|
|
1417
|
+
}
|
|
1418
|
+
tui.requestRender();
|
|
1419
|
+
}));
|
|
1148
1420
|
const degraded = describeMissingOptional(probe);
|
|
1149
1421
|
if (degraded !== undefined)
|
|
1150
1422
|
model.notice(degraded);
|
|
@@ -1187,6 +1459,12 @@ export function apply(ctx, config) {
|
|
|
1187
1459
|
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
1188
1460
|
tui.start();
|
|
1189
1461
|
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1462
|
+
// A refused settings edit is only visible now that the surface owns the
|
|
1463
|
+
// screen; a bare stderr line would have been hidden behind it.
|
|
1464
|
+
if (pendingSettingsProblem !== undefined) {
|
|
1465
|
+
model.notice(`dsh-tui settings: ${pendingSettingsProblem}`);
|
|
1466
|
+
pendingSettingsProblem = undefined;
|
|
1467
|
+
}
|
|
1190
1468
|
await boot();
|
|
1191
1469
|
};
|
|
1192
1470
|
void start().catch((error) => {
|