@sagmans/dsh-tui 0.4.0 → 0.5.1
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/LICENSE +1 -1
- package/README.md +270 -68
- package/lib/agent/present.js +1 -1
- package/lib/agent/present.js.map +1 -1
- package/lib/agent/prompt-history.d.ts.map +1 -1
- package/lib/agent/prompt-history.js +5 -3
- package/lib/agent/prompt-history.js.map +1 -1
- package/lib/agent/status.d.ts +1 -1
- package/lib/cards.d.ts +83 -13
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +96 -28
- package/lib/cards.js.map +1 -1
- package/lib/export.d.ts +3 -3
- package/lib/export.d.ts.map +1 -1
- package/lib/export.js +23 -13
- package/lib/export.js.map +1 -1
- package/lib/fold-cursor.d.ts +12 -5
- package/lib/fold-cursor.d.ts.map +1 -1
- package/lib/fold-cursor.js +16 -5
- package/lib/fold-cursor.js.map +1 -1
- package/lib/gates.d.ts.map +1 -1
- package/lib/gates.js +42 -9
- package/lib/gates.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +459 -72
- package/lib/index.js.map +1 -1
- package/lib/injection.d.ts +9 -0
- package/lib/injection.d.ts.map +1 -0
- package/lib/injection.js +128 -0
- package/lib/injection.js.map +1 -0
- package/lib/input/actions.d.ts +19 -2
- package/lib/input/actions.d.ts.map +1 -1
- package/lib/input/actions.js +57 -11
- package/lib/input/actions.js.map +1 -1
- package/lib/input/cancel.d.ts +55 -0
- package/lib/input/cancel.d.ts.map +1 -0
- package/lib/input/cancel.js +47 -0
- package/lib/input/cancel.js.map +1 -0
- package/lib/input/completion.d.ts +5 -2
- package/lib/input/completion.d.ts.map +1 -1
- package/lib/input/completion.js +79 -4
- package/lib/input/completion.js.map +1 -1
- package/lib/input/file-search.d.ts +105 -0
- package/lib/input/file-search.d.ts.map +1 -0
- package/lib/input/file-search.js +665 -0
- package/lib/input/file-search.js.map +1 -0
- package/lib/input/fuzzy.d.ts +42 -0
- package/lib/input/fuzzy.d.ts.map +1 -0
- package/lib/input/fuzzy.js +289 -0
- package/lib/input/fuzzy.js.map +1 -0
- package/lib/input/keymap.d.ts +3 -1
- package/lib/input/keymap.d.ts.map +1 -1
- package/lib/input/keymap.js +14 -3
- package/lib/input/keymap.js.map +1 -1
- package/lib/input/match.d.ts +12 -4
- package/lib/input/match.d.ts.map +1 -1
- package/lib/input/match.js +50 -51
- package/lib/input/match.js.map +1 -1
- package/lib/input/submission.d.ts +10 -0
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +7 -6
- package/lib/input/submission.js.map +1 -1
- package/lib/keys-command.d.ts +19 -3
- package/lib/keys-command.d.ts.map +1 -1
- package/lib/keys-command.js +34 -31
- package/lib/keys-command.js.map +1 -1
- package/lib/settings-notice.d.ts +5 -0
- package/lib/settings-notice.d.ts.map +1 -1
- package/lib/settings-notice.js +9 -7
- package/lib/settings-notice.js.map +1 -1
- package/lib/stash/paths.d.ts +8 -8
- package/lib/stash/paths.d.ts.map +1 -1
- package/lib/stash/paths.js +19 -18
- package/lib/stash/paths.js.map +1 -1
- package/lib/stash/schema.d.ts +11 -12
- package/lib/stash/schema.d.ts.map +1 -1
- package/lib/stash/schema.js +10 -29
- package/lib/stash/schema.js.map +1 -1
- package/lib/stash/store.js +10 -10
- package/lib/stash/store.js.map +1 -1
- package/lib/stash.d.ts +37 -6
- package/lib/stash.d.ts.map +1 -1
- package/lib/stash.js +93 -36
- package/lib/stash.js.map +1 -1
- package/lib/terminal/external-editor.d.ts +93 -0
- package/lib/terminal/external-editor.d.ts.map +1 -0
- package/lib/terminal/external-editor.js +263 -0
- package/lib/terminal/external-editor.js.map +1 -0
- package/lib/terminal/host-writes.d.ts +39 -0
- package/lib/terminal/host-writes.d.ts.map +1 -0
- package/lib/terminal/host-writes.js +136 -0
- package/lib/terminal/host-writes.js.map +1 -0
- package/lib/terminal/signals.d.ts +25 -0
- package/lib/terminal/signals.d.ts.map +1 -0
- package/lib/terminal/signals.js +39 -0
- package/lib/terminal/signals.js.map +1 -0
- package/lib/terminal/warning-screen.d.ts +19 -1
- package/lib/terminal/warning-screen.d.ts.map +1 -1
- package/lib/terminal/warning-screen.js +37 -1
- package/lib/terminal/warning-screen.js.map +1 -1
- package/lib/terminal-text.d.ts +72 -0
- package/lib/terminal-text.d.ts.map +1 -0
- package/lib/terminal-text.js +603 -0
- package/lib/terminal-text.js.map +1 -0
- package/lib/text.d.ts +45 -6
- package/lib/text.d.ts.map +1 -1
- package/lib/text.js +106 -46
- package/lib/text.js.map +1 -1
- package/lib/theme-command.d.ts +7 -4
- package/lib/theme-command.d.ts.map +1 -1
- package/lib/theme-command.js +32 -12
- package/lib/theme-command.js.map +1 -1
- package/lib/theme-files.d.ts +110 -0
- package/lib/theme-files.d.ts.map +1 -0
- package/lib/theme-files.js +352 -0
- package/lib/theme-files.js.map +1 -0
- package/lib/theme-schema.d.ts +121 -0
- package/lib/theme-schema.d.ts.map +1 -0
- package/lib/theme-schema.js +87 -0
- package/lib/theme-schema.js.map +1 -0
- package/lib/theme-settings.d.ts +43 -10
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +103 -43
- package/lib/theme-settings.js.map +1 -1
- package/lib/theme-tokens.d.ts +39 -7
- package/lib/theme-tokens.d.ts.map +1 -1
- package/lib/theme-tokens.js +96 -13
- package/lib/theme-tokens.js.map +1 -1
- package/lib/theme.d.ts +24 -2
- package/lib/theme.d.ts.map +1 -1
- package/lib/theme.js +49 -6
- package/lib/theme.js.map +1 -1
- package/lib/tool-display.d.ts +57 -0
- package/lib/tool-display.d.ts.map +1 -0
- package/lib/tool-display.js +51 -0
- package/lib/tool-display.js.map +1 -0
- package/lib/transcript.d.ts +28 -0
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +126 -37
- package/lib/transcript.js.map +1 -1
- package/lib/ui/diff.d.ts +49 -0
- package/lib/ui/diff.d.ts.map +1 -0
- package/lib/ui/diff.js +208 -0
- package/lib/ui/diff.js.map +1 -0
- package/lib/ui/dock.d.ts.map +1 -1
- package/lib/ui/dock.js +9 -6
- package/lib/ui/dock.js.map +1 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +4 -2
- package/lib/ui/editor.js.map +1 -1
- package/lib/ui/frame.d.ts +16 -5
- package/lib/ui/frame.d.ts.map +1 -1
- package/lib/ui/frame.js +32 -13
- package/lib/ui/frame.js.map +1 -1
- package/lib/ui/keymap-picker.d.ts +16 -0
- package/lib/ui/keymap-picker.d.ts.map +1 -0
- package/lib/ui/keymap-picker.js +41 -0
- package/lib/ui/keymap-picker.js.map +1 -0
- package/lib/ui/layout.d.ts +33 -0
- package/lib/ui/layout.d.ts.map +1 -0
- package/lib/ui/layout.js +33 -0
- package/lib/ui/layout.js.map +1 -0
- package/lib/ui/markdown.d.ts +25 -3
- package/lib/ui/markdown.d.ts.map +1 -1
- package/lib/ui/markdown.js +12 -8
- package/lib/ui/markdown.js.map +1 -1
- package/lib/ui/picker-card.d.ts +42 -0
- package/lib/ui/picker-card.d.ts.map +1 -0
- package/lib/ui/picker-card.js +148 -0
- package/lib/ui/picker-card.js.map +1 -0
- package/lib/ui/picker.d.ts +40 -2
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +50 -6
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/prompt.d.ts +10 -2
- package/lib/ui/prompt.d.ts.map +1 -1
- package/lib/ui/prompt.js +21 -2
- package/lib/ui/prompt.js.map +1 -1
- package/lib/ui/rows.d.ts +8 -6
- package/lib/ui/rows.d.ts.map +1 -1
- package/lib/ui/rows.js +10 -18
- package/lib/ui/rows.js.map +1 -1
- package/lib/ui/stash-picker.d.ts +2 -2
- package/lib/ui/stash-picker.d.ts.map +1 -1
- package/lib/ui/stash-picker.js +4 -4
- package/lib/ui/stash-picker.js.map +1 -1
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +18 -16
- package/lib/ui/status.js.map +1 -1
- package/lib/ui/theme-picker.d.ts +18 -0
- package/lib/ui/theme-picker.d.ts.map +1 -0
- package/lib/ui/theme-picker.js +68 -0
- package/lib/ui/theme-picker.js.map +1 -0
- package/lib/ui/view.d.ts +146 -22
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +461 -154
- package/lib/ui/view.js.map +1 -1
- package/package.json +45 -11
- package/themes/deepseek-blue.yaml +183 -0
- package/themes/violet-orbit.yaml +178 -0
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 { ProcessTerminal, ScrollView,
|
|
4
|
+
import { ProcessTerminal, ScrollView, 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 { createPromptHistory } from "./agent/prompt-history.js";
|
|
@@ -15,6 +15,7 @@ import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } f
|
|
|
15
15
|
import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
|
|
16
16
|
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
17
17
|
import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
|
|
18
|
+
import { cancelStep, quitStep } from "./input/cancel.js";
|
|
18
19
|
import { createCompletionProvider } from "./input/completion.js";
|
|
19
20
|
import { ghostSuffix } from "./input/ghost.js";
|
|
20
21
|
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
@@ -24,6 +25,8 @@ import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
|
|
|
24
25
|
import { resolveConfig } from "./config.js";
|
|
25
26
|
import { FoldCursor } from "./fold-cursor.js";
|
|
26
27
|
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
28
|
+
import { ExternalEditor } from "./terminal/external-editor.js";
|
|
29
|
+
import { installSignalRestore } from "./terminal/signals.js";
|
|
27
30
|
import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
28
31
|
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
29
32
|
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
@@ -34,9 +37,11 @@ import { defaultExportFile, transcriptToText } from "./export.js";
|
|
|
34
37
|
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
35
38
|
import { detectColourMode } from "./theme-capability.js";
|
|
36
39
|
import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
40
|
+
import { toolDisplayFor } from "./tool-display.js";
|
|
37
41
|
import { pendingPrompts } from "./queue.js";
|
|
38
42
|
import { renderThemeTable } from "./theme-command.js";
|
|
39
|
-
import {
|
|
43
|
+
import { DEFAULT_THEME, builtinNames, builtinThemesDir, ensureThemesHome, exportTheme, loadThemes, themesHomeDir, watchThemes } from "./theme-files.js";
|
|
44
|
+
import { KEYMAP_LAYERS, keymapLayer } from "./keys-command.js";
|
|
40
45
|
import { resetSequence } from "./theme-tokens.js";
|
|
41
46
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
42
47
|
import { formatTokens } from "./tokens.js";
|
|
@@ -45,15 +50,19 @@ import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanSt
|
|
|
45
50
|
import { WorkDock } from "./ui/dock.js";
|
|
46
51
|
import { GateInputBar } from "./ui/gate-input.js";
|
|
47
52
|
import { HistoryPicker } from "./ui/history-picker.js";
|
|
53
|
+
import { surfaceLayout } from "./ui/layout.js";
|
|
54
|
+
import { KeymapPicker } from "./ui/keymap-picker.js";
|
|
55
|
+
import { PickerPopup, POPUP_MAX_HEIGHT, popupWidth } from "./ui/picker-card.js";
|
|
48
56
|
import { PromptBar } from "./ui/prompt.js";
|
|
49
57
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
50
58
|
import { createMermaidTransform } from "./ui/mermaid.js";
|
|
51
59
|
import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
|
|
52
60
|
import { QueueBar } from "./ui/queue.js";
|
|
53
|
-
import {
|
|
61
|
+
import { StatusBar } from "./ui/status.js";
|
|
54
62
|
import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
|
|
55
63
|
import { PromptStash } from "./stash.js";
|
|
56
64
|
import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
|
|
65
|
+
import { ThemePicker } from "./ui/theme-picker.js";
|
|
57
66
|
export const name = 'tui';
|
|
58
67
|
/**
|
|
59
68
|
* Services the surface cannot run without.
|
|
@@ -128,19 +137,76 @@ export function apply(ctx, config) {
|
|
|
128
137
|
* late-bound read that the injection point and the change event both use.
|
|
129
138
|
*/
|
|
130
139
|
let readSection = () => defaultSettings();
|
|
140
|
+
/**
|
|
141
|
+
* The themes this session can draw.
|
|
142
|
+
*
|
|
143
|
+
* Read from disk rather than compiled in, so a file the reader saves is a theme
|
|
144
|
+
* the moment the save lands. Held beside the settings rather than inside them
|
|
145
|
+
* because the two answer different questions: the section says which name the
|
|
146
|
+
* reader chose, and this says which names exist — including the one they typed
|
|
147
|
+
* into the directory a second ago.
|
|
148
|
+
*/
|
|
149
|
+
const themesHome = themesHomeDir();
|
|
150
|
+
let themeLibrary = loadThemes(themesHome, builtinThemesDir());
|
|
151
|
+
/**
|
|
152
|
+
* Persist a theme choice, replaced once the section is registered.
|
|
153
|
+
*
|
|
154
|
+
* A theme picked mid-session has to outlive it, so the choice is written
|
|
155
|
+
* through the same scope the reader's document is read from instead of kept
|
|
156
|
+
* in memory: the host persists it, and the change comes back through
|
|
157
|
+
* `settings/updated` like any other edit — which is what restyles the screen.
|
|
158
|
+
*/
|
|
159
|
+
let chooseTheme = (_name) => { };
|
|
131
160
|
/**
|
|
132
161
|
* A refused settings edit, kept until the surface can show it: stderr is
|
|
133
162
|
* behind the alt screen, and the section is read on a schedule of its own.
|
|
134
163
|
*/
|
|
135
164
|
const settingsNotice = createDeferredNotice();
|
|
136
165
|
let current = createTheme(themeMode());
|
|
166
|
+
/**
|
|
167
|
+
* The section as it was last read.
|
|
168
|
+
*
|
|
169
|
+
* Held so a preview can rebuild the table without reading the document again:
|
|
170
|
+
* the picker repaints on every arrow key, and a read there would report a
|
|
171
|
+
* refused section once per press.
|
|
172
|
+
*/
|
|
173
|
+
let appliedSection;
|
|
174
|
+
/**
|
|
175
|
+
* The theme the picker's cursor is on, while its list is open.
|
|
176
|
+
*
|
|
177
|
+
* A preview is a name and nothing else — the document is not written — so
|
|
178
|
+
* leaving the list is one more rebuild from the section, and a session that
|
|
179
|
+
* ends mid-preview has still persisted only what the reader chose.
|
|
180
|
+
*/
|
|
181
|
+
let previewTheme;
|
|
137
182
|
const applyTheme = (section) => {
|
|
138
|
-
|
|
183
|
+
// The row under the cursor outranks the document while a list is open, so a
|
|
184
|
+
// theme is judged on the reader's own transcript before it is taken.
|
|
185
|
+
current = createTheme(themeMode(), toOverrides({ ...section, theme: previewTheme ?? section.theme }, themeLibrary));
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Show a theme without choosing it.
|
|
189
|
+
*
|
|
190
|
+
* The list's cursor is the preview: every row paints the surface and writes
|
|
191
|
+
* nothing, so two themes are compared on the reader's own transcript rather
|
|
192
|
+
* than on a name. Clearing the name puts back what the document says, which is
|
|
193
|
+
* what cancelling the list has to leave behind.
|
|
194
|
+
*/
|
|
195
|
+
const showTheme = (name) => {
|
|
196
|
+
previewTheme = name;
|
|
197
|
+
// Nothing to rebuild from before the first read, and nothing to show either.
|
|
198
|
+
if (appliedSection === undefined)
|
|
199
|
+
return;
|
|
200
|
+
applyTheme(appliedSection);
|
|
201
|
+
markdown.invalidate();
|
|
202
|
+
view.invalidate();
|
|
203
|
+
tui.requestRender();
|
|
139
204
|
};
|
|
140
205
|
const theme = {
|
|
141
206
|
get revision() { return current.revision; },
|
|
142
207
|
get color() { return current.color; },
|
|
143
208
|
style: (token, text) => current.style(token, text),
|
|
209
|
+
rich: (raw, options) => current.rich(raw, options),
|
|
144
210
|
cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
|
|
145
211
|
glyph: token => current.glyph(token),
|
|
146
212
|
visible: token => current.visible(token),
|
|
@@ -151,12 +217,13 @@ export function apply(ctx, config) {
|
|
|
151
217
|
markdown: forwardMarkdownTheme(() => current.markdown),
|
|
152
218
|
};
|
|
153
219
|
/**
|
|
154
|
-
* Rows the reader has opened. The model stays untouched; only the view
|
|
155
|
-
* this
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
220
|
+
* Rows the reader has opened by key. The model stays untouched; only the view
|
|
221
|
+
* reads this, and a click on one message overrides it there. A thought starts
|
|
222
|
+
* folded: it is the longest, least scannable row in the transcript, so leaving
|
|
223
|
+
* it open pushes the answer a reader came for off the screen, and a folded row
|
|
224
|
+
* still names itself and its key. Cards start from the reader's own `tools:`
|
|
225
|
+
* settings, and a PTC card's calls start open for the opposite reason: each is
|
|
226
|
+
* one clipped line under a header that already names the program.
|
|
160
227
|
*/
|
|
161
228
|
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
162
229
|
/**
|
|
@@ -167,6 +234,8 @@ export function apply(ctx, config) {
|
|
|
167
234
|
* follow the edit.
|
|
168
235
|
*/
|
|
169
236
|
let mermaidMode = defaultSettings().mermaid;
|
|
237
|
+
/** How each tool's cards draw; the settings document owns it and the view reads it live. */
|
|
238
|
+
let toolDisplay = defaultSettings().tools;
|
|
170
239
|
/** The keys that start a chord, and how long one waits; the settings document owns all of it. */
|
|
171
240
|
let prefixKeys = DEFAULT_PREFIX_KEYS;
|
|
172
241
|
let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
|
|
@@ -190,11 +259,13 @@ export function apply(ctx, config) {
|
|
|
190
259
|
*
|
|
191
260
|
* The key toggles nested calls for one session, but a settings edit is a
|
|
192
261
|
* deliberate act, so it re-seeds and becomes the new starting point; the
|
|
193
|
-
* mermaid mode
|
|
262
|
+
* mermaid mode and the per-tool card fold have no key of their own and only
|
|
263
|
+
* ever come from the document.
|
|
194
264
|
*/
|
|
195
265
|
const applyDisplay = (section) => {
|
|
196
266
|
viewState.expandSubCalls = section.subcalls === 'inline';
|
|
197
267
|
mermaidMode = section.mermaid;
|
|
268
|
+
toolDisplay = section.tools;
|
|
198
269
|
prefixKeys = section.prefixes;
|
|
199
270
|
prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
|
|
200
271
|
keymap = section.keymap;
|
|
@@ -215,9 +286,30 @@ export function apply(ctx, config) {
|
|
|
215
286
|
*/
|
|
216
287
|
const applySettings = () => {
|
|
217
288
|
const section = readSection();
|
|
289
|
+
appliedSection = section;
|
|
290
|
+
// A settings edit ends any preview: what the document says is now the choice,
|
|
291
|
+
// and a name left over from a list would outrank it.
|
|
292
|
+
previewTheme = undefined;
|
|
293
|
+
reportMissingTheme(section);
|
|
218
294
|
applyTheme(section);
|
|
219
295
|
applyDisplay(section);
|
|
220
296
|
};
|
|
297
|
+
/**
|
|
298
|
+
* Say so when the reader named a theme that nothing answers to.
|
|
299
|
+
*
|
|
300
|
+
* The schema cannot refuse the name: a theme is a file, so the set of names is
|
|
301
|
+
* known to the directory rather than to this build, and one can stop answering
|
|
302
|
+
* between two reads. Falling back to the default without a word would leave the
|
|
303
|
+
* reader looking at shades they did not choose, so the refusal lands here
|
|
304
|
+
* instead — beside the read that found it, and alongside the rest of the
|
|
305
|
+
* section, which is still theirs.
|
|
306
|
+
*/
|
|
307
|
+
const reportMissingTheme = (section) => {
|
|
308
|
+
const name = section.theme;
|
|
309
|
+
if (name === undefined || themeLibrary.get(name) !== undefined)
|
|
310
|
+
return;
|
|
311
|
+
settingsNotice.post(`dsh-tui theme "${name}" is not a theme · themes: ${themeLibrary.names().join(' · ')} · the default, ${DEFAULT_THEME}, is drawn instead`);
|
|
312
|
+
};
|
|
221
313
|
/**
|
|
222
314
|
* Own the section, so the harness validates and persists it for the reader.
|
|
223
315
|
*
|
|
@@ -243,6 +335,9 @@ export function apply(ctx, config) {
|
|
|
243
335
|
return;
|
|
244
336
|
}
|
|
245
337
|
readSection = () => readScope(scope, message => settingsNotice.post(message));
|
|
338
|
+
chooseTheme = name => {
|
|
339
|
+
void scope.update({ theme: name }).then(() => model.notice(`theme · ${name} · written to the settings document`), (error) => settingsNotice.post(settingsProblemMessage(error)));
|
|
340
|
+
};
|
|
246
341
|
applySettings();
|
|
247
342
|
});
|
|
248
343
|
/**
|
|
@@ -314,6 +409,28 @@ export function apply(ctx, config) {
|
|
|
314
409
|
const restore = createRestoreRegistry();
|
|
315
410
|
const terminal = new ProcessTerminal();
|
|
316
411
|
const tui = new WarningSafeTui(terminal);
|
|
412
|
+
// A frame that cannot be drawn leaves the last good screen up, so the failure
|
|
413
|
+
// has to reach the transcript: otherwise the surface looks frozen and nothing
|
|
414
|
+
// on screen can say why.
|
|
415
|
+
tui.onFrameError = error => model.reportError(error);
|
|
416
|
+
/** Whether a child process owns the terminal, which is when nothing here may write to it. */
|
|
417
|
+
let handedOver = false;
|
|
418
|
+
/** Whether the host has unloaded this surface, after which nothing may start it again. */
|
|
419
|
+
let disposed = false;
|
|
420
|
+
/** An exit asked for while a child owned the terminal, run once the screen is ours again. */
|
|
421
|
+
let deferredExit;
|
|
422
|
+
/**
|
|
423
|
+
* Write to the tty, but only while this surface owns it.
|
|
424
|
+
*
|
|
425
|
+
* An editor the reader opened draws its own screen on the same terminal, so a
|
|
426
|
+
* title or a bell from here would land on top of it and, for a bell, sound as
|
|
427
|
+
* if the editor had failed. The title is written again when the screen comes
|
|
428
|
+
* back; a bell that fell in the gap is dropped rather than rung late.
|
|
429
|
+
*/
|
|
430
|
+
const writeTerminal = (text) => {
|
|
431
|
+
if (!handedOver)
|
|
432
|
+
terminal.write(text);
|
|
433
|
+
};
|
|
317
434
|
/** The session this surface drives: commands, approvals, and the bell belong to it. */
|
|
318
435
|
let activeSession = resolved.sessionId;
|
|
319
436
|
/** The session the transcript is showing, which can be one of its children. */
|
|
@@ -325,8 +442,9 @@ export function apply(ctx, config) {
|
|
|
325
442
|
const view = new TranscriptView(model, theme, markdown, {
|
|
326
443
|
state: () => viewState,
|
|
327
444
|
gate: () => pending?.gate.card(),
|
|
328
|
-
picker: () => pendingPicker?.
|
|
445
|
+
picker: () => pendingPicker?.card?.(),
|
|
329
446
|
keys: () => keymap,
|
|
447
|
+
toolDisplay: tool => toolDisplayFor(toolDisplay, tool),
|
|
330
448
|
});
|
|
331
449
|
// The key map goes in before the bar exists, so no press can be read as the
|
|
332
450
|
// send the library submits on by default. A settings document read after this
|
|
@@ -372,7 +490,8 @@ export function apply(ctx, config) {
|
|
|
372
490
|
const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
|
|
373
491
|
// The queue is read from the agent this terminal drives rather than from the
|
|
374
492
|
// session on screen, because it sits on the editor that submits to that agent.
|
|
375
|
-
const
|
|
493
|
+
const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
|
|
494
|
+
const queue = new QueueBar(queuedPrompts, theme);
|
|
376
495
|
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
377
496
|
const statusTicker = setInterval(() => {
|
|
378
497
|
if (turnOpen)
|
|
@@ -390,6 +509,9 @@ export function apply(ctx, config) {
|
|
|
390
509
|
const unregisterExit = herdr.registerExitRelease();
|
|
391
510
|
restore.add(() => tui.stop());
|
|
392
511
|
ctx.effect(() => () => {
|
|
512
|
+
// A surface the host unloads owns no screen and keeps no listeners, so a
|
|
513
|
+
// child still running in another process must not be handed a start().
|
|
514
|
+
disposed = true;
|
|
393
515
|
// The pane stops being an agent before the process that claimed it unwinds:
|
|
394
516
|
// a release that ran after the reports were unregistered would race them,
|
|
395
517
|
// and one that never ran would leave a row that reads as a live agent. The
|
|
@@ -404,27 +526,24 @@ export function apply(ctx, config) {
|
|
|
404
526
|
dispose();
|
|
405
527
|
return released.finally(unregisterExit);
|
|
406
528
|
});
|
|
407
|
-
tui.setLayoutRoot(
|
|
408
|
-
{
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
// Work state earns rows only when there is some: a dock that always
|
|
415
|
-
// occupied a row would cost every conversation one line of transcript.
|
|
416
|
-
{ component: dock, basis: 'auto', shrink: 0, minSize: 0 },
|
|
417
|
-
// Queued input earns rows only while something is waiting, and it gives
|
|
418
|
-
// them up before the editor does: the bar being typed in outranks what is
|
|
419
|
-
// waiting behind it.
|
|
420
|
-
{ component: queue, basis: 'auto', shrink: 2, minSize: 0 },
|
|
421
|
-
{ component: new VStack([{ component: promptBar, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 0 },
|
|
422
|
-
{ component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
|
|
423
|
-
]));
|
|
529
|
+
tui.setLayoutRoot(surfaceLayout({
|
|
530
|
+
transcript: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
|
|
531
|
+
dock,
|
|
532
|
+
queue,
|
|
533
|
+
prompt: promptBar,
|
|
534
|
+
status: statusBar,
|
|
535
|
+
}));
|
|
424
536
|
tui.setFocus(editor);
|
|
425
537
|
const requestExit = (code, reason) => {
|
|
426
538
|
if (exited)
|
|
427
539
|
return;
|
|
540
|
+
// A child owns the terminal: restoring it here would leave the reader a
|
|
541
|
+
// shell behind an editor that is still running, and would put its tty back
|
|
542
|
+
// into cooked mode under it. The exit waits for the screen to come back.
|
|
543
|
+
if (handedOver) {
|
|
544
|
+
deferredExit = { code, reason };
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
428
547
|
exited = true;
|
|
429
548
|
clearInterval(statusTicker);
|
|
430
549
|
// The screen goes back at once, so leaving feels like leaving; the row goes
|
|
@@ -453,6 +572,10 @@ export function apply(ctx, config) {
|
|
|
453
572
|
appExit(code);
|
|
454
573
|
});
|
|
455
574
|
};
|
|
575
|
+
// A signal ends the process from outside the surface, and the default action
|
|
576
|
+
// would leave the reader on a screen no shell prompt is drawn in: the same
|
|
577
|
+
// shutdown a quit key runs goes to the signals a supervisor sends.
|
|
578
|
+
disposers.push(installSignalRestore({ shutdown: code => requestExit(code, 'interrupted') }));
|
|
456
579
|
const openGate = (next) => {
|
|
457
580
|
pending = next;
|
|
458
581
|
// The card's own title names the decision, which is what a reader glancing
|
|
@@ -477,6 +600,14 @@ export function apply(ctx, config) {
|
|
|
477
600
|
tui.setFocus(editor);
|
|
478
601
|
tui.requestRender();
|
|
479
602
|
};
|
|
603
|
+
/**
|
|
604
|
+
* Whether the bar holds anything the reader wrote.
|
|
605
|
+
*
|
|
606
|
+
* Whitespace counts: it is a character the editor holds, and a press that
|
|
607
|
+
* clears it is the press the reader asked for. A paste counts expanded,
|
|
608
|
+
* because a marker is content rather than an absence of it.
|
|
609
|
+
*/
|
|
610
|
+
const barHasText = () => editor.getExpandedText() !== '';
|
|
480
611
|
/**
|
|
481
612
|
* What each key the surface answers itself does; false hands the press back.
|
|
482
613
|
*
|
|
@@ -514,15 +645,57 @@ export function apply(ctx, config) {
|
|
|
514
645
|
},
|
|
515
646
|
interrupt: () => {
|
|
516
647
|
// In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
|
|
517
|
-
// decides
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
648
|
+
// decides what one press takes back — and a press with nothing left to
|
|
649
|
+
// cancel is handed back rather than spent on leaving.
|
|
650
|
+
const queued = queuedPrompts();
|
|
651
|
+
const step = cancelStep({
|
|
652
|
+
barHasText: barHasText(),
|
|
653
|
+
queuedPrompts: queued.length,
|
|
654
|
+
turnRunning: turnOpen,
|
|
655
|
+
viewingChild: viewedSession !== activeSession,
|
|
656
|
+
});
|
|
657
|
+
switch (step) {
|
|
658
|
+
case 'clear-editor':
|
|
659
|
+
editor.setText('');
|
|
660
|
+
tui.requestRender();
|
|
661
|
+
return true;
|
|
662
|
+
case 'reclaim-queued':
|
|
663
|
+
// The interrupt drops whatever the agent had not started, so the words
|
|
664
|
+
// are read before it is stopped and handed back to the bar: a key that
|
|
665
|
+
// means "stop" must not be the key that loses the reader's own prompts.
|
|
666
|
+
agent?.interrupt();
|
|
667
|
+
editor.setText(queued.join('\n'));
|
|
668
|
+
model.notice(`interrupt requested · ${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} back in the bar`);
|
|
669
|
+
tui.requestRender();
|
|
670
|
+
return true;
|
|
671
|
+
case 'interrupt-turn':
|
|
672
|
+
agent?.interrupt();
|
|
673
|
+
model.notice('interrupt requested');
|
|
674
|
+
tui.requestRender();
|
|
675
|
+
return true;
|
|
676
|
+
case 'leave-child-view':
|
|
677
|
+
void showAgentSession();
|
|
678
|
+
return true;
|
|
679
|
+
case 'hand-back':
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
quit: () => {
|
|
684
|
+
// The only key that leaves. Text in the bar keeps it for the editor, so
|
|
685
|
+
// the library's delete forward is never taken from a reader who is editing.
|
|
686
|
+
switch (quitStep({ barHasText: barHasText(), overlayOpen: tui.hasOverlay(), turnRunning: turnOpen })) {
|
|
687
|
+
case 'cancel-then-quit':
|
|
688
|
+
// An exit that waits on a tool call is not an exit, so the turn is
|
|
689
|
+
// asked to stop and the leave does not wait for the answer.
|
|
690
|
+
agent?.interrupt();
|
|
691
|
+
requestExit(0);
|
|
692
|
+
return true;
|
|
693
|
+
case 'quit':
|
|
694
|
+
requestExit(0);
|
|
695
|
+
return true;
|
|
696
|
+
case 'hand-back':
|
|
697
|
+
return false;
|
|
521
698
|
}
|
|
522
|
-
agent?.interrupt();
|
|
523
|
-
model.notice('interrupt requested');
|
|
524
|
-
tui.requestRender();
|
|
525
|
-
return true;
|
|
526
699
|
},
|
|
527
700
|
};
|
|
528
701
|
disposers.push(tui.addInputListener(data => {
|
|
@@ -647,6 +820,9 @@ export function apply(ctx, config) {
|
|
|
647
820
|
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
648
821
|
const settlePicker = (id) => {
|
|
649
822
|
const settle = pendingPicker?.settle;
|
|
823
|
+
// The screen goes back before the keyboard does: a box left behind a settled
|
|
824
|
+
// list would sit over the transcript until something else repainted.
|
|
825
|
+
pendingPicker?.release?.();
|
|
650
826
|
pendingPicker = undefined;
|
|
651
827
|
herdr.unblock();
|
|
652
828
|
editor.disableSubmit = false;
|
|
@@ -672,9 +848,36 @@ export function apply(ctx, config) {
|
|
|
672
848
|
return error instanceof Error ? error.message : String(error);
|
|
673
849
|
}
|
|
674
850
|
};
|
|
675
|
-
/**
|
|
676
|
-
|
|
677
|
-
|
|
851
|
+
/**
|
|
852
|
+
* Take the keyboard for a picker and answer with the id it settled on.
|
|
853
|
+
*
|
|
854
|
+
* Where the list is drawn is the caller's decision, because it is a decision
|
|
855
|
+
* about the reader's attention. A list that is the destination — a session to
|
|
856
|
+
* open, a model to switch to — joins the transcript and is read with it. A
|
|
857
|
+
* list that is a reference for the work in front of the reader is drawn over
|
|
858
|
+
* that work instead, so looking something up does not cost them their place.
|
|
859
|
+
* The keyboard is owned the same way either way.
|
|
860
|
+
*/
|
|
861
|
+
const openPicker = (picker, vet, placement = 'inline') => new Promise(resolve => {
|
|
862
|
+
const overlay = placement === 'popup'
|
|
863
|
+
? tui.showOverlay(new PickerPopup(rows => picker.card(rows), () => terminal.rows, theme), {
|
|
864
|
+
width: popupWidth(terminal.columns),
|
|
865
|
+
maxHeight: POPUP_MAX_HEIGHT,
|
|
866
|
+
anchor: 'center',
|
|
867
|
+
margin: 1,
|
|
868
|
+
// The surface's own listener reads every press before a focused
|
|
869
|
+
// component does, so the box needs no focus to be driven; taking it
|
|
870
|
+
// would only move focus away from where the reader left it.
|
|
871
|
+
nonCapturing: true,
|
|
872
|
+
})
|
|
873
|
+
: undefined;
|
|
874
|
+
pendingPicker = {
|
|
875
|
+
picker,
|
|
876
|
+
settle: resolve,
|
|
877
|
+
vet,
|
|
878
|
+
card: overlay === undefined ? () => picker.card() : undefined,
|
|
879
|
+
release: overlay === undefined ? undefined : () => overlay.hide(),
|
|
880
|
+
};
|
|
678
881
|
// A picker owns the keyboard exactly as a gate does: nothing moves until
|
|
679
882
|
// the reader chooses, so it is the same kind of wait.
|
|
680
883
|
herdr.block(picker.card().title);
|
|
@@ -682,6 +885,36 @@ export function apply(ctx, config) {
|
|
|
682
885
|
tui.setFocus(null);
|
|
683
886
|
tui.requestRender();
|
|
684
887
|
});
|
|
888
|
+
/**
|
|
889
|
+
* Open the key map over the surface.
|
|
890
|
+
*
|
|
891
|
+
* Nothing here is a pick: a row names an action and the keys reaching it, so
|
|
892
|
+
* the id the list settles on is thrown away. What the reader came for is the
|
|
893
|
+
* list itself, and the filter that narrows it.
|
|
894
|
+
*/
|
|
895
|
+
const openKeyMap = (layer) => {
|
|
896
|
+
void openPicker(new KeymapPicker(() => keymap, layer), undefined, 'popup');
|
|
897
|
+
};
|
|
898
|
+
/**
|
|
899
|
+
* Choose a theme from a list the screen follows.
|
|
900
|
+
*
|
|
901
|
+
* Enter writes the choice through the settings document, the same path a typed
|
|
902
|
+
* name takes, so what lands is what the reader was looking at. Leaving the list
|
|
903
|
+
* restores the theme in force, because the document never changed while they
|
|
904
|
+
* looked.
|
|
905
|
+
*/
|
|
906
|
+
const openThemePicker = async () => {
|
|
907
|
+
const picked = await openPicker(new ThemePicker(() => themeLibrary, () => appliedSection?.theme, () => keymap, theme => showTheme(theme?.name)));
|
|
908
|
+
if (picked === undefined) {
|
|
909
|
+
showTheme(undefined);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
// The row stays on screen until the document carries it: restoring first
|
|
913
|
+
// would flash the theme the reader just left. The write clears the preview as
|
|
914
|
+
// it lands, and a write that fails says so, leaving a theme that is still one
|
|
915
|
+
// of theirs rather than shades nothing chose.
|
|
916
|
+
chooseTheme(picked);
|
|
917
|
+
};
|
|
685
918
|
/**
|
|
686
919
|
* Reverse search over recorded prompts, seeded with whatever is in the bar.
|
|
687
920
|
*
|
|
@@ -718,13 +951,12 @@ export function apply(ctx, config) {
|
|
|
718
951
|
return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
|
|
719
952
|
};
|
|
720
953
|
/**
|
|
721
|
-
* The prompt bank for this
|
|
954
|
+
* The prompt bank for the session this surface drives.
|
|
722
955
|
*
|
|
723
956
|
* The surface owns the editor, the picker, and the notices, so the bank is
|
|
724
957
|
* handed the few things it needs to reach them and nothing else: the commands
|
|
725
958
|
* stay free of terminal state and are exercised without one in the tests.
|
|
726
959
|
*/
|
|
727
|
-
const stashCwdLabel = shortPath(process.cwd(), process.env.HOME);
|
|
728
960
|
stash = new PromptStash({
|
|
729
961
|
getEditorText: () => editor.getExpandedText(),
|
|
730
962
|
setEditorText: text => {
|
|
@@ -732,13 +964,65 @@ export function apply(ctx, config) {
|
|
|
732
964
|
tui.requestRender();
|
|
733
965
|
},
|
|
734
966
|
// A question answers in this editor, so a draft written into a borrowed bar
|
|
735
|
-
// would become somebody's answer instead of a parked prompt.
|
|
736
|
-
|
|
967
|
+
// would become somebody's answer instead of a parked prompt. An editor
|
|
968
|
+
// holding the draft in another program owns it just as firmly: a pop that
|
|
969
|
+
// landed then would be deleted from the bank and then overwritten.
|
|
970
|
+
editorIsAvailable: () => !promptBar.isBorrowed() && !handedOver,
|
|
737
971
|
notice: message => model.notice(message),
|
|
738
972
|
pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
|
|
739
973
|
confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
|
|
740
974
|
render: () => tui.requestRender(),
|
|
741
|
-
},
|
|
975
|
+
},
|
|
976
|
+
// The bank follows the session this surface drives, not the directory it
|
|
977
|
+
// runs in: two terminals in one checkout keep separate drafts, and a resume
|
|
978
|
+
// finds the ones it parked. Read per command so a switch retargets it.
|
|
979
|
+
{ sessionId: () => String(activeSession) });
|
|
980
|
+
/**
|
|
981
|
+
* The reader's own editor, opened over the draft the bar holds.
|
|
982
|
+
*
|
|
983
|
+
* The screen is handed over rather than drawn beside: an editor needs the
|
|
984
|
+
* terminal, so this is the one moment the surface is not the process painting
|
|
985
|
+
* on it. Every failure is reported as a notice and leaves the bar as it was,
|
|
986
|
+
* because the caller is a key press with nowhere to put an error.
|
|
987
|
+
*/
|
|
988
|
+
const externalEditor = new ExternalEditor({
|
|
989
|
+
suspend: () => {
|
|
990
|
+
handedOver = true;
|
|
991
|
+
try {
|
|
992
|
+
// The frame is left in place rather than repainted into the normal
|
|
993
|
+
// buffer: the editor is about to paint over that same screen.
|
|
994
|
+
tui.stop({ preserveScreen: true });
|
|
995
|
+
}
|
|
996
|
+
catch (error) {
|
|
997
|
+
// A stop that failed leaves the screen ours; leaving the flag up would
|
|
998
|
+
// suppress every later title and defer every exit for good.
|
|
999
|
+
handedOver = false;
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
resume: () => {
|
|
1004
|
+
// Whatever the host unloaded is not coming back: starting it again would
|
|
1005
|
+
// paint on a terminal this process is done with, into listeners that are gone.
|
|
1006
|
+
if (disposed)
|
|
1007
|
+
return;
|
|
1008
|
+
try {
|
|
1009
|
+
tui.start();
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
handedOver = false;
|
|
1013
|
+
}
|
|
1014
|
+
// Entering the alternate screen clears it, and any render asked for while
|
|
1015
|
+
// the child owned the terminal was dropped after setting the very flag that
|
|
1016
|
+
// makes the next ordinary request a no-op: without a forced one the reader
|
|
1017
|
+
// would get a blank screen with a working keyboard under it.
|
|
1018
|
+
tui.requestRender(true);
|
|
1019
|
+
// The title is state this surface owns and the handoff swallowed any change
|
|
1020
|
+
// to it, so a turn that ended while the editor was open would leave
|
|
1021
|
+
// "working" up until the next turn.
|
|
1022
|
+
writeTerminal(windowTitle(process.cwd(), turnOpen ? 'working' : 'ready'));
|
|
1023
|
+
},
|
|
1024
|
+
notice: message => model.notice(message),
|
|
1025
|
+
});
|
|
742
1026
|
/** Title the listed sessions without making the reader wait for the slowest log. */
|
|
743
1027
|
const loadTitles = async (history, sessions, titles) => {
|
|
744
1028
|
const queue = [...sessions];
|
|
@@ -810,21 +1094,21 @@ export function apply(ctx, config) {
|
|
|
810
1094
|
*/
|
|
811
1095
|
const foldCursor = new FoldCursor();
|
|
812
1096
|
/** Feed one durable event to the model, unless a fold has already folded it. */
|
|
813
|
-
const applyDurable = (event) => {
|
|
814
|
-
if (foldCursor.accept(event))
|
|
1097
|
+
const applyDurable = (session, event) => {
|
|
1098
|
+
if (foldCursor.accept(session, event))
|
|
815
1099
|
applyEvent(event);
|
|
816
1100
|
};
|
|
817
1101
|
const foldHistory = async (id) => {
|
|
818
1102
|
// Resolved before the fold so every card reads its tool through the scope
|
|
819
1103
|
// that actually registered it; a stored session nobody runs has none.
|
|
820
1104
|
presentScope = ctx.agents?.get(id);
|
|
821
|
-
// Every fold starts a cleared transcript, so
|
|
822
|
-
//
|
|
1105
|
+
// Every fold starts a cleared transcript, so a session already known to the
|
|
1106
|
+
// cursor is read from its first event rather than from where it left off.
|
|
823
1107
|
foldCursor.reset();
|
|
824
1108
|
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
825
1109
|
if (inMemory !== undefined) {
|
|
826
1110
|
for (const event of inMemory)
|
|
827
|
-
applyDurable(event);
|
|
1111
|
+
applyDurable(id, event);
|
|
828
1112
|
return inMemory.length;
|
|
829
1113
|
}
|
|
830
1114
|
const history = createSessionHistory(ctx);
|
|
@@ -832,7 +1116,7 @@ export function apply(ctx, config) {
|
|
|
832
1116
|
return 0;
|
|
833
1117
|
const events = await history.read(id);
|
|
834
1118
|
for (const event of events)
|
|
835
|
-
applyDurable(event);
|
|
1119
|
+
applyDurable(id, event);
|
|
836
1120
|
return events.length;
|
|
837
1121
|
};
|
|
838
1122
|
const replayHistory = async (id) => {
|
|
@@ -948,6 +1232,9 @@ export function apply(ctx, config) {
|
|
|
948
1232
|
activeSession = id;
|
|
949
1233
|
viewedSession = id;
|
|
950
1234
|
agent = handle;
|
|
1235
|
+
// The bank follows the session, so the footer stops counting the drafts of
|
|
1236
|
+
// the session just left and the next command reads this session's file.
|
|
1237
|
+
void stash?.open();
|
|
951
1238
|
// The agent's own id is reported rather than the requested one: a resume can
|
|
952
1239
|
// be answered by the session the log actually holds.
|
|
953
1240
|
herdr.session({
|
|
@@ -1657,21 +1944,66 @@ export function apply(ctx, config) {
|
|
|
1657
1944
|
case 'todo':
|
|
1658
1945
|
runTodoCommand();
|
|
1659
1946
|
return;
|
|
1660
|
-
case 'theme':
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1947
|
+
case 'theme': {
|
|
1948
|
+
const argument = submission.argument;
|
|
1949
|
+
// A bare command is the list: a theme is judged by looking at it, so
|
|
1950
|
+
// choosing one belongs in a list the screen follows rather than in a name
|
|
1951
|
+
// the reader has to already know.
|
|
1952
|
+
if (argument === '') {
|
|
1953
|
+
void openThemePicker();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
const [head = '', ...rest] = argument.split(/\s+/u);
|
|
1957
|
+
// The table answers the other question a theme raises — which layer drew a
|
|
1958
|
+
// shade — and stays reachable by name now that the list has the command.
|
|
1959
|
+
if (head === 'tokens') {
|
|
1960
|
+
for (const line of renderThemeTable(toOverrides(readSection(), themeLibrary), themeLibrary))
|
|
1961
|
+
model.notice(line);
|
|
1962
|
+
tui.requestRender();
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
// The one way a built-in becomes editable. Its file ships inside the
|
|
1966
|
+
// package and the next version replaces it, so a reader who wants to
|
|
1967
|
+
// change one needs a copy that is theirs — and the copy is a theme the
|
|
1968
|
+
// moment it lands, which is why the table is re-read rather than patched.
|
|
1969
|
+
if (head === 'export') {
|
|
1970
|
+
const chosen = rest.join(' ').trim();
|
|
1971
|
+
if (chosen === '') {
|
|
1972
|
+
model.notice(`theme export · which built-in? ${builtinNames(themeLibrary).join(' · ')}`);
|
|
1973
|
+
tui.requestRender();
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
const outcome = exportTheme(themeLibrary, chosen);
|
|
1977
|
+
if (outcome.ok) {
|
|
1978
|
+
themeLibrary = loadThemes(themesHome, builtinThemesDir());
|
|
1979
|
+
model.notice(`theme · exported ${chosen} to ${outcome.path} · /theme ${outcome.select} applies it`);
|
|
1980
|
+
}
|
|
1981
|
+
else {
|
|
1982
|
+
model.notice(outcome.problem);
|
|
1983
|
+
}
|
|
1984
|
+
tui.requestRender();
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
// A name nothing answers to is refused by name, like an unknown key
|
|
1988
|
+
// layer: the reader asked for something, so the answer lists names.
|
|
1989
|
+
if (themeLibrary.get(head) === undefined) {
|
|
1990
|
+
model.notice(`unknown theme "${head}" · themes: ${themeLibrary.names().join(' · ')}`);
|
|
1991
|
+
tui.requestRender();
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
chooseTheme(head);
|
|
1664
1995
|
return;
|
|
1996
|
+
}
|
|
1665
1997
|
case 'keys': {
|
|
1666
1998
|
const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
|
|
1999
|
+
// A layer that does not exist is not a filter that matches nothing: the
|
|
2000
|
+
// reader asked for something by name, so the answer names the names.
|
|
1667
2001
|
if (submission.argument !== '' && layer === undefined) {
|
|
1668
2002
|
model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
|
|
2003
|
+
tui.requestRender();
|
|
2004
|
+
return;
|
|
1669
2005
|
}
|
|
1670
|
-
|
|
1671
|
-
for (const line of renderKeymap(keymap, layer))
|
|
1672
|
-
model.notice(line);
|
|
1673
|
-
}
|
|
1674
|
-
tui.requestRender();
|
|
2006
|
+
openKeyMap(layer);
|
|
1675
2007
|
return;
|
|
1676
2008
|
}
|
|
1677
2009
|
case 'copy':
|
|
@@ -1699,7 +2031,7 @@ export function apply(ctx, config) {
|
|
|
1699
2031
|
void stash?.apply(submission.selector);
|
|
1700
2032
|
return;
|
|
1701
2033
|
case 'stash-list':
|
|
1702
|
-
void stash?.list(
|
|
2034
|
+
void stash?.list(String(activeSession));
|
|
1703
2035
|
return;
|
|
1704
2036
|
case 'stash-drop':
|
|
1705
2037
|
void stash?.drop(submission.selector);
|
|
@@ -1707,6 +2039,24 @@ export function apply(ctx, config) {
|
|
|
1707
2039
|
case 'stash-clear':
|
|
1708
2040
|
void stash?.clear();
|
|
1709
2041
|
return;
|
|
2042
|
+
case 'editor':
|
|
2043
|
+
void externalEditor.edit(editor.getExpandedText()).then(text => {
|
|
2044
|
+
if (text !== undefined) {
|
|
2045
|
+
// A gate can open while the child owns the screen, and the bar then
|
|
2046
|
+
// holds somebody's answer: the edited draft waits behind it instead
|
|
2047
|
+
// of being written into a question the reader never answered.
|
|
2048
|
+
if (promptBar.isBorrowed())
|
|
2049
|
+
promptBar.replaceHeld(text);
|
|
2050
|
+
else
|
|
2051
|
+
editor.setText(text);
|
|
2052
|
+
tui.requestRender();
|
|
2053
|
+
}
|
|
2054
|
+
const pendingExit = deferredExit;
|
|
2055
|
+
deferredExit = undefined;
|
|
2056
|
+
if (pendingExit !== undefined)
|
|
2057
|
+
requestExit(pendingExit.code, pendingExit.reason);
|
|
2058
|
+
});
|
|
2059
|
+
return;
|
|
1710
2060
|
case 'status': {
|
|
1711
2061
|
const facts = statusFacts();
|
|
1712
2062
|
const context = facts.contextTokens === undefined
|
|
@@ -1785,17 +2135,17 @@ export function apply(ctx, config) {
|
|
|
1785
2135
|
if (event.type === 'turn/start') {
|
|
1786
2136
|
turnOpen = true;
|
|
1787
2137
|
turnStartedAt = Date.now();
|
|
1788
|
-
|
|
2138
|
+
writeTerminal(windowTitle(process.cwd(), 'working'));
|
|
1789
2139
|
herdr.working();
|
|
1790
2140
|
}
|
|
1791
2141
|
if (event.type === 'turn/end') {
|
|
1792
2142
|
const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
|
|
1793
2143
|
turnOpen = false;
|
|
1794
2144
|
turnStartedAt = undefined;
|
|
1795
|
-
|
|
2145
|
+
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
1796
2146
|
herdr.idle();
|
|
1797
2147
|
if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
|
|
1798
|
-
|
|
2148
|
+
writeTerminal(BELL);
|
|
1799
2149
|
// A job the turn started may have settled while the reader was watching
|
|
1800
2150
|
// something else, and nothing else refreshes a live board.
|
|
1801
2151
|
refreshJobs();
|
|
@@ -1808,7 +2158,7 @@ export function apply(ctx, config) {
|
|
|
1808
2158
|
if (session.id !== viewedSession)
|
|
1809
2159
|
return;
|
|
1810
2160
|
presentScope = ctx.agents?.get(session.id);
|
|
1811
|
-
applyDurable(event);
|
|
2161
|
+
applyDurable(session.id, event);
|
|
1812
2162
|
tui.requestRender();
|
|
1813
2163
|
}));
|
|
1814
2164
|
/**
|
|
@@ -1895,14 +2245,18 @@ export function apply(ctx, config) {
|
|
|
1895
2245
|
return;
|
|
1896
2246
|
refreshJobs();
|
|
1897
2247
|
}) ?? (() => { }));
|
|
2248
|
+
// The transcript belongs to the session on screen, while status, the bell,
|
|
2249
|
+
// and the queue stay with the agent this terminal drives. A live delta or a
|
|
2250
|
+
// failure folded into the wrong model would print one session's words as
|
|
2251
|
+
// another's, and the durable copy that follows would never correct it.
|
|
1898
2252
|
disposers.push(ctx.on('agent/error', payload => {
|
|
1899
|
-
if (payload.agent.id !==
|
|
2253
|
+
if (payload.agent.id !== viewedSession)
|
|
1900
2254
|
return;
|
|
1901
2255
|
model.reportError(payload.error);
|
|
1902
2256
|
tui.requestRender();
|
|
1903
2257
|
}));
|
|
1904
2258
|
disposers.push(ctx.on('agent/assistant-stream', payload => {
|
|
1905
|
-
if (payload.agent.id !==
|
|
2259
|
+
if (payload.agent.id !== viewedSession)
|
|
1906
2260
|
return;
|
|
1907
2261
|
if (payload.frame.type !== 'chunk')
|
|
1908
2262
|
return;
|
|
@@ -1927,6 +2281,42 @@ export function apply(ctx, config) {
|
|
|
1927
2281
|
view.invalidate();
|
|
1928
2282
|
tui.requestRender();
|
|
1929
2283
|
}));
|
|
2284
|
+
/**
|
|
2285
|
+
* The reader's own themes: created, reported, and then watched.
|
|
2286
|
+
*
|
|
2287
|
+
* Created because a directory that is not there is a command that cannot work —
|
|
2288
|
+
* `/theme export` names a path the reader should find where the surface said it
|
|
2289
|
+
* would be. Watched because a theme is a file they are editing by hand, so
|
|
2290
|
+
* saving one is how they ask for it; a session that needed a restart per shade
|
|
2291
|
+
* would make the whole table useless for tuning. Everything unreadable is
|
|
2292
|
+
* reported through the deferred notice rather than stderr, which the alternate
|
|
2293
|
+
* screen is drawn over.
|
|
2294
|
+
*/
|
|
2295
|
+
let reportedThemes = new Set();
|
|
2296
|
+
const reportThemes = () => {
|
|
2297
|
+
// Only what is newly wrong: the watcher re-reads the whole directory on every
|
|
2298
|
+
// save, so an unfixed file would otherwise repeat its complaint on each one,
|
|
2299
|
+
// and a reader who has just been told is not helped by being told again.
|
|
2300
|
+
const problems = themeLibrary.problems();
|
|
2301
|
+
for (const problem of problems) {
|
|
2302
|
+
if (!reportedThemes.has(problem))
|
|
2303
|
+
settingsNotice.post(problem);
|
|
2304
|
+
}
|
|
2305
|
+
reportedThemes = new Set(problems);
|
|
2306
|
+
};
|
|
2307
|
+
for (const problem of ensureThemesHome(themesHome))
|
|
2308
|
+
settingsNotice.post(problem);
|
|
2309
|
+
reportThemes();
|
|
2310
|
+
disposers.push(watchThemes(themesHome, () => {
|
|
2311
|
+
themeLibrary = loadThemes(themesHome, builtinThemesDir());
|
|
2312
|
+
reportThemes();
|
|
2313
|
+
// The file that was just saved may be the theme already in force, so the
|
|
2314
|
+
// table is rebuilt rather than only repainted.
|
|
2315
|
+
applySettings();
|
|
2316
|
+
markdown.invalidate();
|
|
2317
|
+
view.invalidate();
|
|
2318
|
+
tui.requestRender();
|
|
2319
|
+
}));
|
|
1930
2320
|
const degraded = describeMissingOptional(probe);
|
|
1931
2321
|
if (degraded !== undefined)
|
|
1932
2322
|
model.notice(degraded);
|
|
@@ -1968,10 +2358,7 @@ export function apply(ctx, config) {
|
|
|
1968
2358
|
if (!resolved.resumePicker)
|
|
1969
2359
|
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
1970
2360
|
tui.start();
|
|
1971
|
-
|
|
1972
|
-
// a bank that cannot be read should say so before a reader trusts it.
|
|
1973
|
-
void stash?.open();
|
|
1974
|
-
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
2361
|
+
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
1975
2362
|
// Claiming the pane's agent row does not wait for a session: the pane is
|
|
1976
2363
|
// already on screen and already idle, and a session may still be chosen.
|
|
1977
2364
|
herdr.publish();
|