@sagmans/dsh-tui 0.3.0 → 0.5.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/LICENSE +1 -1
- package/README.md +407 -53
- package/cordis.patch.yml +14 -0
- package/lib/agent/present.js +1 -1
- package/lib/agent/present.js.map +1 -1
- package/lib/agent/projections.d.ts +22 -6
- package/lib/agent/projections.d.ts.map +1 -1
- package/lib/agent/projections.js +21 -10
- package/lib/agent/projections.js.map +1 -1
- package/lib/agent/prompt-history.d.ts +89 -0
- package/lib/agent/prompt-history.d.ts.map +1 -0
- package/lib/agent/prompt-history.js +382 -0
- package/lib/agent/prompt-history.js.map +1 -0
- package/lib/agent/status.d.ts +6 -0
- package/lib/agent/status.d.ts.map +1 -1
- package/lib/agent/status.js +1 -0
- package/lib/agent/status.js.map +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/herdr/client.d.ts +57 -0
- package/lib/herdr/client.d.ts.map +1 -0
- package/lib/herdr/client.js +218 -0
- package/lib/herdr/client.js.map +1 -0
- package/lib/herdr/constants.d.ts +93 -0
- package/lib/herdr/constants.d.ts.map +1 -0
- package/lib/herdr/constants.js +79 -0
- package/lib/herdr/constants.js.map +1 -0
- package/lib/herdr/reporter.d.ts +60 -0
- package/lib/herdr/reporter.d.ts.map +1 -0
- package/lib/herdr/reporter.js +217 -0
- package/lib/herdr/reporter.js.map +1 -0
- package/lib/herdr/state.d.ts +57 -0
- package/lib/herdr/state.d.ts.map +1 -0
- package/lib/herdr/state.js +62 -0
- package/lib/herdr/state.js.map +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +700 -81
- 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 +60 -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/ghost.d.ts +46 -0
- package/lib/input/ghost.d.ts.map +1 -0
- package/lib/input/ghost.js +58 -0
- package/lib/input/ghost.js.map +1 -0
- package/lib/input/keymap.d.ts +3 -1
- package/lib/input/keymap.d.ts.map +1 -1
- package/lib/input/keymap.js +16 -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 +34 -1
- package/lib/input/submission.d.ts.map +1 -1
- package/lib/input/submission.js +33 -5
- 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/lock.d.ts +72 -0
- package/lib/stash/lock.d.ts.map +1 -0
- package/lib/stash/lock.js +374 -0
- package/lib/stash/lock.js.map +1 -0
- package/lib/stash/paths.d.ts +22 -0
- package/lib/stash/paths.d.ts.map +1 -0
- package/lib/stash/paths.js +84 -0
- package/lib/stash/paths.js.map +1 -0
- package/lib/stash/private-fs.d.ts +61 -0
- package/lib/stash/private-fs.d.ts.map +1 -0
- package/lib/stash/private-fs.js +379 -0
- package/lib/stash/private-fs.js.map +1 -0
- package/lib/stash/schema.d.ts +56 -0
- package/lib/stash/schema.d.ts.map +1 -0
- package/lib/stash/schema.js +108 -0
- package/lib/stash/schema.js.map +1 -0
- package/lib/stash/store.d.ts +92 -0
- package/lib/stash/store.d.ts.map +1 -0
- package/lib/stash/store.js +262 -0
- package/lib/stash/store.js.map +1 -0
- package/lib/stash.d.ts +122 -0
- package/lib/stash.d.ts.map +1 -0
- package/lib/stash.js +353 -0
- package/lib/stash.js.map +1 -0
- 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 +72 -10
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +162 -45
- 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 +104 -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/todo-guard.d.ts +116 -0
- package/lib/todo-guard.d.ts.map +1 -0
- package/lib/todo-guard.js +259 -0
- package/lib/todo-guard.js.map +1 -0
- package/lib/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 +41 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +100 -4
- 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/history-picker.d.ts +15 -0
- package/lib/ui/history-picker.d.ts.map +1 -0
- package/lib/ui/history-picker.js +26 -0
- package/lib/ui/history-picker.js.map +1 -0
- package/lib/ui/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 +50 -2
- package/lib/ui/picker.d.ts.map +1 -1
- package/lib/ui/picker.js +66 -7
- package/lib/ui/picker.js.map +1 -1
- package/lib/ui/prompt.d.ts +12 -2
- package/lib/ui/prompt.d.ts.map +1 -1
- package/lib/ui/prompt.js +25 -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 +43 -0
- package/lib/ui/stash-picker.d.ts.map +1 -0
- package/lib/ui/stash-picker.js +78 -0
- package/lib/ui/stash-picker.js.map +1 -0
- package/lib/ui/status.d.ts +2 -0
- package/lib/ui/status.d.ts.map +1 -1
- package/lib/ui/status.js +25 -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/lib/work.d.ts +3 -1
- package/lib/work.d.ts.map +1 -1
- package/lib/work.js +9 -1
- package/lib/work.js.map +1 -1
- package/package.json +7 -1
- package/themes/deepseek-blue.yaml +183 -0
- package/themes/violet-orbit.yaml +178 -0
package/lib/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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
|
+
import { createPromptHistory } from "./agent/prompt-history.js";
|
|
7
8
|
import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
|
|
8
9
|
import { createToolPresenter } from "./agent/present.js";
|
|
9
10
|
import { forkPoint } from "./agent/fork.js";
|
|
@@ -14,7 +15,9 @@ import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } f
|
|
|
14
15
|
import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
|
|
15
16
|
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
16
17
|
import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
|
|
18
|
+
import { cancelStep, quitStep } from "./input/cancel.js";
|
|
17
19
|
import { createCompletionProvider } from "./input/completion.js";
|
|
20
|
+
import { ghostSuffix } from "./input/ghost.js";
|
|
18
21
|
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
19
22
|
import { createDeferredNotice } from "./settings-notice.js";
|
|
20
23
|
import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
|
|
@@ -22,23 +25,34 @@ import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
|
|
|
22
25
|
import { resolveConfig } from "./config.js";
|
|
23
26
|
import { FoldCursor } from "./fold-cursor.js";
|
|
24
27
|
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
28
|
+
import { ExternalEditor } from "./terminal/external-editor.js";
|
|
29
|
+
import { installSignalRestore } from "./terminal/signals.js";
|
|
25
30
|
import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
26
31
|
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
27
32
|
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
28
33
|
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
34
|
+
import { sessionStartReason } from "./herdr/state.js";
|
|
35
|
+
import { createHerdrReporter } from "./herdr/reporter.js";
|
|
29
36
|
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
30
37
|
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
31
38
|
import { detectColourMode } from "./theme-capability.js";
|
|
32
39
|
import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
40
|
+
import { toolDisplayFor } from "./tool-display.js";
|
|
33
41
|
import { pendingPrompts } from "./queue.js";
|
|
34
42
|
import { renderThemeTable } from "./theme-command.js";
|
|
35
|
-
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";
|
|
45
|
+
import { resetSequence } from "./theme-tokens.js";
|
|
36
46
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
37
47
|
import { formatTokens } from "./tokens.js";
|
|
38
48
|
import { TranscriptModel } from "./transcript.js";
|
|
39
49
|
import { WorkFold, describeTodos, planSelectedActive, planToggleLine, readPlanState } from "./work.js";
|
|
40
50
|
import { WorkDock } from "./ui/dock.js";
|
|
41
51
|
import { GateInputBar } from "./ui/gate-input.js";
|
|
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";
|
|
42
56
|
import { PromptBar } from "./ui/prompt.js";
|
|
43
57
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
44
58
|
import { createMermaidTransform } from "./ui/mermaid.js";
|
|
@@ -46,6 +60,9 @@ import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, Se
|
|
|
46
60
|
import { QueueBar } from "./ui/queue.js";
|
|
47
61
|
import { StatusBar } from "./ui/status.js";
|
|
48
62
|
import { DEFAULT_VIEW_STATE, TranscriptView } from "./ui/view.js";
|
|
63
|
+
import { PromptStash } from "./stash.js";
|
|
64
|
+
import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
|
|
65
|
+
import { ThemePicker } from "./ui/theme-picker.js";
|
|
49
66
|
export const name = 'tui';
|
|
50
67
|
/**
|
|
51
68
|
* Services the surface cannot run without.
|
|
@@ -65,6 +82,8 @@ const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK
|
|
|
65
82
|
const TITLE_CONCURRENCY = 4;
|
|
66
83
|
/** How often the running-state clock repaints while a turn is open. */
|
|
67
84
|
const STATUS_TICK_MS = 1000;
|
|
85
|
+
/** Reverse video for the cell the cursor occupies, so a ghost keeps the cursor visible. */
|
|
86
|
+
const GHOST_CURSOR_PREFIX = '\u001b[7m';
|
|
68
87
|
function asRecord(value) {
|
|
69
88
|
return typeof value === 'object' && value !== null ? value : undefined;
|
|
70
89
|
}
|
|
@@ -118,19 +137,76 @@ export function apply(ctx, config) {
|
|
|
118
137
|
* late-bound read that the injection point and the change event both use.
|
|
119
138
|
*/
|
|
120
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) => { };
|
|
121
160
|
/**
|
|
122
161
|
* A refused settings edit, kept until the surface can show it: stderr is
|
|
123
162
|
* behind the alt screen, and the section is read on a schedule of its own.
|
|
124
163
|
*/
|
|
125
164
|
const settingsNotice = createDeferredNotice();
|
|
126
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;
|
|
127
182
|
const applyTheme = (section) => {
|
|
128
|
-
|
|
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();
|
|
129
204
|
};
|
|
130
205
|
const theme = {
|
|
131
206
|
get revision() { return current.revision; },
|
|
132
207
|
get color() { return current.color; },
|
|
133
208
|
style: (token, text) => current.style(token, text),
|
|
209
|
+
rich: (raw, options) => current.rich(raw, options),
|
|
134
210
|
cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
|
|
135
211
|
glyph: token => current.glyph(token),
|
|
136
212
|
visible: token => current.visible(token),
|
|
@@ -141,12 +217,13 @@ export function apply(ctx, config) {
|
|
|
141
217
|
markdown: forwardMarkdownTheme(() => current.markdown),
|
|
142
218
|
};
|
|
143
219
|
/**
|
|
144
|
-
* Rows the reader has opened. The model stays untouched; only the view
|
|
145
|
-
* this
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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.
|
|
150
227
|
*/
|
|
151
228
|
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
152
229
|
/**
|
|
@@ -157,11 +234,17 @@ export function apply(ctx, config) {
|
|
|
157
234
|
* follow the edit.
|
|
158
235
|
*/
|
|
159
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;
|
|
160
239
|
/** The keys that start a chord, and how long one waits; the settings document owns all of it. */
|
|
161
240
|
let prefixKeys = DEFAULT_PREFIX_KEYS;
|
|
162
241
|
let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
|
|
163
242
|
/** Every action's keys in force; the settings document owns it and a press reads it live. */
|
|
164
243
|
let keymap = defaultKeymap();
|
|
244
|
+
/** Whether prompts are recorded and offered, and the cap on how many; the settings document owns all three. */
|
|
245
|
+
let historyEnabled = defaultSettings().history.enabled;
|
|
246
|
+
let historyGhost = defaultSettings().history.ghost;
|
|
247
|
+
let historyMaxEntries = defaultSettings().history.maxEntries;
|
|
165
248
|
/**
|
|
166
249
|
* The chord between a prefix and the action that follows it.
|
|
167
250
|
*
|
|
@@ -176,17 +259,22 @@ export function apply(ctx, config) {
|
|
|
176
259
|
*
|
|
177
260
|
* The key toggles nested calls for one session, but a settings edit is a
|
|
178
261
|
* deliberate act, so it re-seeds and becomes the new starting point; the
|
|
179
|
-
* 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.
|
|
180
264
|
*/
|
|
181
265
|
const applyDisplay = (section) => {
|
|
182
266
|
viewState.expandSubCalls = section.subcalls === 'inline';
|
|
183
267
|
mermaidMode = section.mermaid;
|
|
268
|
+
toolDisplay = section.tools;
|
|
184
269
|
prefixKeys = section.prefixes;
|
|
185
270
|
prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
|
|
186
271
|
keymap = section.keymap;
|
|
187
272
|
// Installed where the library reads it, so a remap lands on the next press
|
|
188
273
|
// rather than at the next restart.
|
|
189
274
|
installKeybindings(keymap);
|
|
275
|
+
historyEnabled = section.history.enabled;
|
|
276
|
+
historyGhost = section.history.ghost;
|
|
277
|
+
historyMaxEntries = section.history.maxEntries;
|
|
190
278
|
// A chord armed under the keymap the reader just replaced is not their chord.
|
|
191
279
|
keyChord.disarm();
|
|
192
280
|
};
|
|
@@ -198,9 +286,30 @@ export function apply(ctx, config) {
|
|
|
198
286
|
*/
|
|
199
287
|
const applySettings = () => {
|
|
200
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);
|
|
201
294
|
applyTheme(section);
|
|
202
295
|
applyDisplay(section);
|
|
203
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
|
+
};
|
|
204
313
|
/**
|
|
205
314
|
* Own the section, so the harness validates and persists it for the reader.
|
|
206
315
|
*
|
|
@@ -219,10 +328,16 @@ export function apply(ctx, config) {
|
|
|
219
328
|
scope = settingsCtx.settings.register(TUI_SETTINGS_NAMESPACE, TuiSettingsSchema);
|
|
220
329
|
}
|
|
221
330
|
catch (error) {
|
|
222
|
-
|
|
331
|
+
// Nothing registered means nothing to read, so the reader's switch cannot
|
|
332
|
+
// be confirmed: recording stays off rather than falling back to on.
|
|
333
|
+
historyEnabled = false;
|
|
334
|
+
settingsNotice.post(settingsProblemMessage(error) + ' · prompt history stays off until the section parses');
|
|
223
335
|
return;
|
|
224
336
|
}
|
|
225
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
|
+
};
|
|
226
341
|
applySettings();
|
|
227
342
|
});
|
|
228
343
|
/**
|
|
@@ -235,6 +350,33 @@ export function apply(ctx, config) {
|
|
|
235
350
|
*/
|
|
236
351
|
let presentScope;
|
|
237
352
|
const model = new TranscriptModel(createToolPresenter(ctx, () => presentScope));
|
|
353
|
+
/**
|
|
354
|
+
* The reader's prompt history: global across projects, recorded from every
|
|
355
|
+
* submitted line, and offered back as they type. Built before the bar so its
|
|
356
|
+
* first load cannot race the first suggestion.
|
|
357
|
+
*/
|
|
358
|
+
const promptHistory = createPromptHistory({
|
|
359
|
+
cap: () => historyMaxEntries,
|
|
360
|
+
warn: message => {
|
|
361
|
+
model.notice(message);
|
|
362
|
+
tui.requestRender();
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
/**
|
|
366
|
+
* The dimmed completion drawn from recorded prompts.
|
|
367
|
+
*
|
|
368
|
+
* Colour is the affordance: with styling off the suggestion would be
|
|
369
|
+
* unreadable text the reader could still accept, which is worse than none.
|
|
370
|
+
* The cursor cell is also reversed so the cursor stays visible on the ghost.
|
|
371
|
+
*/
|
|
372
|
+
const ghostBrush = {
|
|
373
|
+
enabled: () => historyEnabled && historyGhost && theme.color && theme.visible('editor.ghost'),
|
|
374
|
+
suggestion: input => ghostSuffix({ entries: promptHistory.entries(), ...input }),
|
|
375
|
+
paint: (text, cell) => {
|
|
376
|
+
const styled = theme.style('editor.ghost', text);
|
|
377
|
+
return cell === 'cursor' ? GHOST_CURSOR_PREFIX + styled + resetSequence() : styled;
|
|
378
|
+
},
|
|
379
|
+
};
|
|
238
380
|
const work = new WorkFold();
|
|
239
381
|
const modelSwitch = new ModelSwitch();
|
|
240
382
|
const agentPresets = createPresetRoster(ctx);
|
|
@@ -267,6 +409,28 @@ export function apply(ctx, config) {
|
|
|
267
409
|
const restore = createRestoreRegistry();
|
|
268
410
|
const terminal = new ProcessTerminal();
|
|
269
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
|
+
};
|
|
270
434
|
/** The session this surface drives: commands, approvals, and the bell belong to it. */
|
|
271
435
|
let activeSession = resolved.sessionId;
|
|
272
436
|
/** The session the transcript is showing, which can be one of its children. */
|
|
@@ -278,14 +442,15 @@ export function apply(ctx, config) {
|
|
|
278
442
|
const view = new TranscriptView(model, theme, markdown, {
|
|
279
443
|
state: () => viewState,
|
|
280
444
|
gate: () => pending?.gate.card(),
|
|
281
|
-
picker: () => pendingPicker?.
|
|
445
|
+
picker: () => pendingPicker?.card?.(),
|
|
282
446
|
keys: () => keymap,
|
|
447
|
+
toolDisplay: tool => toolDisplayFor(toolDisplay, tool),
|
|
283
448
|
});
|
|
284
449
|
// The key map goes in before the bar exists, so no press can be read as the
|
|
285
450
|
// send the library submits on by default. A settings document read after this
|
|
286
451
|
// point installs over it, which is why the bar reads the map per press.
|
|
287
452
|
installKeybindings(keymap);
|
|
288
|
-
const editor = new GateInputBar(tui, theme.editor, () => keymap);
|
|
453
|
+
const editor = new GateInputBar(tui, theme.editor, () => keymap, ghostBrush);
|
|
289
454
|
// Answers are written in the reader's own editor, which is why a question
|
|
290
455
|
// borrows the bar instead of drawing a second one beside it.
|
|
291
456
|
const promptBar = new PromptBar(editor);
|
|
@@ -306,6 +471,9 @@ export function apply(ctx, config) {
|
|
|
306
471
|
jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
|
|
307
472
|
tui.requestRender();
|
|
308
473
|
};
|
|
474
|
+
// The bank is built once the picker exists to answer for it, so the status
|
|
475
|
+
// source is late-bound: the footer must not read a half-constructed stash.
|
|
476
|
+
let stash;
|
|
309
477
|
const statusFacts = createStatusFacts(ctx, {
|
|
310
478
|
sessionId: () => activeSession,
|
|
311
479
|
activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
|
|
@@ -316,12 +484,14 @@ export function apply(ctx, config) {
|
|
|
316
484
|
// a hint stored with the transcript would keep naming the key of the day it
|
|
317
485
|
// was written, and the reader may remap it with the row already on screen.
|
|
318
486
|
back: () => (viewedSession === activeSession ? undefined : backHint(keymap)),
|
|
487
|
+
stash: () => stash?.entryCount,
|
|
319
488
|
});
|
|
320
489
|
const statusBar = new StatusBar(statusFacts, theme);
|
|
321
490
|
const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
|
|
322
491
|
// The queue is read from the agent this terminal drives rather than from the
|
|
323
492
|
// session on screen, because it sits on the editor that submits to that agent.
|
|
324
|
-
const
|
|
493
|
+
const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
|
|
494
|
+
const queue = new QueueBar(queuedPrompts, theme);
|
|
325
495
|
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
326
496
|
const statusTicker = setInterval(() => {
|
|
327
497
|
if (turnOpen)
|
|
@@ -330,55 +500,87 @@ export function apply(ctx, config) {
|
|
|
330
500
|
disposers.push(() => clearInterval(statusTicker));
|
|
331
501
|
// A window that outlived the surface would repaint a screen that is gone.
|
|
332
502
|
disposers.push(() => keyChord.disarm());
|
|
503
|
+
// A containing Herdr is told what this pane is doing; away from one the
|
|
504
|
+
// reporter is inert, so the surface never depends on being multiplexed.
|
|
505
|
+
const herdr = createHerdrReporter();
|
|
506
|
+
// Kept out of the disposal list on purpose: the row is handed back before
|
|
507
|
+
// this stops guarding it, so a host that leaves during the release still
|
|
508
|
+
// releases synchronously.
|
|
509
|
+
const unregisterExit = herdr.registerExitRelease();
|
|
333
510
|
restore.add(() => tui.stop());
|
|
334
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;
|
|
515
|
+
// The pane stops being an agent before the process that claimed it unwinds:
|
|
516
|
+
// a release that ran after the reports were unregistered would race them,
|
|
517
|
+
// and one that never ran would leave a row that reads as a live agent. The
|
|
518
|
+
// reports already on the wire are settled first, because Herdr ignores a
|
|
519
|
+
// release for a pane nothing has claimed yet — the report that followed it
|
|
520
|
+
// would otherwise claim the row back. The promise is returned so a host that
|
|
521
|
+
// waits for teardown waits for the row too, and the exit listener outlives
|
|
522
|
+
// the wait, so one that does not still hands the row back synchronously.
|
|
523
|
+
const released = herdr.release().catch(() => undefined);
|
|
335
524
|
restore.restore();
|
|
336
525
|
for (const dispose of disposers.reverse())
|
|
337
526
|
dispose();
|
|
527
|
+
return released.finally(unregisterExit);
|
|
338
528
|
});
|
|
339
|
-
tui.setLayoutRoot(
|
|
340
|
-
{
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// Work state earns rows only when there is some: a dock that always
|
|
347
|
-
// occupied a row would cost every conversation one line of transcript.
|
|
348
|
-
{ component: dock, basis: 'auto', shrink: 0, minSize: 0 },
|
|
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 },
|
|
354
|
-
{ component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
|
|
355
|
-
]));
|
|
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
|
+
}));
|
|
356
536
|
tui.setFocus(editor);
|
|
357
537
|
const requestExit = (code, reason) => {
|
|
358
538
|
if (exited)
|
|
359
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
|
+
}
|
|
360
547
|
exited = true;
|
|
361
548
|
clearInterval(statusTicker);
|
|
362
|
-
//
|
|
363
|
-
//
|
|
549
|
+
// The screen goes back at once, so leaving feels like leaving; the row goes
|
|
550
|
+
// back behind it. Reports already on the wire are settled first, because
|
|
551
|
+
// Herdr ignores the release of a pane nothing has claimed yet — the report
|
|
552
|
+
// that followed it would otherwise claim the row back during shutdown.
|
|
364
553
|
terminal.write(CLEAR_TITLE);
|
|
365
554
|
restore.restore();
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
555
|
+
void herdr
|
|
556
|
+
.release()
|
|
557
|
+
// Nobody is left to report a release that failed on the way out, and a
|
|
558
|
+
// row that could not be cleared is not a reason to keep the process.
|
|
559
|
+
.catch(() => undefined)
|
|
560
|
+
.finally(() => {
|
|
561
|
+
// Everything below is written after the release: a failure nobody can
|
|
562
|
+
// read is not a failure that was reported.
|
|
563
|
+
if (reason !== undefined)
|
|
564
|
+
terminal.write(`\ndsh-tui: ${reason}\n`);
|
|
565
|
+
// The hint is computed here rather than read from the context because
|
|
566
|
+
// only the surface knows which session it is leaving: a fork or a
|
|
567
|
+
// switch moves it. A run that opened nothing has nothing to offer back:
|
|
568
|
+
// the identity it was launched with names no log, so pointing at it
|
|
569
|
+
// would send the reader to a conversation that does not exist.
|
|
570
|
+
if (sessionOpened)
|
|
571
|
+
terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
|
|
572
|
+
appExit(code);
|
|
573
|
+
});
|
|
379
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') }));
|
|
380
579
|
const openGate = (next) => {
|
|
381
580
|
pending = next;
|
|
581
|
+
// The card's own title names the decision, which is what a reader glancing
|
|
582
|
+
// at a wall of panes needs in order to know which one to open.
|
|
583
|
+
herdr.block(next.gate.card().title);
|
|
382
584
|
// A gate owns the keyboard: the editor must not collect the decision keys.
|
|
383
585
|
editor.disableSubmit = true;
|
|
384
586
|
tui.setFocus(null);
|
|
@@ -390,6 +592,7 @@ export function apply(ctx, config) {
|
|
|
390
592
|
};
|
|
391
593
|
const closeGate = () => {
|
|
392
594
|
pending = undefined;
|
|
595
|
+
herdr.unblock();
|
|
393
596
|
editor.disableSubmit = false;
|
|
394
597
|
// The question is answered or skipped, so the reader gets their prompt back
|
|
395
598
|
// in the bar they left it in.
|
|
@@ -397,10 +600,18 @@ export function apply(ctx, config) {
|
|
|
397
600
|
tui.setFocus(editor);
|
|
398
601
|
tui.requestRender();
|
|
399
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() !== '';
|
|
400
611
|
/**
|
|
401
612
|
* What each key the surface answers itself does; false hands the press back.
|
|
402
613
|
*
|
|
403
|
-
* Keyed by the table's own ids, so a key added to {@link
|
|
614
|
+
* Keyed by the table's own ids, so a key added to {@link SURFACE_ACTIONS}
|
|
404
615
|
* without a handler here fails to compile rather than doing nothing.
|
|
405
616
|
*/
|
|
406
617
|
const surfaceActions = {
|
|
@@ -423,6 +634,10 @@ export function apply(ctx, config) {
|
|
|
423
634
|
void openEffortPicker();
|
|
424
635
|
return true;
|
|
425
636
|
},
|
|
637
|
+
history: () => {
|
|
638
|
+
void openHistoryPicker();
|
|
639
|
+
return true;
|
|
640
|
+
},
|
|
426
641
|
back: () => {
|
|
427
642
|
if (viewedSession !== activeSession)
|
|
428
643
|
void showAgentSession();
|
|
@@ -430,15 +645,57 @@ export function apply(ctx, config) {
|
|
|
430
645
|
},
|
|
431
646
|
interrupt: () => {
|
|
432
647
|
// In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
|
|
433
|
-
// decides
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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;
|
|
437
698
|
}
|
|
438
|
-
agent?.interrupt();
|
|
439
|
-
model.notice('interrupt requested');
|
|
440
|
-
tui.requestRender();
|
|
441
|
-
return true;
|
|
442
699
|
},
|
|
443
700
|
};
|
|
444
701
|
disposers.push(tui.addInputListener(data => {
|
|
@@ -563,7 +820,11 @@ export function apply(ctx, config) {
|
|
|
563
820
|
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
564
821
|
const settlePicker = (id) => {
|
|
565
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?.();
|
|
566
826
|
pendingPicker = undefined;
|
|
827
|
+
herdr.unblock();
|
|
567
828
|
editor.disableSubmit = false;
|
|
568
829
|
tui.setFocus(editor);
|
|
569
830
|
settle?.(id);
|
|
@@ -587,13 +848,99 @@ export function apply(ctx, config) {
|
|
|
587
848
|
return error instanceof Error ? error.message : String(error);
|
|
588
849
|
}
|
|
589
850
|
};
|
|
590
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
};
|
|
881
|
+
// A picker owns the keyboard exactly as a gate does: nothing moves until
|
|
882
|
+
// the reader chooses, so it is the same kind of wait.
|
|
883
|
+
herdr.block(picker.card().title);
|
|
593
884
|
editor.disableSubmit = true;
|
|
594
885
|
tui.setFocus(null);
|
|
595
886
|
tui.requestRender();
|
|
596
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
|
+
};
|
|
918
|
+
/**
|
|
919
|
+
* Reverse search over recorded prompts, seeded with whatever is in the bar.
|
|
920
|
+
*
|
|
921
|
+
* A pick replaces the draft; a cancel leaves it exactly as it was, because the
|
|
922
|
+
* list was opened to look rather than to lose what is already typed.
|
|
923
|
+
*/
|
|
924
|
+
const openHistoryPicker = async () => {
|
|
925
|
+
if (!historyEnabled) {
|
|
926
|
+
model.notice('prompt history is disabled in ' + TUI_SETTINGS_NAMESPACE + ' settings');
|
|
927
|
+
tui.requestRender();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (promptHistory.entries().length === 0) {
|
|
931
|
+
const blocked = promptHistory.blockedReason();
|
|
932
|
+
model.notice(blocked === undefined ? 'no prompt history yet' : 'prompt history is unavailable: ' + blocked);
|
|
933
|
+
tui.requestRender();
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
// The expanded text is what the reader wrote; a large paste sits in the bar
|
|
937
|
+
// as a marker, and seeding with it would filter out the prompt it came from.
|
|
938
|
+
const picked = await openPicker(new HistoryPicker(() => promptHistory.entries(), editor.getExpandedText(), () => keymap));
|
|
939
|
+
if (picked === undefined)
|
|
940
|
+
return;
|
|
941
|
+
editor.setText(picked);
|
|
942
|
+
tui.requestRender();
|
|
943
|
+
};
|
|
597
944
|
/** The roster as the picker paints it, refreshed when the picker opens. */
|
|
598
945
|
let presetRows = [];
|
|
599
946
|
/** Choose the mode a session that has not started yet will run. */
|
|
@@ -603,6 +950,79 @@ export function apply(ctx, config) {
|
|
|
603
950
|
presetRows = await agentPresets.list();
|
|
604
951
|
return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
|
|
605
952
|
};
|
|
953
|
+
/**
|
|
954
|
+
* The prompt bank for the session this surface drives.
|
|
955
|
+
*
|
|
956
|
+
* The surface owns the editor, the picker, and the notices, so the bank is
|
|
957
|
+
* handed the few things it needs to reach them and nothing else: the commands
|
|
958
|
+
* stay free of terminal state and are exercised without one in the tests.
|
|
959
|
+
*/
|
|
960
|
+
stash = new PromptStash({
|
|
961
|
+
getEditorText: () => editor.getExpandedText(),
|
|
962
|
+
setEditorText: text => {
|
|
963
|
+
editor.setText(text);
|
|
964
|
+
tui.requestRender();
|
|
965
|
+
},
|
|
966
|
+
// A question answers in this editor, so a draft written into a borrowed bar
|
|
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,
|
|
971
|
+
notice: message => model.notice(message),
|
|
972
|
+
pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
|
|
973
|
+
confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
|
|
974
|
+
render: () => tui.requestRender(),
|
|
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
|
+
});
|
|
606
1026
|
/** Title the listed sessions without making the reader wait for the slowest log. */
|
|
607
1027
|
const loadTitles = async (history, sessions, titles) => {
|
|
608
1028
|
const queue = [...sessions];
|
|
@@ -674,21 +1094,21 @@ export function apply(ctx, config) {
|
|
|
674
1094
|
*/
|
|
675
1095
|
const foldCursor = new FoldCursor();
|
|
676
1096
|
/** Feed one durable event to the model, unless a fold has already folded it. */
|
|
677
|
-
const applyDurable = (event) => {
|
|
678
|
-
if (foldCursor.accept(event))
|
|
1097
|
+
const applyDurable = (session, event) => {
|
|
1098
|
+
if (foldCursor.accept(session, event))
|
|
679
1099
|
applyEvent(event);
|
|
680
1100
|
};
|
|
681
1101
|
const foldHistory = async (id) => {
|
|
682
1102
|
// Resolved before the fold so every card reads its tool through the scope
|
|
683
1103
|
// that actually registered it; a stored session nobody runs has none.
|
|
684
1104
|
presentScope = ctx.agents?.get(id);
|
|
685
|
-
// Every fold starts a cleared transcript, so
|
|
686
|
-
//
|
|
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.
|
|
687
1107
|
foldCursor.reset();
|
|
688
1108
|
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
689
1109
|
if (inMemory !== undefined) {
|
|
690
1110
|
for (const event of inMemory)
|
|
691
|
-
applyDurable(event);
|
|
1111
|
+
applyDurable(id, event);
|
|
692
1112
|
return inMemory.length;
|
|
693
1113
|
}
|
|
694
1114
|
const history = createSessionHistory(ctx);
|
|
@@ -696,7 +1116,7 @@ export function apply(ctx, config) {
|
|
|
696
1116
|
return 0;
|
|
697
1117
|
const events = await history.read(id);
|
|
698
1118
|
for (const event of events)
|
|
699
|
-
applyDurable(event);
|
|
1119
|
+
applyDurable(id, event);
|
|
700
1120
|
return events.length;
|
|
701
1121
|
};
|
|
702
1122
|
const replayHistory = async (id) => {
|
|
@@ -812,6 +1232,16 @@ export function apply(ctx, config) {
|
|
|
812
1232
|
activeSession = id;
|
|
813
1233
|
viewedSession = id;
|
|
814
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();
|
|
1238
|
+
// The agent's own id is reported rather than the requested one: a resume can
|
|
1239
|
+
// be answered by the session the log actually holds.
|
|
1240
|
+
herdr.session({
|
|
1241
|
+
id: String(handle.sessionId),
|
|
1242
|
+
cwd: process.cwd(),
|
|
1243
|
+
reason: sessionStartReason({ forked: fork !== undefined, resumed: resume }),
|
|
1244
|
+
});
|
|
815
1245
|
// A session with no history to fold still has to present its first live
|
|
816
1246
|
// card through the right scope, so the scope is set before any event can.
|
|
817
1247
|
presentScope = handle.agent;
|
|
@@ -1433,6 +1863,47 @@ export function apply(ctx, config) {
|
|
|
1433
1863
|
...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
|
|
1434
1864
|
});
|
|
1435
1865
|
};
|
|
1866
|
+
/** Show where history is kept, or forget it; the file is global to this machine. */
|
|
1867
|
+
const runHistoryCommand = (argument) => {
|
|
1868
|
+
// The line that asked for this is recorded before the command runs, but that
|
|
1869
|
+
// write rides the store's queue; waiting for it lets the count describe the
|
|
1870
|
+
// file the reader has, not the state before their own line landed.
|
|
1871
|
+
void promptHistory.flush().then(() => {
|
|
1872
|
+
if (argument === '') {
|
|
1873
|
+
const count = promptHistory.entries().length;
|
|
1874
|
+
const blocked = promptHistory.blockedReason();
|
|
1875
|
+
model.notice([
|
|
1876
|
+
count + (count === 1 ? ' prompt recorded' : ' prompts recorded'),
|
|
1877
|
+
promptHistory.path(),
|
|
1878
|
+
blocked === undefined ? undefined : 'writes disabled: ' + blocked,
|
|
1879
|
+
].filter(part => part !== undefined).join(' · '));
|
|
1880
|
+
tui.requestRender();
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (argument !== 'clear') {
|
|
1884
|
+
model.notice('usage: /history shows where history is kept · /history clear forgets every prompt');
|
|
1885
|
+
tui.requestRender();
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
// A refused write cannot remove anything, so saying "forgot 0 prompts"
|
|
1889
|
+
// would describe a successful clear the file never had.
|
|
1890
|
+
const blocked = promptHistory.blockedReason();
|
|
1891
|
+
if (blocked !== undefined) {
|
|
1892
|
+
model.notice('prompt history is unavailable: ' + blocked);
|
|
1893
|
+
tui.requestRender();
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
return promptHistory.clear().then(removed => {
|
|
1897
|
+
model.notice('forgot ' + removed + (removed === 1 ? ' prompt' : ' prompts'));
|
|
1898
|
+
tui.requestRender();
|
|
1899
|
+
});
|
|
1900
|
+
}).catch(error => {
|
|
1901
|
+
// The store keeps what the file still holds, so the reader is told the
|
|
1902
|
+
// clear failed rather than being shown a count that never landed.
|
|
1903
|
+
model.notice('could not clear prompt history: ' + (error instanceof Error ? error.message : String(error)));
|
|
1904
|
+
tui.requestRender();
|
|
1905
|
+
});
|
|
1906
|
+
};
|
|
1436
1907
|
/**
|
|
1437
1908
|
* Carry out one classified line, wherever it was asked for.
|
|
1438
1909
|
*
|
|
@@ -1473,26 +1944,119 @@ export function apply(ctx, config) {
|
|
|
1473
1944
|
case 'todo':
|
|
1474
1945
|
runTodoCommand();
|
|
1475
1946
|
return;
|
|
1476
|
-
case 'theme':
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
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);
|
|
1480
1995
|
return;
|
|
1996
|
+
}
|
|
1481
1997
|
case 'keys': {
|
|
1482
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.
|
|
1483
2001
|
if (submission.argument !== '' && layer === undefined) {
|
|
1484
2002
|
model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
|
|
2003
|
+
tui.requestRender();
|
|
2004
|
+
return;
|
|
1485
2005
|
}
|
|
1486
|
-
|
|
1487
|
-
for (const line of renderKeymap(keymap, layer))
|
|
1488
|
-
model.notice(line);
|
|
1489
|
-
}
|
|
1490
|
-
tui.requestRender();
|
|
2006
|
+
openKeyMap(layer);
|
|
1491
2007
|
return;
|
|
1492
2008
|
}
|
|
1493
2009
|
case 'copy':
|
|
1494
2010
|
runCopyCommand();
|
|
1495
2011
|
return;
|
|
2012
|
+
case 'history':
|
|
2013
|
+
runHistoryCommand(submission.argument);
|
|
2014
|
+
return;
|
|
2015
|
+
case 'stash':
|
|
2016
|
+
// Submitting a command consumes the line it was typed on, so this path
|
|
2017
|
+
// can only carry a draft it was given; parking the bar's own draft is
|
|
2018
|
+
// what the chord is for.
|
|
2019
|
+
if (submission.argument.trim() === '')
|
|
2020
|
+
model.notice('usage: /stash <draft>, or ctrl+x then s to park the editor');
|
|
2021
|
+
else
|
|
2022
|
+
void stash?.stashEditor(submission.argument);
|
|
2023
|
+
return;
|
|
2024
|
+
case 'stash-draft':
|
|
2025
|
+
void stash?.stashEditor();
|
|
2026
|
+
return;
|
|
2027
|
+
case 'stash-pop':
|
|
2028
|
+
void stash?.pop(submission.selector);
|
|
2029
|
+
return;
|
|
2030
|
+
case 'stash-apply':
|
|
2031
|
+
void stash?.apply(submission.selector);
|
|
2032
|
+
return;
|
|
2033
|
+
case 'stash-list':
|
|
2034
|
+
void stash?.list(String(activeSession));
|
|
2035
|
+
return;
|
|
2036
|
+
case 'stash-drop':
|
|
2037
|
+
void stash?.drop(submission.selector);
|
|
2038
|
+
return;
|
|
2039
|
+
case 'stash-clear':
|
|
2040
|
+
void stash?.clear();
|
|
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;
|
|
1496
2060
|
case 'status': {
|
|
1497
2061
|
const facts = statusFacts();
|
|
1498
2062
|
const context = facts.contextTokens === undefined
|
|
@@ -1553,7 +2117,17 @@ export function apply(ctx, config) {
|
|
|
1553
2117
|
}
|
|
1554
2118
|
}
|
|
1555
2119
|
};
|
|
1556
|
-
editor.onSubmit = text =>
|
|
2120
|
+
editor.onSubmit = text => {
|
|
2121
|
+
const submission = classifySubmission(text);
|
|
2122
|
+
if (submission.kind === 'empty')
|
|
2123
|
+
return;
|
|
2124
|
+
// The library's own history feeds the up/down keys; the store below feeds
|
|
2125
|
+
// ghost completion and reverse search, and is global rather than per-session.
|
|
2126
|
+
editor.addToHistory(text);
|
|
2127
|
+
if (historyEnabled)
|
|
2128
|
+
promptHistory.record(text);
|
|
2129
|
+
runSubmission(submission);
|
|
2130
|
+
};
|
|
1557
2131
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
1558
2132
|
// The surface state — activity, timer, title, bell, job board — belongs to
|
|
1559
2133
|
// the agent this terminal drives, even while a child is on screen.
|
|
@@ -1561,15 +2135,17 @@ export function apply(ctx, config) {
|
|
|
1561
2135
|
if (event.type === 'turn/start') {
|
|
1562
2136
|
turnOpen = true;
|
|
1563
2137
|
turnStartedAt = Date.now();
|
|
1564
|
-
|
|
2138
|
+
writeTerminal(windowTitle(process.cwd(), 'working'));
|
|
2139
|
+
herdr.working();
|
|
1565
2140
|
}
|
|
1566
2141
|
if (event.type === 'turn/end') {
|
|
1567
2142
|
const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
|
|
1568
2143
|
turnOpen = false;
|
|
1569
2144
|
turnStartedAt = undefined;
|
|
1570
|
-
|
|
2145
|
+
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
2146
|
+
herdr.idle();
|
|
1571
2147
|
if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
|
|
1572
|
-
|
|
2148
|
+
writeTerminal(BELL);
|
|
1573
2149
|
// A job the turn started may have settled while the reader was watching
|
|
1574
2150
|
// something else, and nothing else refreshes a live board.
|
|
1575
2151
|
refreshJobs();
|
|
@@ -1582,7 +2158,7 @@ export function apply(ctx, config) {
|
|
|
1582
2158
|
if (session.id !== viewedSession)
|
|
1583
2159
|
return;
|
|
1584
2160
|
presentScope = ctx.agents?.get(session.id);
|
|
1585
|
-
applyDurable(event);
|
|
2161
|
+
applyDurable(session.id, event);
|
|
1586
2162
|
tui.requestRender();
|
|
1587
2163
|
}));
|
|
1588
2164
|
/**
|
|
@@ -1669,14 +2245,18 @@ export function apply(ctx, config) {
|
|
|
1669
2245
|
return;
|
|
1670
2246
|
refreshJobs();
|
|
1671
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.
|
|
1672
2252
|
disposers.push(ctx.on('agent/error', payload => {
|
|
1673
|
-
if (payload.agent.id !==
|
|
2253
|
+
if (payload.agent.id !== viewedSession)
|
|
1674
2254
|
return;
|
|
1675
2255
|
model.reportError(payload.error);
|
|
1676
2256
|
tui.requestRender();
|
|
1677
2257
|
}));
|
|
1678
2258
|
disposers.push(ctx.on('agent/assistant-stream', payload => {
|
|
1679
|
-
if (payload.agent.id !==
|
|
2259
|
+
if (payload.agent.id !== viewedSession)
|
|
1680
2260
|
return;
|
|
1681
2261
|
if (payload.frame.type !== 'chunk')
|
|
1682
2262
|
return;
|
|
@@ -1701,6 +2281,42 @@ export function apply(ctx, config) {
|
|
|
1701
2281
|
view.invalidate();
|
|
1702
2282
|
tui.requestRender();
|
|
1703
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
|
+
}));
|
|
1704
2320
|
const degraded = describeMissingOptional(probe);
|
|
1705
2321
|
if (degraded !== undefined)
|
|
1706
2322
|
model.notice(degraded);
|
|
@@ -1742,7 +2358,10 @@ export function apply(ctx, config) {
|
|
|
1742
2358
|
if (!resolved.resumePicker)
|
|
1743
2359
|
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
1744
2360
|
tui.start();
|
|
1745
|
-
|
|
2361
|
+
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
2362
|
+
// Claiming the pane's agent row does not wait for a session: the pane is
|
|
2363
|
+
// already on screen and already idle, and a session may still be chosen.
|
|
2364
|
+
herdr.publish();
|
|
1746
2365
|
// A refused settings edit is only visible now that the surface owns the
|
|
1747
2366
|
// screen; whatever the scope found before this point prints here instead.
|
|
1748
2367
|
settingsNotice.open(message => model.notice(message));
|