@sagmans/dsh-tui 0.6.0 → 0.7.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/.agents/skills/dsh-tui-dogfood/SKILL.md +83 -0
- package/.agents/skills/dsh-tui-dogfood/references/home-state.md +78 -0
- package/.agents/skills/dsh-tui-dogfood/scripts/clone-links.mjs +133 -0
- package/.agents/skills/dsh-tui-dogfood/scripts/run-plugin-from-worktree.sh +417 -0
- package/.agents/skills/dsh-tui-update-models/SKILL.md +121 -0
- package/.agents/skills/dsh-tui-update-models/references/model-wiring.md +162 -0
- package/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs +178 -0
- package/README.md +99 -5
- package/lib/agent/present.d.ts.map +1 -1
- package/lib/agent/present.js +2 -1
- package/lib/agent/present.js.map +1 -1
- package/lib/cards/composition.d.ts +68 -0
- package/lib/cards/composition.d.ts.map +1 -0
- package/lib/cards/composition.js +117 -0
- package/lib/cards/composition.js.map +1 -0
- package/lib/cards/presenter.d.ts +23 -0
- package/lib/cards/presenter.d.ts.map +1 -0
- package/lib/cards/presenter.js +394 -0
- package/lib/cards/presenter.js.map +1 -0
- package/lib/cards/preview.d.ts +42 -0
- package/lib/cards/preview.d.ts.map +1 -0
- package/lib/cards/preview.js +51 -0
- package/lib/cards/preview.js.map +1 -0
- package/lib/cards.d.ts +12 -120
- package/lib/cards.d.ts.map +1 -1
- package/lib/cards.js +4 -556
- package/lib/cards.js.map +1 -1
- package/lib/compat/section.d.ts +29 -0
- package/lib/compat/section.d.ts.map +1 -0
- package/lib/compat/section.js +82 -0
- package/lib/compat/section.js.map +1 -0
- package/lib/config.d.ts +27 -2
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +61 -1
- package/lib/config.js.map +1 -1
- package/lib/contracts.d.ts +28 -0
- package/lib/contracts.d.ts.map +1 -1
- package/lib/fold-cursor.d.ts +8 -0
- package/lib/fold-cursor.d.ts.map +1 -1
- package/lib/fold-cursor.js +18 -0
- package/lib/fold-cursor.js.map +1 -1
- package/lib/gates/question-card.d.ts +77 -0
- package/lib/gates/question-card.d.ts.map +1 -0
- package/lib/gates/question-card.js +113 -0
- package/lib/gates/question-card.js.map +1 -0
- package/lib/gates/questions.d.ts +127 -0
- package/lib/gates/questions.d.ts.map +1 -0
- package/lib/gates/questions.js +418 -0
- package/lib/gates/questions.js.map +1 -0
- package/lib/gates.d.ts +1 -113
- package/lib/gates.d.ts.map +1 -1
- package/lib/gates.js +1 -484
- package/lib/gates.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +343 -2602
- package/lib/index.js.map +1 -1
- package/lib/input/action-catalog.d.ts +74 -0
- package/lib/input/action-catalog.d.ts.map +1 -0
- package/lib/input/action-catalog.js +137 -0
- package/lib/input/action-catalog.js.map +1 -0
- package/lib/input/actions.d.ts +5 -81
- package/lib/input/actions.d.ts.map +1 -1
- package/lib/input/actions.js +4 -625
- package/lib/input/actions.js.map +1 -1
- package/lib/input/completion.d.ts +1 -1
- package/lib/input/completion.d.ts.map +1 -1
- package/lib/input/completion.js +2 -1
- package/lib/input/completion.js.map +1 -1
- package/lib/input/file-index.d.ts +40 -0
- package/lib/input/file-index.d.ts.map +1 -0
- package/lib/input/file-index.js +141 -0
- package/lib/input/file-index.js.map +1 -0
- package/lib/input/file-search.d.ts +1 -38
- package/lib/input/file-search.d.ts.map +1 -1
- package/lib/input/file-search.js +1 -487
- package/lib/input/file-search.js.map +1 -1
- package/lib/input/key-press.d.ts +57 -0
- package/lib/input/key-press.d.ts.map +1 -0
- package/lib/input/key-press.js +218 -0
- package/lib/input/key-press.js.map +1 -0
- package/lib/input/keymap-conflicts.d.ts +59 -0
- package/lib/input/keymap-conflicts.d.ts.map +1 -0
- package/lib/input/keymap-conflicts.js +292 -0
- package/lib/input/keymap-conflicts.js.map +1 -0
- package/lib/input/keymap-settings.js +1 -1
- package/lib/input/keymap-settings.js.map +1 -1
- package/lib/input/keymap.d.ts.map +1 -1
- package/lib/input/keymap.js +3 -1
- package/lib/input/keymap.js.map +1 -1
- package/lib/input/workspace-files.d.ts +10 -0
- package/lib/input/workspace-files.d.ts.map +1 -0
- package/lib/input/workspace-files.js +359 -0
- package/lib/input/workspace-files.js.map +1 -0
- package/lib/install-skills.d.ts +20 -0
- package/lib/install-skills.d.ts.map +1 -0
- package/lib/install-skills.js +198 -0
- package/lib/install-skills.js.map +1 -0
- package/lib/keys-command.d.ts +2 -1
- package/lib/keys-command.d.ts.map +1 -1
- package/lib/keys-command.js +2 -1
- package/lib/keys-command.js.map +1 -1
- package/lib/model-list.d.ts +37 -0
- package/lib/model-list.d.ts.map +1 -0
- package/lib/model-list.js +60 -0
- package/lib/model-list.js.map +1 -0
- package/lib/startup.d.ts +8 -0
- package/lib/startup.d.ts.map +1 -1
- package/lib/startup.js +120 -24
- package/lib/startup.js.map +1 -1
- package/lib/subagents.d.ts +7 -3
- package/lib/subagents.d.ts.map +1 -1
- package/lib/subagents.js +28 -4
- package/lib/subagents.js.map +1 -1
- package/lib/surface/appearance-preferences.d.ts +26 -0
- package/lib/surface/appearance-preferences.d.ts.map +1 -0
- package/lib/surface/appearance-preferences.js +139 -0
- package/lib/surface/appearance-preferences.js.map +1 -0
- package/lib/surface/appearance.d.ts +87 -0
- package/lib/surface/appearance.d.ts.map +1 -0
- package/lib/surface/appearance.js +353 -0
- package/lib/surface/appearance.js.map +1 -0
- package/lib/surface/background-work.d.ts +49 -0
- package/lib/surface/background-work.d.ts.map +1 -0
- package/lib/surface/background-work.js +146 -0
- package/lib/surface/background-work.js.map +1 -0
- package/lib/surface/commands.d.ts +80 -0
- package/lib/surface/commands.d.ts.map +1 -0
- package/lib/surface/commands.js +364 -0
- package/lib/surface/commands.js.map +1 -0
- package/lib/surface/modal-input.d.ts +58 -0
- package/lib/surface/modal-input.d.ts.map +1 -0
- package/lib/surface/modal-input.js +237 -0
- package/lib/surface/modal-input.js.map +1 -0
- package/lib/surface/model-choice.d.ts +54 -0
- package/lib/surface/model-choice.d.ts.map +1 -0
- package/lib/surface/model-choice.js +325 -0
- package/lib/surface/model-choice.js.map +1 -0
- package/lib/surface/preset-choice.d.ts +78 -0
- package/lib/surface/preset-choice.d.ts.map +1 -0
- package/lib/surface/preset-choice.js +163 -0
- package/lib/surface/preset-choice.js.map +1 -0
- package/lib/surface/prompt-input.d.ts +97 -0
- package/lib/surface/prompt-input.d.ts.map +1 -0
- package/lib/surface/prompt-input.js +215 -0
- package/lib/surface/prompt-input.js.map +1 -0
- package/lib/surface/prompt-memory.d.ts +76 -0
- package/lib/surface/prompt-memory.d.ts.map +1 -0
- package/lib/surface/prompt-memory.js +167 -0
- package/lib/surface/prompt-memory.js.map +1 -0
- package/lib/surface/session-lifecycle.d.ts +120 -0
- package/lib/surface/session-lifecycle.d.ts.map +1 -0
- package/lib/surface/session-lifecycle.js +321 -0
- package/lib/surface/session-lifecycle.js.map +1 -0
- package/lib/surface/session-picker.d.ts +39 -0
- package/lib/surface/session-picker.d.ts.map +1 -0
- package/lib/surface/session-picker.js +107 -0
- package/lib/surface/session-picker.js.map +1 -0
- package/lib/surface/session-view.d.ts +86 -0
- package/lib/surface/session-view.d.ts.map +1 -0
- package/lib/surface/session-view.js +215 -0
- package/lib/surface/session-view.js.map +1 -0
- package/lib/surface/staged-turns.d.ts +81 -0
- package/lib/surface/staged-turns.d.ts.map +1 -0
- package/lib/surface/staged-turns.js +206 -0
- package/lib/surface/staged-turns.js.map +1 -0
- package/lib/surface/terminal-lifecycle.d.ts +76 -0
- package/lib/surface/terminal-lifecycle.d.ts.map +1 -0
- package/lib/surface/terminal-lifecycle.js +222 -0
- package/lib/surface/terminal-lifecycle.js.map +1 -0
- package/lib/terminal-text/scan.d.ts +44 -0
- package/lib/terminal-text/scan.d.ts.map +1 -0
- package/lib/terminal-text/scan.js +195 -0
- package/lib/terminal-text/scan.js.map +1 -0
- package/lib/terminal-text/sgr.d.ts +32 -0
- package/lib/terminal-text/sgr.d.ts.map +1 -0
- package/lib/terminal-text/sgr.js +203 -0
- package/lib/terminal-text/sgr.js.map +1 -0
- package/lib/terminal-text.d.ts +1 -1
- package/lib/terminal-text.d.ts.map +1 -1
- package/lib/terminal-text.js +2 -379
- package/lib/terminal-text.js.map +1 -1
- package/lib/theme-command.js +2 -1
- package/lib/theme-command.js.map +1 -1
- package/lib/theme-defaults.d.ts +64 -0
- package/lib/theme-defaults.d.ts.map +1 -0
- package/lib/theme-defaults.js +248 -0
- package/lib/theme-defaults.js.map +1 -0
- package/lib/theme-resolver.d.ts +36 -0
- package/lib/theme-resolver.d.ts.map +1 -0
- package/lib/theme-resolver.js +112 -0
- package/lib/theme-resolver.js.map +1 -0
- package/lib/theme-schema.d.ts.map +1 -1
- package/lib/theme-schema.js +3 -2
- package/lib/theme-schema.js.map +1 -1
- package/lib/theme-settings.d.ts +5 -7
- package/lib/theme-settings.d.ts.map +1 -1
- package/lib/theme-settings.js +27 -19
- package/lib/theme-settings.js.map +1 -1
- package/lib/theme-tokens.d.ts +0 -89
- package/lib/theme-tokens.d.ts.map +1 -1
- package/lib/theme-tokens.js +0 -356
- package/lib/theme-tokens.js.map +1 -1
- package/lib/theme.d.ts +1 -1
- package/lib/theme.d.ts.map +1 -1
- package/lib/theme.js +2 -1
- package/lib/theme.js.map +1 -1
- package/lib/tool-display.d.ts.map +1 -1
- package/lib/tool-display.js +2 -1
- package/lib/tool-display.js.map +1 -1
- package/lib/transcript/message-content.d.ts +21 -0
- package/lib/transcript/message-content.d.ts.map +1 -0
- package/lib/transcript/message-content.js +61 -0
- package/lib/transcript/message-content.js.map +1 -0
- package/lib/transcript/tool-calls.d.ts +106 -0
- package/lib/transcript/tool-calls.d.ts.map +1 -0
- package/lib/transcript/tool-calls.js +254 -0
- package/lib/transcript/tool-calls.js.map +1 -0
- package/lib/transcript.d.ts +14 -55
- package/lib/transcript.d.ts.map +1 -1
- package/lib/transcript.js +31 -253
- package/lib/transcript.js.map +1 -1
- package/lib/ui/dock.d.ts +14 -2
- package/lib/ui/dock.d.ts.map +1 -1
- package/lib/ui/dock.js +52 -10
- package/lib/ui/dock.js.map +1 -1
- package/lib/ui/editor.d.ts.map +1 -1
- package/lib/ui/editor.js +2 -1
- package/lib/ui/editor.js.map +1 -1
- package/lib/ui/keymap-picker.d.ts +2 -1
- package/lib/ui/keymap-picker.d.ts.map +1 -1
- package/lib/ui/keymap-picker.js +1 -0
- package/lib/ui/keymap-picker.js.map +1 -1
- package/lib/ui/view/gate-card.d.ts +21 -0
- package/lib/ui/view/gate-card.d.ts.map +1 -0
- package/lib/ui/view/gate-card.js +104 -0
- package/lib/ui/view/gate-card.js.map +1 -0
- package/lib/ui/view/tool-card.d.ts +138 -0
- package/lib/ui/view/tool-card.d.ts.map +1 -0
- package/lib/ui/view/tool-card.js +413 -0
- package/lib/ui/view/tool-card.js.map +1 -0
- package/lib/ui/view/transcript-message.d.ts +62 -0
- package/lib/ui/view/transcript-message.d.ts.map +1 -0
- package/lib/ui/view/transcript-message.js +158 -0
- package/lib/ui/view/transcript-message.js.map +1 -0
- package/lib/ui/view.d.ts +80 -220
- package/lib/ui/view.d.ts.map +1 -1
- package/lib/ui/view.js +81 -675
- package/lib/ui/view.js.map +1 -1
- package/package.json +3 -1
package/lib/index.js
CHANGED
|
@@ -1,71 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { ProcessTerminal, ScrollView, isKeyRelease, matchesKey } from '@earendil-works/pi-tui';
|
|
5
|
-
import { startAgent } from "./agent/host.js";
|
|
6
|
-
import { PICKER_LIMIT, createSessionHistory, presetOfStoredSession, readSessionTitle, } from "./agent/history.js";
|
|
7
|
-
import { createPromptHistory } from "./agent/prompt-history.js";
|
|
8
|
-
import { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
|
|
9
|
-
import { createToolPresenter } from "./agent/present.js";
|
|
10
|
-
import { forkPoint } from "./agent/fork.js";
|
|
11
|
-
import { hiddenTail, NO_UNDO, redoStep, resetUndo, turnsOf, UNDO_TURN_SETTLE_MS, undoStep } from "./agent/undo.js";
|
|
12
|
-
import { PROFILE_NAME, resumeHint } from "./identity.js";
|
|
1
|
+
import { ScrollView } from '@earendil-works/pi-tui';
|
|
2
|
+
import { createSessionHistory, presetOfStoredSession } from "./agent/history.js";
|
|
3
|
+
import { createPresetRoster } from "./agent/presets.js";
|
|
13
4
|
import { createStatusFacts } from "./agent/status.js";
|
|
14
|
-
import { ModelSwitch, createModelCatalog, parseModelArgument, readModelRouteKey } from "./agent/model.js";
|
|
15
|
-
import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
|
|
16
|
-
import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
|
|
17
5
|
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import { createFileIndex } from "./input/file-search.js";
|
|
22
|
-
import { ghostSuffix } from "./input/ghost.js";
|
|
23
|
-
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
24
|
-
import { createDeferredNotice } from "./settings-notice.js";
|
|
25
|
-
import { ChordReader, DEFAULT_PREFIX_KEYS, DEFAULT_PREFIX_WINDOW_S, chordBindings, chordKeysLine, installKeybindings, surfaceKeysLine, } from "./input/keymap.js";
|
|
26
|
-
import { defaultKeymap, hintKeys, surfaceBindings } from "./input/actions.js";
|
|
27
|
-
import { resolveConfig } from "./config.js";
|
|
28
|
-
import { FoldCursor } from "./fold-cursor.js";
|
|
29
|
-
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
30
|
-
import { ExternalEditor } from "./terminal/external-editor.js";
|
|
31
|
-
import { installSignalRestore } from "./terminal/signals.js";
|
|
32
|
-
import { WarningSafeTui } from "./terminal/warning-screen.js";
|
|
33
|
-
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
34
|
-
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
35
|
-
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
36
|
-
import { driverReportFor, sessionStartReason } from "./herdr/state.js";
|
|
37
|
-
import { createHerdrReporter } from "./herdr/reporter.js";
|
|
38
|
-
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
39
|
-
import { createTheme, forwardEditorTheme, forwardMarkdownTheme } from "./theme.js";
|
|
40
|
-
import { detectColourMode } from "./theme-capability.js";
|
|
41
|
-
import { defaultSettings, readScope, settingsProblemMessage, toOverrides, TUI_SETTINGS_NAMESPACE, TuiSettingsSchema } from "./theme-settings.js";
|
|
6
|
+
import { hasLiveRowSettings, readRowSettings, resolveConfig } from "./config.js";
|
|
7
|
+
export { Config } from "./config.js";
|
|
8
|
+
import { windowTitle } from "./terminal/title.js";
|
|
42
9
|
import { toolDisplayFor } from "./tool-display.js";
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import { DEFAULT_THEME, builtinNames, builtinThemesDir, ensureThemesHome, exportTheme, loadThemes, themesHomeDir, watchThemes } from "./theme-files.js";
|
|
46
|
-
import { KEYMAP_LAYERS, keymapLayer } from "./keys-command.js";
|
|
47
|
-
import { resetSequence } from "./theme-tokens.js";
|
|
10
|
+
import { runModelList } from "./model-list.js";
|
|
11
|
+
import { LIST_MODELS_SERVICE } from "./startup.js";
|
|
48
12
|
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
13
|
+
import { createAppearance } from "./surface/appearance.js";
|
|
14
|
+
import { createBackgroundWork } from "./surface/background-work.js";
|
|
15
|
+
import { createCommands } from "./surface/commands.js";
|
|
16
|
+
import { createModalInput } from "./surface/modal-input.js";
|
|
17
|
+
import { createModelChoice } from "./surface/model-choice.js";
|
|
18
|
+
import { createPromptInput } from "./surface/prompt-input.js";
|
|
19
|
+
import { createPromptMemory } from "./surface/prompt-memory.js";
|
|
20
|
+
import { createPresetChoice } from "./surface/preset-choice.js";
|
|
21
|
+
import { createSessionLifecycle } from "./surface/session-lifecycle.js";
|
|
22
|
+
import { backHint, createSessionView } from "./surface/session-view.js";
|
|
23
|
+
import { createStagedTurns } from "./surface/staged-turns.js";
|
|
24
|
+
import { createTerminalLifecycle } from "./surface/terminal-lifecycle.js";
|
|
53
25
|
import { WorkDock } from "./ui/dock.js";
|
|
54
26
|
import { GateInputBar } from "./ui/gate-input.js";
|
|
55
|
-
import { HistoryPicker } from "./ui/history-picker.js";
|
|
56
27
|
import { surfaceLayout } from "./ui/layout.js";
|
|
57
|
-
import { KeymapPicker } from "./ui/keymap-picker.js";
|
|
58
|
-
import { PickerPopup, POPUP_MAX_HEIGHT, popupWidth } from "./ui/picker-card.js";
|
|
59
28
|
import { PromptBar } from "./ui/prompt.js";
|
|
60
29
|
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
61
30
|
import { createMermaidTransform } from "./ui/mermaid.js";
|
|
62
|
-
import { EffortPicker, ModelPicker, PROVIDER_DEFAULT_EFFORT_ID, PresetPicker, SessionPicker, effortChoices, } from "./ui/picker.js";
|
|
63
31
|
import { QueueBar } from "./ui/queue.js";
|
|
64
32
|
import { StatusBar } from "./ui/status.js";
|
|
65
|
-
import {
|
|
66
|
-
import { PromptStash } from "./stash.js";
|
|
67
|
-
import { confirmedClear, StashConfirmPicker, StashPicker } from "./ui/stash-picker.js";
|
|
68
|
-
import { ThemePicker } from "./ui/theme-picker.js";
|
|
33
|
+
import { TranscriptView } from "./ui/view.js";
|
|
69
34
|
export const name = 'tui';
|
|
70
35
|
/**
|
|
71
36
|
* Services the surface cannot run without.
|
|
@@ -75,21 +40,6 @@ export const name = 'tui';
|
|
|
75
40
|
* every card to a bare generic row.
|
|
76
41
|
*/
|
|
77
42
|
export const inject = ['agents', 'tools'];
|
|
78
|
-
/** One second in the unit a chord window is scheduled in. */
|
|
79
|
-
const MS_PER_SECOND = 1000;
|
|
80
|
-
/** What the back hint names when the reader has unbound the key it would advertise. */
|
|
81
|
-
const BACK_HINT_FALLBACK = 'ctrl+b';
|
|
82
|
-
/** What the reader presses to leave a view they did not open. */
|
|
83
|
-
const backHint = (map) => `${hintKeys(map, 'surface.back') || BACK_HINT_FALLBACK} returns to this session`;
|
|
84
|
-
/** Stored sessions titled at once when the picker opens. */
|
|
85
|
-
const TITLE_CONCURRENCY = 4;
|
|
86
|
-
/** How often the running-state clock repaints while a turn is open. */
|
|
87
|
-
const STATUS_TICK_MS = 1000;
|
|
88
|
-
/** Reverse video for the cell the cursor occupies, so a ghost keeps the cursor visible. */
|
|
89
|
-
const GHOST_CURSOR_PREFIX = '\u001b[7m';
|
|
90
|
-
function asRecord(value) {
|
|
91
|
-
return typeof value === 'object' && value !== null ? value : undefined;
|
|
92
|
-
}
|
|
93
43
|
/**
|
|
94
44
|
* Refuse to run without a real terminal.
|
|
95
45
|
*
|
|
@@ -112,6 +62,24 @@ export function assertInteractiveTerminal(input = {}) {
|
|
|
112
62
|
* the composition rows around it.
|
|
113
63
|
*/
|
|
114
64
|
export function apply(ctx, config) {
|
|
65
|
+
const appExit = ctx.get('appExit');
|
|
66
|
+
if (appExit === undefined) {
|
|
67
|
+
throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
|
|
68
|
+
}
|
|
69
|
+
// The listing is a pipeable dump, not a surface: it answers before the
|
|
70
|
+
// config, the composition probe, and the TTY gate, claims no terminal, and
|
|
71
|
+
// leaves the exit code to the command that owns the process.
|
|
72
|
+
if (ctx.get(LIST_MODELS_SERVICE) === true) {
|
|
73
|
+
// Provider rows mount after this one, so an immediate read would report an
|
|
74
|
+
// empty catalog; the launcher's committed startup is the first moment the
|
|
75
|
+
// composed adapters exist, and a failed boot must not print a listing.
|
|
76
|
+
const appReady = ctx.get('appReady');
|
|
77
|
+
if (appReady === undefined) {
|
|
78
|
+
throw new Error('dsh-tui: the dsh launcher must provide appReady; start this surface with dsh --profile tui');
|
|
79
|
+
}
|
|
80
|
+
appReady.onReady(() => { void runModelList(ctx, appExit); });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
115
83
|
const resolved = resolveConfig(config);
|
|
116
84
|
assertInteractiveTerminal();
|
|
117
85
|
// Fail before taking the screen over: a missing row is a composition mistake,
|
|
@@ -119,441 +87,305 @@ export function apply(ctx, config) {
|
|
|
119
87
|
const probe = probeComposition(service => ctx.get(service) !== undefined);
|
|
120
88
|
if (probe.missingRequired.length > 0)
|
|
121
89
|
throw new Error(describeMissingRequired(probe));
|
|
122
|
-
const appExit = ctx.get('appExit');
|
|
123
|
-
if (appExit === undefined) {
|
|
124
|
-
throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* The surface's appearance, rebuilt whenever the reader's settings change.
|
|
128
|
-
*
|
|
129
|
-
* Renderers hold this object for the life of the session, so the current
|
|
130
|
-
* theme is swapped *behind* a stable delegate rather than reassigned: every
|
|
131
|
-
* row then reads one whole table, and a repaint can never observe a
|
|
132
|
-
* half-applied one. `--no-color` still outranks anything configured.
|
|
133
|
-
*/
|
|
134
|
-
const themeMode = () => (resolved.color ? detectColourMode(process.env) : 'none');
|
|
135
|
-
/**
|
|
136
|
-
* The reader's section, or nothing when the service is not mounted.
|
|
137
|
-
*
|
|
138
|
-
* The service is only readable inside an `inject` scope — asking for it
|
|
139
|
-
* outside one is a composition error, not a missing value — so this stays a
|
|
140
|
-
* late-bound read that the injection point and the change event both use.
|
|
141
|
-
*/
|
|
142
|
-
let readSection = () => defaultSettings();
|
|
143
|
-
/**
|
|
144
|
-
* The themes this session can draw.
|
|
145
|
-
*
|
|
146
|
-
* Read from disk rather than compiled in, so a file the reader saves is a theme
|
|
147
|
-
* the moment the save lands. Held beside the settings rather than inside them
|
|
148
|
-
* because the two answer different questions: the section says which name the
|
|
149
|
-
* reader chose, and this says which names exist — including the one they typed
|
|
150
|
-
* into the directory a second ago.
|
|
151
|
-
*/
|
|
152
|
-
const themesHome = themesHomeDir();
|
|
153
|
-
let themeLibrary = loadThemes(themesHome, builtinThemesDir());
|
|
154
|
-
/**
|
|
155
|
-
* Persist a theme choice, replaced once the section is registered.
|
|
156
|
-
*
|
|
157
|
-
* A theme picked mid-session has to outlive it, so the choice is written
|
|
158
|
-
* through the same scope the reader's document is read from instead of kept
|
|
159
|
-
* in memory: the host persists it, and the change comes back through
|
|
160
|
-
* `settings/updated` like any other edit — which is what restyles the screen.
|
|
161
|
-
*/
|
|
162
|
-
let chooseTheme = (_name) => { };
|
|
163
|
-
/**
|
|
164
|
-
* A refused settings edit, kept until the surface can show it: stderr is
|
|
165
|
-
* behind the alt screen, and the section is read on a schedule of its own.
|
|
166
|
-
*/
|
|
167
|
-
const settingsNotice = createDeferredNotice();
|
|
168
|
-
let current = createTheme(themeMode());
|
|
169
|
-
/**
|
|
170
|
-
* The section as it was last read.
|
|
171
|
-
*
|
|
172
|
-
* Held so a preview can rebuild the table without reading the document again:
|
|
173
|
-
* the picker repaints on every arrow key, and a read there would report a
|
|
174
|
-
* refused section once per press.
|
|
175
|
-
*/
|
|
176
|
-
let appliedSection;
|
|
177
|
-
/**
|
|
178
|
-
* The theme the picker's cursor is on, while its list is open.
|
|
179
|
-
*
|
|
180
|
-
* A preview is a name and nothing else — the document is not written — so
|
|
181
|
-
* leaving the list is one more rebuild from the section, and a session that
|
|
182
|
-
* ends mid-preview has still persisted only what the reader chose.
|
|
183
|
-
*/
|
|
184
|
-
let previewTheme;
|
|
185
|
-
const applyTheme = (section) => {
|
|
186
|
-
// The row under the cursor outranks the document while a list is open, so a
|
|
187
|
-
// theme is judged on the reader's own transcript before it is taken.
|
|
188
|
-
current = createTheme(themeMode(), toOverrides({ ...section, theme: previewTheme ?? section.theme }, themeLibrary));
|
|
189
|
-
};
|
|
190
|
-
/**
|
|
191
|
-
* Show a theme without choosing it.
|
|
192
|
-
*
|
|
193
|
-
* The list's cursor is the preview: every row paints the surface and writes
|
|
194
|
-
* nothing, so two themes are compared on the reader's own transcript rather
|
|
195
|
-
* than on a name. Clearing the name puts back what the document says, which is
|
|
196
|
-
* what cancelling the list has to leave behind.
|
|
197
|
-
*/
|
|
198
|
-
const showTheme = (name) => {
|
|
199
|
-
previewTheme = name;
|
|
200
|
-
// Nothing to rebuild from before the first read, and nothing to show either.
|
|
201
|
-
if (appliedSection === undefined)
|
|
202
|
-
return;
|
|
203
|
-
applyTheme(appliedSection);
|
|
204
|
-
markdown.invalidate();
|
|
205
|
-
view.invalidate();
|
|
206
|
-
tui.requestRender();
|
|
207
|
-
};
|
|
208
|
-
const theme = {
|
|
209
|
-
get revision() { return current.revision; },
|
|
210
|
-
get color() { return current.color; },
|
|
211
|
-
style: (token, text) => current.style(token, text),
|
|
212
|
-
rich: (raw, options) => current.rich(raw, options),
|
|
213
|
-
cut: (text, width, ellipsis) => current.cut(text, width, ellipsis),
|
|
214
|
-
glyph: token => current.glyph(token),
|
|
215
|
-
visible: token => current.visible(token),
|
|
216
|
-
// Forwarded rather than read, because the editor and the markdown view keep
|
|
217
|
-
// the theme object they were built with: a settings change has to reach them
|
|
218
|
-
// through a stable delegate or they would keep the boot appearance.
|
|
219
|
-
editor: forwardEditorTheme(() => current.editor),
|
|
220
|
-
markdown: forwardMarkdownTheme(() => current.markdown),
|
|
221
|
-
};
|
|
222
|
-
/**
|
|
223
|
-
* Rows the reader has opened by key. The model stays untouched; only the view
|
|
224
|
-
* reads this, and a click on one message overrides it there. A thought starts
|
|
225
|
-
* folded: it is the longest, least scannable row in the transcript, so leaving
|
|
226
|
-
* it open pushes the answer a reader came for off the screen, and a folded row
|
|
227
|
-
* still names itself and its key. Cards start from the reader's own `tools:`
|
|
228
|
-
* settings, and a PTC card's calls start open for the opposite reason: each is
|
|
229
|
-
* one clipped line under a header that already names the program.
|
|
230
|
-
*/
|
|
231
|
-
const viewState = { ...DEFAULT_VIEW_STATE };
|
|
232
|
-
/**
|
|
233
|
-
* How a reply's mermaid fences draw, seeded from the reader's section.
|
|
234
|
-
*
|
|
235
|
-
* The transform reads this per render instead of capturing it, because the
|
|
236
|
-
* settings document is hot-reloaded and a session already on screen has to
|
|
237
|
-
* follow the edit.
|
|
238
|
-
*/
|
|
239
|
-
let mermaidMode = defaultSettings().mermaid;
|
|
240
|
-
/** How each tool's cards draw; the settings document owns it and the view reads it live. */
|
|
241
|
-
let toolDisplay = defaultSettings().tools;
|
|
242
|
-
/** The keys that start a chord, and how long one waits; the settings document owns all of it. */
|
|
243
|
-
let prefixKeys = DEFAULT_PREFIX_KEYS;
|
|
244
|
-
let prefixWindowMs = DEFAULT_PREFIX_WINDOW_S * MS_PER_SECOND;
|
|
245
|
-
/** Every action's keys in force; the settings document owns it and a press reads it live. */
|
|
246
|
-
let keymap = defaultKeymap();
|
|
247
|
-
/** Whether prompts are recorded and offered, and the cap on how many; the settings document owns all three. */
|
|
248
|
-
let historyEnabled = defaultSettings().history.enabled;
|
|
249
|
-
let historyGhost = defaultSettings().history.ghost;
|
|
250
|
-
let historyMaxEntries = defaultSettings().history.maxEntries;
|
|
251
|
-
/**
|
|
252
|
-
* The chord between a prefix and the action that follows it.
|
|
253
|
-
*
|
|
254
|
-
* Built here, before the terminal exists, because the settings scope applies
|
|
255
|
-
* first and has to be able to end a chord armed under the keymap it replaced.
|
|
256
|
-
* The repaint the window also wants is late-bound: only a key press reaches
|
|
257
|
-
* it, and no key can arrive before the surface has started.
|
|
258
|
-
*/
|
|
259
|
-
const keyChord = new ChordReader(() => prefixKeys, () => chordBindings(keymap), () => prefixWindowMs, () => tui.requestRender());
|
|
260
|
-
/**
|
|
261
|
-
* Seed the display the reader configured.
|
|
262
|
-
*
|
|
263
|
-
* The key toggles nested calls for one session, but a settings edit is a
|
|
264
|
-
* deliberate act, so it re-seeds and becomes the new starting point; the
|
|
265
|
-
* mermaid mode and the per-tool card fold have no key of their own and only
|
|
266
|
-
* ever come from the document.
|
|
267
|
-
*/
|
|
268
|
-
const applyDisplay = (section) => {
|
|
269
|
-
viewState.expandSubCalls = section.subcalls === 'inline';
|
|
270
|
-
mermaidMode = section.mermaid;
|
|
271
|
-
toolDisplay = section.tools;
|
|
272
|
-
prefixKeys = section.prefixes;
|
|
273
|
-
prefixWindowMs = section.prefixWindow * MS_PER_SECOND;
|
|
274
|
-
keymap = section.keymap;
|
|
275
|
-
// Installed where the library reads it, so a remap lands on the next press
|
|
276
|
-
// rather than at the next restart.
|
|
277
|
-
installKeybindings(keymap);
|
|
278
|
-
historyEnabled = section.history.enabled;
|
|
279
|
-
historyGhost = section.history.ghost;
|
|
280
|
-
historyMaxEntries = section.history.maxEntries;
|
|
281
|
-
// A chord armed under the keymap the reader just replaced is not their chord.
|
|
282
|
-
keyChord.disarm();
|
|
283
|
-
};
|
|
284
|
-
/**
|
|
285
|
-
* Read the reader's section once and apply everything it configures.
|
|
286
|
-
*
|
|
287
|
-
* One read per change, because a refused section is reported on the way past:
|
|
288
|
-
* reading it once per field would show the reader the same refusal twice.
|
|
289
|
-
*/
|
|
290
|
-
const applySettings = () => {
|
|
291
|
-
const section = readSection();
|
|
292
|
-
appliedSection = section;
|
|
293
|
-
// A settings edit ends any preview: what the document says is now the choice,
|
|
294
|
-
// and a name left over from a list would outrank it.
|
|
295
|
-
previewTheme = undefined;
|
|
296
|
-
reportMissingTheme(section);
|
|
297
|
-
applyTheme(section);
|
|
298
|
-
applyDisplay(section);
|
|
299
|
-
};
|
|
300
|
-
/**
|
|
301
|
-
* Say so when the reader named a theme that nothing answers to.
|
|
302
|
-
*
|
|
303
|
-
* The schema cannot refuse the name: a theme is a file, so the set of names is
|
|
304
|
-
* known to the directory rather than to this build, and one can stop answering
|
|
305
|
-
* between two reads. Falling back to the default without a word would leave the
|
|
306
|
-
* reader looking at shades they did not choose, so the refusal lands here
|
|
307
|
-
* instead — beside the read that found it, and alongside the rest of the
|
|
308
|
-
* section, which is still theirs.
|
|
309
|
-
*/
|
|
310
|
-
const reportMissingTheme = (section) => {
|
|
311
|
-
const name = section.theme;
|
|
312
|
-
if (name === undefined || themeLibrary.get(name) !== undefined)
|
|
313
|
-
return;
|
|
314
|
-
settingsNotice.post(`dsh-tui theme "${name}" is not a theme · themes: ${themeLibrary.names().join(' · ')} · the default, ${DEFAULT_THEME}, is drawn instead`);
|
|
315
|
-
};
|
|
316
90
|
/**
|
|
317
|
-
*
|
|
91
|
+
* The reader's appearance, built first because everything this surface draws
|
|
92
|
+
* holds its theme delegate and reads its preferences live.
|
|
318
93
|
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* first read happens here too, because this is the only scope the service
|
|
322
|
-
* may be touched in.
|
|
94
|
+
* The settings scope is registered further down, because it re-seeds the prompt
|
|
95
|
+
* bar's own keys and may only apply once that owner exists.
|
|
323
96
|
*/
|
|
324
|
-
ctx
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
readSection = () => readScope(scope, message => settingsNotice.post(message));
|
|
341
|
-
chooseTheme = name => {
|
|
342
|
-
void scope.update({ theme: name }).then(() => model.notice(`theme · ${name} · written to the settings document`), (error) => settingsNotice.post(settingsProblemMessage(error)));
|
|
343
|
-
};
|
|
344
|
-
applySettings();
|
|
97
|
+
const appearance = createAppearance(ctx, {
|
|
98
|
+
color: () => resolved.color,
|
|
99
|
+
// The row's own theme is the layer a profile patch can pin on a harness
|
|
100
|
+
// that keeps settings per row, so it is read here beside the section.
|
|
101
|
+
rowTheme: resolved.theme,
|
|
102
|
+
rowSettings: () => readRowSettings(config),
|
|
103
|
+
rowSettingsLive: hasLiveRowSettings(config),
|
|
104
|
+
notice: message => sessionView.notice(message),
|
|
105
|
+
render: () => tui.requestRender(),
|
|
106
|
+
invalidateMarkdown: () => markdown.invalidate(),
|
|
107
|
+
invalidateView: () => view.invalidate(),
|
|
108
|
+
// The chord a settings edit has to end is armed in the bar's own owner, so it
|
|
109
|
+
// is reached through the thing that holds it rather than copied here.
|
|
110
|
+
keybindings: () => promptInput,
|
|
111
|
+
openPicker: picker => modalInput.openPicker(picker),
|
|
345
112
|
});
|
|
113
|
+
const { theme } = appearance;
|
|
114
|
+
/**
|
|
115
|
+
* The prompt bar's own presses, built here rather than beside the bar.
|
|
116
|
+
*
|
|
117
|
+
* The settings scope below applies first and has to be able to end a chord
|
|
118
|
+
* armed under the keymap it replaced, so this owner exists before the terminal
|
|
119
|
+
* or the editor do; each port reads them at call time for that reason.
|
|
120
|
+
*/
|
|
121
|
+
const promptInput = createPromptInput(ctx, {
|
|
122
|
+
keymap: appearance.keymap,
|
|
123
|
+
prefixKeys: appearance.prefixKeys,
|
|
124
|
+
prefixWindowMs: appearance.prefixWindowMs,
|
|
125
|
+
tui: () => tui,
|
|
126
|
+
editor: () => editor,
|
|
127
|
+
promptBar: () => promptBar,
|
|
128
|
+
modalHandleKey: data => modalInput.handleKey(data),
|
|
129
|
+
toggleCards: appearance.toggleCards,
|
|
130
|
+
toggleSubCalls: appearance.toggleSubCalls,
|
|
131
|
+
toggleReasoning: appearance.toggleReasoning,
|
|
132
|
+
openEffortPicker: () => {
|
|
133
|
+
void modelChoice.openEffortPicker();
|
|
134
|
+
},
|
|
135
|
+
openHistoryPicker: () => {
|
|
136
|
+
void promptMemory.openHistoryPicker();
|
|
137
|
+
},
|
|
138
|
+
back: () => {
|
|
139
|
+
if (sessionView.viewingChild())
|
|
140
|
+
void sessionView.showDriven();
|
|
141
|
+
},
|
|
142
|
+
queuedPrompts: () => sessionLifecycle.queuedPrompts(),
|
|
143
|
+
turnRunning: () => sessionLifecycle.turnRunning(),
|
|
144
|
+
viewingChild: () => sessionView.viewingChild(),
|
|
145
|
+
interrupt: () => {
|
|
146
|
+
sessionLifecycle.drivingAgent()?.interrupt();
|
|
147
|
+
},
|
|
148
|
+
notice: message => sessionView.notice(message),
|
|
149
|
+
requestExit: code => requestExit(code),
|
|
150
|
+
recordPrompt: text => promptMemory.record(text),
|
|
151
|
+
runSubmission: submission => commands.runSubmission(submission),
|
|
152
|
+
drivenAgent: () => sessionLifecycle.drivingAgent()?.agent,
|
|
153
|
+
registeredCommands: target => commands.registeredCommands(target),
|
|
154
|
+
});
|
|
155
|
+
appearance.registerSection();
|
|
346
156
|
/**
|
|
347
|
-
* The
|
|
157
|
+
* The transcript on screen, and the fold that feeds it.
|
|
348
158
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
159
|
+
* Built before anything that draws it, and before the session it opens on is
|
|
160
|
+
* driven: every read it takes of the driven identity, the staged cutoff, and
|
|
161
|
+
* the render target is a port taken at call time, because all three move as
|
|
162
|
+
* the reader switches sessions.
|
|
353
163
|
*/
|
|
354
|
-
|
|
355
|
-
|
|
164
|
+
const sessionView = createSessionView(ctx, {
|
|
165
|
+
initialSession: resolved.sessionId,
|
|
166
|
+
drivenSession: () => sessionLifecycle.activeSession(),
|
|
167
|
+
stagedCutoff: () => stagedTurns.stagedCutoff(),
|
|
168
|
+
agentScope: id => ctx.agents?.get(id),
|
|
169
|
+
liveEvents: id => liveSession(id)?.snapshotEvents?.(),
|
|
170
|
+
render: () => tui.requestRender(),
|
|
171
|
+
});
|
|
356
172
|
/**
|
|
357
|
-
* The
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*/
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
173
|
+
* The prompt's own memory, constructed where the box that draws its ghost
|
|
174
|
+
* already exists; the settings readers stay live because the document is
|
|
175
|
+
* hot-reloaded.
|
|
176
|
+
*/
|
|
177
|
+
const promptMemory = createPromptMemory({
|
|
178
|
+
historyEnabled: appearance.historyEnabled,
|
|
179
|
+
historyGhost: appearance.historyGhost,
|
|
180
|
+
historyMaxEntries: appearance.historyMaxEntries,
|
|
181
|
+
theme,
|
|
182
|
+
keymap: appearance.keymap,
|
|
183
|
+
notice: message => sessionView.notice(message),
|
|
184
|
+
render: () => tui.requestRender(),
|
|
185
|
+
editorText: () => editor.getExpandedText(),
|
|
186
|
+
setEditorText: text => {
|
|
187
|
+
editor.setText(text);
|
|
365
188
|
tui.requestRender();
|
|
366
189
|
},
|
|
190
|
+
// A question answers in this editor, so a draft written into a borrowed bar
|
|
191
|
+
// would become somebody's answer instead of a parked prompt. An editor
|
|
192
|
+
// holding the draft in another program owns it just as firmly: a pop that
|
|
193
|
+
// landed then would be deleted from the bank and then overwritten.
|
|
194
|
+
editorAvailable: () => !promptBar.isBorrowed() && !terminalLifecycle.handedOver(),
|
|
195
|
+
activeSession: () => sessionLifecycle.activeSession(),
|
|
196
|
+
// The keyboard's lifetime is the modal owner's, so the list is handed over
|
|
197
|
+
// rather than driven here.
|
|
198
|
+
openPicker: picker => modalInput.openPicker(picker),
|
|
367
199
|
});
|
|
200
|
+
const ghostBrush = promptMemory.ghostBrush;
|
|
368
201
|
/**
|
|
369
|
-
* The
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* unreadable text the reader could still accept, which is worse than none.
|
|
373
|
-
* The cursor cell is also reversed so the cursor stays visible on the ghost.
|
|
202
|
+
* The route this session runs with, built before the status facts that report
|
|
203
|
+
* it and read through ports because the screen, the keyboard and the settings
|
|
204
|
+
* document that supplies the default all belong to the surface around it.
|
|
374
205
|
*/
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
};
|
|
383
|
-
const work = new WorkFold();
|
|
384
|
-
const modelSwitch = new ModelSwitch();
|
|
206
|
+
const modelChoice = createModelChoice(ctx, {
|
|
207
|
+
statusFacts: () => statusFacts(),
|
|
208
|
+
keymap: appearance.keymap,
|
|
209
|
+
openPicker: picker => modalInput.openPicker(picker),
|
|
210
|
+
notice: message => sessionView.notice(message),
|
|
211
|
+
render: () => tui.requestRender(),
|
|
212
|
+
});
|
|
385
213
|
const agentPresets = createPresetRoster(ctx);
|
|
386
|
-
/** The mode named on the command line, which is the only one that may conflict. */
|
|
387
|
-
const requestedPreset = resolved.preset;
|
|
388
|
-
/**
|
|
389
|
-
* The mode a session the reader starts from here on joins.
|
|
390
|
-
*
|
|
391
|
-
* A launch flag seeds it and a successful switch updates it, so `/new` and
|
|
392
|
-
* `/fork` land in the mode the reader last chose; nothing is written to
|
|
393
|
-
* settings, because the mode of a session is not a property of the machine.
|
|
394
|
-
* Unset means nobody chose, which is where the roster's default applies.
|
|
395
|
-
*/
|
|
396
|
-
let seat = requestedPreset;
|
|
397
|
-
/**
|
|
398
|
-
* The mode a session takes its seat in: the reader's latest choice, or the
|
|
399
|
-
* roster's default read at this moment.
|
|
400
|
-
*
|
|
401
|
-
* The default cannot be captured when this row applies: it comes from the
|
|
402
|
-
* settings document, which may be read after that, so a captured copy would
|
|
403
|
-
* pin every later session to the mode the bundle happened to ship.
|
|
404
|
-
*/
|
|
405
|
-
const seatMode = () => seat ?? agentPresets?.defaultId;
|
|
406
|
-
const catalog = createModelCatalog(ctx);
|
|
407
|
-
const jobDirectory = createJobDirectory(ctx);
|
|
408
|
-
let jobs = [];
|
|
409
|
-
const roster = new SubagentRoster();
|
|
410
|
-
const subagentControl = createSubagentControl(ctx);
|
|
411
|
-
const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => mermaidMode }));
|
|
412
|
-
const restore = createRestoreRegistry();
|
|
413
|
-
const terminal = new ProcessTerminal();
|
|
414
|
-
const tui = new WarningSafeTui(terminal, { copySelection });
|
|
415
|
-
// A frame that cannot be drawn leaves the last good screen up, so the failure
|
|
416
|
-
// has to reach the transcript: otherwise the surface looks frozen and nothing
|
|
417
|
-
// on screen can say why.
|
|
418
|
-
tui.onFrameError = error => model.reportError(error);
|
|
419
|
-
/** Whether a child process owns the terminal, which is when nothing here may write to it. */
|
|
420
|
-
let handedOver = false;
|
|
421
|
-
/** Whether the host has unloaded this surface, after which nothing may start it again. */
|
|
422
|
-
let disposed = false;
|
|
423
|
-
/** An exit asked for while a child owned the terminal, run once the screen is ours again. */
|
|
424
|
-
let deferredExit;
|
|
425
214
|
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
215
|
+
* Which mode a session takes, read through ports because the open path mounts
|
|
216
|
+
* through the same roster and the screen, the keyboard, the notice, and the
|
|
217
|
+
* session the reader watches all belong to the surface around it.
|
|
218
|
+
*/
|
|
219
|
+
const presetChoice = createPresetChoice({
|
|
220
|
+
agentPresets,
|
|
221
|
+
requestedPreset: resolved.preset,
|
|
222
|
+
// The mode a session ran is a property of its own log, which is the same
|
|
223
|
+
// history this surface lists the stored sessions from.
|
|
224
|
+
storedPreset: async (id) => {
|
|
225
|
+
const history = createSessionHistory(ctx);
|
|
226
|
+
return history === undefined ? undefined : await presetOfStoredSession(history, id);
|
|
227
|
+
},
|
|
228
|
+
livePreset: id => agentPresets?.current(liveSession(id)),
|
|
229
|
+
drivingAgent: () => sessionLifecycle.drivingAgent(),
|
|
230
|
+
keymap: appearance.keymap,
|
|
231
|
+
openPicker: picker => modalInput.openPicker(picker),
|
|
232
|
+
notice: message => sessionView.notice(message),
|
|
233
|
+
render: () => tui.requestRender(),
|
|
234
|
+
returnToDrivenSession: async () => {
|
|
235
|
+
if (sessionView.viewingChild())
|
|
236
|
+
await sessionView.show(sessionLifecycle.activeSession());
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
const backgroundWork = createBackgroundWork(ctx, {
|
|
240
|
+
drivingAgent: () => sessionLifecycle.drivingAgent(),
|
|
241
|
+
activeSession: () => sessionLifecycle.activeSession(),
|
|
242
|
+
notice: text => sessionView.notice(text),
|
|
243
|
+
marker: text => sessionView.marker(text),
|
|
244
|
+
render: () => tui.requestRender(),
|
|
245
|
+
navigate: id => {
|
|
246
|
+
void sessionView.show(SessionId(id));
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
const markdown = new MarkdownRenderer(theme.markdown, createMermaidTransform({ theme, mode: () => appearance.mermaidMode() }));
|
|
250
|
+
const terminalLifecycle = createTerminalLifecycle(ctx, {
|
|
251
|
+
reportFrameError: error => sessionView.reportError(error),
|
|
252
|
+
notice: message => sessionView.notice(message),
|
|
253
|
+
copyRows: () => view.copyRows(),
|
|
254
|
+
activeSession: () => sessionLifecycle.activeSession(),
|
|
255
|
+
sessionOpened: () => sessionLifecycle.sessionOpened(),
|
|
256
|
+
turnRunning: () => sessionLifecycle.turnRunning(),
|
|
257
|
+
stopClock: () => sessionLifecycle.stopClock(),
|
|
258
|
+
exit: appExit,
|
|
259
|
+
draftText: () => editor.getExpandedText(),
|
|
260
|
+
draftBorrowed: () => promptBar.isBorrowed(),
|
|
261
|
+
holdDraft: text => promptBar.replaceHeld(text),
|
|
262
|
+
writeDraft: text => editor.setText(text),
|
|
263
|
+
});
|
|
264
|
+
const { terminal, tui, herdr, disposers, writeTerminal, requestExit, exited } = terminalLifecycle;
|
|
265
|
+
const view = new TranscriptView(sessionView.model, theme, markdown, {
|
|
266
|
+
state: appearance.viewState,
|
|
267
|
+
gate: () => modalInput.gateCard(),
|
|
268
|
+
picker: () => modalInput.pickerCard(),
|
|
269
|
+
keys: appearance.keymap,
|
|
270
|
+
toolDisplay: tool => toolDisplayFor(appearance.toolDisplay(), tool),
|
|
465
271
|
});
|
|
466
272
|
// The key map goes in before the bar exists, so no press can be read as the
|
|
467
273
|
// send the library submits on by default. A settings document read after this
|
|
468
274
|
// point installs over it, which is why the bar reads the map per press.
|
|
469
|
-
|
|
470
|
-
const editor = new GateInputBar(tui, theme.editor,
|
|
275
|
+
promptInput.installBindings();
|
|
276
|
+
const editor = new GateInputBar(tui, theme.editor, appearance.keymap, ghostBrush);
|
|
471
277
|
// Answers are written in the reader's own editor, which is why a question
|
|
472
278
|
// borrows the bar instead of drawing a second one beside it.
|
|
473
279
|
const promptBar = new PromptBar(editor);
|
|
474
|
-
const disposers = [];
|
|
475
|
-
// The presenter closure outlives the composition's own teardown, so it must
|
|
476
|
-
// not keep an agent alive after its world unwinds.
|
|
477
|
-
disposers.push(() => {
|
|
478
|
-
presentScope = undefined;
|
|
479
|
-
});
|
|
480
|
-
let agent;
|
|
481
|
-
let turnOpen = false;
|
|
482
280
|
/**
|
|
483
|
-
* The
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*/
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
281
|
+
* The one modal interaction at a time, and the keyboard it holds.
|
|
282
|
+
*
|
|
283
|
+
* Built before the transcript view so the view can ask it for a card: the
|
|
284
|
+
* card a gate or picker draws is read per paint, never copied.
|
|
285
|
+
*/
|
|
286
|
+
const modalInput = createModalInput(ctx, {
|
|
287
|
+
herdr,
|
|
288
|
+
editor,
|
|
289
|
+
tui,
|
|
290
|
+
terminal,
|
|
291
|
+
theme,
|
|
292
|
+
keymap: appearance.keymap,
|
|
293
|
+
promptBar,
|
|
294
|
+
refreshCompletion: () => promptInput.applyCompletion(),
|
|
295
|
+
activeSession: () => sessionLifecycle.activeSession(),
|
|
296
|
+
});
|
|
297
|
+
disposers.push(sessionView.clearPresentScope);
|
|
298
|
+
/**
|
|
299
|
+
* The staged cursor over the session this terminal drives, and the prompts
|
|
300
|
+
* parked behind it.
|
|
301
|
+
*
|
|
302
|
+
* Every port is read at call time — the agent, the session, the draft, the
|
|
303
|
+
* queue all move while an undo settles an interrupt — so this owner can exist
|
|
304
|
+
* before the agent it replaces does.
|
|
305
|
+
*/
|
|
306
|
+
const stagedTurns = createStagedTurns({
|
|
307
|
+
viewingChild: () => sessionView.viewingChild(),
|
|
308
|
+
activeSession: () => sessionLifecycle.activeSession(),
|
|
309
|
+
sessionEvents: id => sessionView.sessionEvents(id),
|
|
310
|
+
resetTranscript: () => sessionView.reset(),
|
|
311
|
+
foldTranscript: (id, cutoff) => sessionView.fold(id, cutoff),
|
|
312
|
+
drivingAgent: () => sessionLifecycle.drivingAgent(),
|
|
313
|
+
disposeOutgoing: () => sessionLifecycle.disposeOutgoing(),
|
|
314
|
+
openSession: (id, resume, fork) => sessionLifecycle.openAgent(id, resume, fork),
|
|
315
|
+
queuedPrompts: () => sessionLifecycle.queuedPrompts(),
|
|
316
|
+
canPark: () => stash !== undefined,
|
|
317
|
+
parkPrompt: text => stash?.stashEditor(text) ?? Promise.resolve(),
|
|
318
|
+
turnRunning: () => sessionLifecycle.turnRunning(),
|
|
319
|
+
draft: () => editor.getExpandedText(),
|
|
320
|
+
writeDraft: text => editor.setText(text),
|
|
321
|
+
notice: message => sessionView.notice(message),
|
|
322
|
+
render: () => tui.requestRender(),
|
|
323
|
+
});
|
|
502
324
|
// The bank is built once the picker exists to answer for it, so the status
|
|
503
325
|
// source is late-bound: the footer must not read a half-constructed stash.
|
|
504
326
|
let stash;
|
|
505
327
|
const statusFacts = createStatusFacts(ctx, {
|
|
506
|
-
sessionId: () => activeSession,
|
|
507
|
-
activity: () => (
|
|
508
|
-
override: () =>
|
|
328
|
+
sessionId: () => sessionLifecycle.activeSession(),
|
|
329
|
+
activity: () => sessionLifecycle.activity(),
|
|
330
|
+
override: () => modelChoice.current(),
|
|
509
331
|
home: process.env.HOME,
|
|
510
|
-
chord: () =>
|
|
332
|
+
chord: () => promptInput.chordHint(),
|
|
511
333
|
// Read per paint rather than written into the marker the view left behind:
|
|
512
334
|
// a hint stored with the transcript would keep naming the key of the day it
|
|
513
335
|
// was written, and the reader may remap it with the row already on screen.
|
|
514
|
-
back: () => (
|
|
336
|
+
back: () => (sessionView.viewingChild() ? backHint(appearance.keymap()) : undefined),
|
|
515
337
|
stash: () => stash?.entryCount,
|
|
516
338
|
});
|
|
517
339
|
const statusBar = new StatusBar(statusFacts, theme);
|
|
518
|
-
const dock = new WorkDock(() =>
|
|
519
|
-
|
|
520
|
-
// session on screen, because it sits on the editor that submits to that agent.
|
|
521
|
-
const queuedPrompts = () => pendingPrompts(ctx, liveSession(activeSession));
|
|
522
|
-
const queue = new QueueBar(queuedPrompts, theme);
|
|
523
|
-
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
524
|
-
const statusTicker = setInterval(() => {
|
|
525
|
-
if (turnOpen)
|
|
526
|
-
tui.requestRender();
|
|
527
|
-
}, STATUS_TICK_MS);
|
|
528
|
-
disposers.push(() => clearInterval(statusTicker));
|
|
529
|
-
// A window that outlived the surface would repaint a screen that is gone.
|
|
530
|
-
disposers.push(() => keyChord.disarm());
|
|
531
|
-
// A containing Herdr is told what this pane is doing; away from one the
|
|
532
|
-
// reporter is inert, so the surface never depends on being multiplexed.
|
|
533
|
-
const herdr = createHerdrReporter();
|
|
534
|
-
// Kept out of the disposal list on purpose: the row is handed back before
|
|
535
|
-
// this stops guarding it, so a host that leaves during the release still
|
|
536
|
-
// releases synchronously.
|
|
537
|
-
const unregisterExit = herdr.registerExitRelease();
|
|
538
|
-
restore.add(() => tui.stop());
|
|
539
|
-
ctx.effect(() => () => {
|
|
540
|
-
// A surface the host unloads owns no screen and keeps no listeners, so a
|
|
541
|
-
// child still running in another process must not be handed a start().
|
|
542
|
-
disposed = true;
|
|
543
|
-
// The pane stops being an agent before the process that claimed it unwinds:
|
|
544
|
-
// a release that ran after the reports were unregistered would race them,
|
|
545
|
-
// and one that never ran would leave a row that reads as a live agent. The
|
|
546
|
-
// reports already on the wire are settled first, because Herdr ignores a
|
|
547
|
-
// release for a pane nothing has claimed yet — the report that followed it
|
|
548
|
-
// would otherwise claim the row back. The promise is returned so a host that
|
|
549
|
-
// waits for teardown waits for the row too, and the exit listener outlives
|
|
550
|
-
// the wait, so one that does not still hands the row back synchronously.
|
|
551
|
-
const released = herdr.release().catch(() => undefined);
|
|
552
|
-
restore.restore();
|
|
553
|
-
for (const dispose of disposers.reverse())
|
|
554
|
-
dispose();
|
|
555
|
-
return released.finally(unregisterExit);
|
|
340
|
+
const dock = new WorkDock(() => sessionView.workState(), theme, () => backgroundWork.jobs(), () => backgroundWork.roster.list(), undefined, id => {
|
|
341
|
+
void sessionView.show(SessionId(id));
|
|
556
342
|
});
|
|
343
|
+
const queue = new QueueBar(() => sessionLifecycle.queuedPrompts(), theme);
|
|
344
|
+
/**
|
|
345
|
+
* The agent this terminal drives, and the clock that repaints it while a turn
|
|
346
|
+
* is open.
|
|
347
|
+
*
|
|
348
|
+
* Built after the widgets that draw it and before the layout that mounts them,
|
|
349
|
+
* and every port is read at call time: the readers above exist before this
|
|
350
|
+
* owner does, and the session it drives moves under them.
|
|
351
|
+
*/
|
|
352
|
+
const sessionLifecycle = createSessionLifecycle(ctx, {
|
|
353
|
+
initialSession: resolved.sessionId,
|
|
354
|
+
model: resolved.model,
|
|
355
|
+
provider: resolved.provider,
|
|
356
|
+
bell: resolved.bell,
|
|
357
|
+
presetFor: (id, resume, fork) => presetChoice.presetFor(id, resume, fork),
|
|
358
|
+
installModelChoice: agentCtx => modelChoice.setup(agentCtx),
|
|
359
|
+
mountPreset: async (agentCtx, preset) => {
|
|
360
|
+
await agentPresets?.mount(agentCtx, preset);
|
|
361
|
+
},
|
|
362
|
+
liveSession: id => liveSession(id),
|
|
363
|
+
installCompletion: () => promptInput.installCompletion(),
|
|
364
|
+
sessionEvents: id => sessionView.sessionEvents(id),
|
|
365
|
+
setViewed: id => sessionView.setViewed(id),
|
|
366
|
+
setPresentScope: scope => sessionView.setPresentScope(scope),
|
|
367
|
+
clearPresentScope: sessionView.clearPresentScope,
|
|
368
|
+
resetTranscript: () => sessionView.reset(),
|
|
369
|
+
replay: id => sessionView.replay(id),
|
|
370
|
+
fold: id => sessionView.fold(id),
|
|
371
|
+
notice: message => sessionView.notice(message),
|
|
372
|
+
render: () => tui.requestRender(),
|
|
373
|
+
promptMemorySessionOpened: () => promptMemory.sessionOpened(),
|
|
374
|
+
stagedSessionOpened: id => stagedTurns.sessionOpened(id),
|
|
375
|
+
turnSettled: () => stagedTurns.turnSettled(),
|
|
376
|
+
acceptCatalog: info => backgroundWork.acceptCatalog(info),
|
|
377
|
+
resetRoster: () => backgroundWork.resetRoster(),
|
|
378
|
+
refreshJobs: () => backgroundWork.refresh(),
|
|
379
|
+
herdr,
|
|
380
|
+
writeTerminal,
|
|
381
|
+
exiting: exited,
|
|
382
|
+
disposers,
|
|
383
|
+
});
|
|
384
|
+
// A window that outlived the surface would repaint a screen that is gone.
|
|
385
|
+
disposers.push(() => promptInput.disarmChord());
|
|
386
|
+
// Registered here rather than inside the terminal owner: this is the position
|
|
387
|
+
// the one teardown list gave the signal seam before the surface was split.
|
|
388
|
+
disposers.push(terminalLifecycle.signalShutdown());
|
|
557
389
|
tui.setLayoutRoot(surfaceLayout({
|
|
558
390
|
transcript: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
|
|
559
391
|
dock,
|
|
@@ -562,2147 +394,56 @@ export function apply(ctx, config) {
|
|
|
562
394
|
status: statusBar,
|
|
563
395
|
}));
|
|
564
396
|
tui.setFocus(editor);
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
return;
|
|
568
|
-
// A child owns the terminal: restoring it here would leave the reader a
|
|
569
|
-
// shell behind an editor that is still running, and would put its tty back
|
|
570
|
-
// into cooked mode under it. The exit waits for the screen to come back.
|
|
571
|
-
if (handedOver) {
|
|
572
|
-
deferredExit = { code, reason };
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
exited = true;
|
|
576
|
-
clearInterval(statusTicker);
|
|
577
|
-
// The screen goes back at once, so leaving feels like leaving; the row goes
|
|
578
|
-
// back behind it. Reports already on the wire are settled first, because
|
|
579
|
-
// Herdr ignores the release of a pane nothing has claimed yet — the report
|
|
580
|
-
// that followed it would otherwise claim the row back during shutdown.
|
|
581
|
-
terminal.write(CLEAR_TITLE);
|
|
582
|
-
restore.restore();
|
|
583
|
-
void herdr
|
|
584
|
-
.release()
|
|
585
|
-
// Nobody is left to report a release that failed on the way out, and a
|
|
586
|
-
// row that could not be cleared is not a reason to keep the process.
|
|
587
|
-
.catch(() => undefined)
|
|
588
|
-
.finally(() => {
|
|
589
|
-
// Everything below is written after the release: a failure nobody can
|
|
590
|
-
// read is not a failure that was reported.
|
|
591
|
-
if (reason !== undefined)
|
|
592
|
-
terminal.write(`\ndsh-tui: ${reason}\n`);
|
|
593
|
-
// The hint is computed here rather than read from the context because
|
|
594
|
-
// only the surface knows which session it is leaving: a fork or a
|
|
595
|
-
// switch moves it. A run that opened nothing has nothing to offer back:
|
|
596
|
-
// the identity it was launched with names no log, so pointing at it
|
|
597
|
-
// would send the reader to a conversation that does not exist.
|
|
598
|
-
if (sessionOpened)
|
|
599
|
-
terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
|
|
600
|
-
appExit(code);
|
|
601
|
-
});
|
|
602
|
-
};
|
|
603
|
-
// A signal ends the process from outside the surface, and the default action
|
|
604
|
-
// would leave the reader on a screen no shell prompt is drawn in: the same
|
|
605
|
-
// shutdown a quit key runs goes to the signals a supervisor sends.
|
|
606
|
-
disposers.push(installSignalRestore({ shutdown: code => requestExit(code, 'interrupted') }));
|
|
607
|
-
const openGate = (next) => {
|
|
608
|
-
pending = next;
|
|
609
|
-
// The card's own title names the decision, which is what a reader glancing
|
|
610
|
-
// at a wall of panes needs in order to know which one to open.
|
|
611
|
-
herdr.block(next.gate.card().title);
|
|
612
|
-
// A gate owns the keyboard: the editor must not collect the decision keys.
|
|
613
|
-
editor.disableSubmit = true;
|
|
614
|
-
tui.setFocus(null);
|
|
615
|
-
// A question is answered in this editor, which the gate draws under the row
|
|
616
|
-
// being answered, so the hardware cursor belongs to it while it is borrowed.
|
|
617
|
-
// It is set after the focus is cleared, which unmarks the component it left.
|
|
618
|
-
editor.focused = next.kind === 'question';
|
|
619
|
-
tui.requestRender();
|
|
620
|
-
};
|
|
621
|
-
const closeGate = () => {
|
|
622
|
-
pending = undefined;
|
|
623
|
-
herdr.unblock();
|
|
624
|
-
editor.disableSubmit = false;
|
|
625
|
-
// The question is answered or skipped, so the reader gets their prompt back
|
|
626
|
-
// in the bar they left it in, with the menu the prompt bar offered.
|
|
627
|
-
promptBar.giveBack();
|
|
628
|
-
applyCompletion();
|
|
629
|
-
tui.setFocus(editor);
|
|
630
|
-
tui.requestRender();
|
|
631
|
-
};
|
|
632
|
-
/**
|
|
633
|
-
* Whether the bar holds anything the reader wrote.
|
|
634
|
-
*
|
|
635
|
-
* Whitespace counts: it is a character the editor holds, and a press that
|
|
636
|
-
* clears it is the press the reader asked for. A paste counts expanded,
|
|
637
|
-
* because a marker is content rather than an absence of it.
|
|
638
|
-
*/
|
|
639
|
-
const barHasText = () => editor.getExpandedText() !== '';
|
|
397
|
+
disposers.push(promptInput.inputListener());
|
|
398
|
+
stash = promptMemory.buildStash();
|
|
640
399
|
/**
|
|
641
|
-
*
|
|
400
|
+
* The command plane, wired to the owners every submission is delegated to.
|
|
642
401
|
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
402
|
+
* Built once the bank exists, because the parked-draft commands route to the
|
|
403
|
+
* very bank the footer counts; every other port is a live read, so a command
|
|
404
|
+
* typed after a session switch answers for the session now on screen.
|
|
645
405
|
*/
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
},
|
|
652
|
-
subCalls: () => {
|
|
653
|
-
viewState.expandSubCalls = !viewState.expandSubCalls;
|
|
654
|
-
tui.requestRender();
|
|
655
|
-
return true;
|
|
656
|
-
},
|
|
657
|
-
reasoning: () => {
|
|
658
|
-
viewState.expandReasoning = !viewState.expandReasoning;
|
|
659
|
-
tui.requestRender();
|
|
660
|
-
return true;
|
|
406
|
+
const commands = createCommands(ctx, {
|
|
407
|
+
launch: {
|
|
408
|
+
sessionId: resolved.sessionId,
|
|
409
|
+
resume: resolved.resume,
|
|
410
|
+
resumePicker: resolved.resumePicker,
|
|
661
411
|
},
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
// decides what one press takes back — and a press with nothing left to
|
|
678
|
-
// cancel is handed back rather than spent on leaving.
|
|
679
|
-
const queued = queuedPrompts();
|
|
680
|
-
const step = cancelStep({
|
|
681
|
-
barHasText: barHasText(),
|
|
682
|
-
queuedPrompts: queued.length,
|
|
683
|
-
turnRunning: turnOpen,
|
|
684
|
-
viewingChild: viewedSession !== activeSession,
|
|
685
|
-
});
|
|
686
|
-
switch (step) {
|
|
687
|
-
case 'clear-editor':
|
|
688
|
-
editor.setText('');
|
|
689
|
-
tui.requestRender();
|
|
690
|
-
return true;
|
|
691
|
-
case 'reclaim-queued':
|
|
692
|
-
// The interrupt drops whatever the agent had not started, so the words
|
|
693
|
-
// are read before it is stopped and handed back to the bar: a key that
|
|
694
|
-
// means "stop" must not be the key that loses the reader's own prompts.
|
|
695
|
-
agent?.interrupt();
|
|
696
|
-
editor.setText(queued.join('\n'));
|
|
697
|
-
model.notice(`interrupt requested · ${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} back in the bar`);
|
|
698
|
-
tui.requestRender();
|
|
699
|
-
return true;
|
|
700
|
-
case 'interrupt-turn':
|
|
701
|
-
agent?.interrupt();
|
|
702
|
-
model.notice('interrupt requested');
|
|
703
|
-
tui.requestRender();
|
|
704
|
-
return true;
|
|
705
|
-
case 'leave-child-view':
|
|
706
|
-
void showAgentSession();
|
|
707
|
-
return true;
|
|
708
|
-
case 'hand-back':
|
|
709
|
-
return false;
|
|
710
|
-
}
|
|
711
|
-
},
|
|
712
|
-
quit: () => {
|
|
713
|
-
// The only key that leaves. Text in the bar keeps it for the editor, so
|
|
714
|
-
// the library's delete forward is never taken from a reader who is editing.
|
|
715
|
-
switch (quitStep({ barHasText: barHasText(), overlayOpen: tui.hasOverlay(), turnRunning: turnOpen })) {
|
|
716
|
-
case 'cancel-then-quit':
|
|
717
|
-
// An exit that waits on a tool call is not an exit, so the turn is
|
|
718
|
-
// asked to stop and the leave does not wait for the answer.
|
|
719
|
-
agent?.interrupt();
|
|
720
|
-
requestExit(0);
|
|
721
|
-
return true;
|
|
722
|
-
case 'quit':
|
|
723
|
-
requestExit(0);
|
|
724
|
-
return true;
|
|
725
|
-
case 'hand-back':
|
|
726
|
-
return false;
|
|
727
|
-
}
|
|
728
|
-
},
|
|
729
|
-
};
|
|
730
|
-
disposers.push(tui.addInputListener(data => {
|
|
731
|
-
// A key arrives as a press and a release once the surface asks the terminal
|
|
732
|
-
// to report key events, and this library drops the release only for the
|
|
733
|
-
// focused component: a listener sees both halves, so an arrow key that is
|
|
734
|
-
// acted on twice steps a picker two rows and answers a question two options
|
|
735
|
-
// on. Falls through rather than consuming, which leaves a component that
|
|
736
|
-
// asked for releases its own half.
|
|
737
|
-
if (isKeyRelease(data))
|
|
738
|
-
return undefined;
|
|
739
|
-
if (pending !== undefined) {
|
|
740
|
-
if (pending.kind === 'approval') {
|
|
741
|
-
const outcome = pending.gate.handleKey(data);
|
|
742
|
-
if (outcome === undefined)
|
|
743
|
-
tui.requestRender();
|
|
744
|
-
else {
|
|
745
|
-
pending.settle(outcome);
|
|
746
|
-
closeGate();
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
else {
|
|
750
|
-
const answers = pending.gate.handleKey(data);
|
|
751
|
-
if (answers === undefined)
|
|
752
|
-
tui.requestRender();
|
|
753
|
-
else {
|
|
754
|
-
pending.settle(answers);
|
|
755
|
-
closeGate();
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
return { consume: true };
|
|
759
|
-
}
|
|
760
|
-
if (pendingPicker !== undefined) {
|
|
761
|
-
const action = pendingPicker.picker.handleKey(data);
|
|
762
|
-
if (action === undefined) {
|
|
763
|
-
tui.requestRender();
|
|
764
|
-
return { consume: true };
|
|
765
|
-
}
|
|
766
|
-
if (vetting) {
|
|
767
|
-
// A refusal check must not take the keyboard with it: the reader keeps
|
|
768
|
-
// filtering and can still leave, while a second pick waits for the
|
|
769
|
-
// first verdict rather than racing it.
|
|
770
|
-
if (action.kind === 'cancel')
|
|
771
|
-
settlePicker(undefined);
|
|
772
|
-
return { consume: true };
|
|
773
|
-
}
|
|
774
|
-
if (action.kind === 'cancel') {
|
|
775
|
-
settlePicker(undefined);
|
|
776
|
-
return { consume: true };
|
|
777
|
-
}
|
|
778
|
-
const vet = pendingPicker.vet;
|
|
779
|
-
if (vet === undefined) {
|
|
780
|
-
settlePicker(action.id);
|
|
781
|
-
return { consume: true };
|
|
782
|
-
}
|
|
783
|
-
vetting = true;
|
|
784
|
-
void (async () => {
|
|
785
|
-
let reason;
|
|
786
|
-
try {
|
|
787
|
-
reason = await vet(action.id);
|
|
788
|
-
}
|
|
789
|
-
finally {
|
|
790
|
-
// A check that fails must not take the keyboard with it.
|
|
791
|
-
vetting = false;
|
|
792
|
-
}
|
|
793
|
-
if (pendingPicker === undefined)
|
|
794
|
-
return;
|
|
795
|
-
if (reason === undefined) {
|
|
796
|
-
settlePicker(action.id);
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
pendingPicker.picker.setNote(reason);
|
|
800
|
-
tui.requestRender();
|
|
801
|
-
})();
|
|
802
|
-
return { consume: true };
|
|
803
|
-
}
|
|
804
|
-
// A chord is the surface's second key: the prefix is consumed and the
|
|
805
|
-
// footer names what may follow, while a key that finishes nothing is handed
|
|
806
|
-
// on. Detail the reader asked for is always available, even mid-turn: the
|
|
807
|
-
// collapsed view is a default, not the only state.
|
|
808
|
-
const chorded = keyChord.handle(data);
|
|
809
|
-
if (chorded !== undefined) {
|
|
810
|
-
if (chorded.kind === 'action')
|
|
811
|
-
runSubmission(chorded.binding.submission);
|
|
812
|
-
tui.requestRender();
|
|
813
|
-
return { consume: true };
|
|
814
|
-
}
|
|
815
|
-
for (const binding of surfaceBindings(keymap)) {
|
|
816
|
-
if (!matchesKey(data, binding.key))
|
|
817
|
-
continue;
|
|
818
|
-
return surfaceActions[binding.action]() ? { consume: true } : undefined;
|
|
819
|
-
}
|
|
820
|
-
return undefined;
|
|
821
|
-
}));
|
|
822
|
-
const registry = () => ctx.get('commands');
|
|
823
|
-
/** One scan of the workspace, shared by both menus: the listing is read-only and cached. */
|
|
824
|
-
const fileIndex = createFileIndex(process.cwd());
|
|
825
|
-
/** The menu the prompt bar offers: the commands this session can run, and the workspace's files. */
|
|
826
|
-
let promptCompletion;
|
|
827
|
-
/** The menu an answer offers, which is the same menu without the commands. */
|
|
828
|
-
let answerCompletion;
|
|
829
|
-
/**
|
|
830
|
-
* Give the bar the menu its current role calls for.
|
|
831
|
-
*
|
|
832
|
-
* A question borrows the prompt bar's editor rather than drawing its own, so
|
|
833
|
-
* the provider has to follow the borrow: a slash command is a line this
|
|
834
|
-
* surface would run, and an answer is text the model reads, so the two roles
|
|
835
|
-
* must not share one menu.
|
|
836
|
-
*/
|
|
837
|
-
const applyCompletion = () => {
|
|
838
|
-
const chosen = promptBar.isBorrowed() ? answerCompletion : promptCompletion;
|
|
839
|
-
if (chosen !== undefined)
|
|
840
|
-
editor.setAutocompleteProvider(chosen);
|
|
841
|
-
};
|
|
842
|
-
/**
|
|
843
|
-
* Offer completion for whatever this session can run right now.
|
|
844
|
-
*
|
|
845
|
-
* The registry is agent-scoped and still empty while the agent starts, so the
|
|
846
|
-
* menu is rebuilt when the agent arrives and whenever a package registers
|
|
847
|
-
* another command. Both providers are built here because the answer's is the
|
|
848
|
-
* prompt bar's own minus the commands, and a rebuild while a question is open
|
|
849
|
-
* must not leave the answer menu unreachable.
|
|
850
|
-
*/
|
|
851
|
-
const installCompletion = () => {
|
|
852
|
-
const current = agent?.agent;
|
|
853
|
-
const commands = registry();
|
|
854
|
-
if (current === undefined || commands === undefined)
|
|
855
|
-
return;
|
|
856
|
-
answerCompletion = createAnswerCompletionProvider(process.cwd(), fileIndex);
|
|
857
|
-
promptCompletion = createCompletionProvider(commands.list(current), process.cwd(), fileIndex);
|
|
858
|
-
applyCompletion();
|
|
859
|
-
};
|
|
860
|
-
/**
|
|
861
|
-
* Take the keyboard for a picker.
|
|
862
|
-
*
|
|
863
|
-
* The list is already in hand, so the picker is interactive immediately while
|
|
864
|
-
* the titles that make its rows recognizable stream in behind it.
|
|
865
|
-
*/
|
|
866
|
-
const askForSession = async (history, sessions) => {
|
|
867
|
-
const titles = new Map();
|
|
868
|
-
void loadTitles(history, sessions, titles);
|
|
869
|
-
const picked = await openPicker(new SessionPicker(sessions, () => titles, undefined, () => keymap), refuseReason);
|
|
870
|
-
return picked === undefined ? undefined : SessionId(picked);
|
|
871
|
-
};
|
|
872
|
-
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
873
|
-
const settlePicker = (id) => {
|
|
874
|
-
const settle = pendingPicker?.settle;
|
|
875
|
-
// The screen goes back before the keyboard does: a box left behind a settled
|
|
876
|
-
// list would sit over the transcript until something else repainted.
|
|
877
|
-
pendingPicker?.release?.();
|
|
878
|
-
pendingPicker = undefined;
|
|
879
|
-
herdr.unblock();
|
|
880
|
-
editor.disableSubmit = false;
|
|
881
|
-
tui.setFocus(editor);
|
|
882
|
-
settle?.(id);
|
|
883
|
-
tui.requestRender();
|
|
884
|
-
};
|
|
885
|
-
/**
|
|
886
|
-
* Why this run cannot open a stored session, or undefined when it can.
|
|
887
|
-
*
|
|
888
|
-
* A session runs the mode its own log recorded, so a `--preset` that
|
|
889
|
-
* disagrees with it can only be refused: the picker asks first so the refusal
|
|
890
|
-
* lands in the list rather than after the terminal has been handed back.
|
|
891
|
-
*/
|
|
892
|
-
const refuseReason = async (id) => {
|
|
893
|
-
if (requestedPreset === undefined || agentPresets === undefined)
|
|
894
|
-
return undefined;
|
|
895
|
-
try {
|
|
896
|
-
await presetFor(SessionId(id), true, undefined);
|
|
897
|
-
return undefined;
|
|
898
|
-
}
|
|
899
|
-
catch (error) {
|
|
900
|
-
return error instanceof Error ? error.message : String(error);
|
|
901
|
-
}
|
|
902
|
-
};
|
|
903
|
-
/**
|
|
904
|
-
* Take the keyboard for a picker and answer with the id it settled on.
|
|
905
|
-
*
|
|
906
|
-
* Where the list is drawn is the caller's decision, because it is a decision
|
|
907
|
-
* about the reader's attention. A list that is the destination — a session to
|
|
908
|
-
* open, a model to switch to — joins the transcript and is read with it. A
|
|
909
|
-
* list that is a reference for the work in front of the reader is drawn over
|
|
910
|
-
* that work instead, so looking something up does not cost them their place.
|
|
911
|
-
* The keyboard is owned the same way either way.
|
|
912
|
-
*/
|
|
913
|
-
const openPicker = (picker, vet, placement = 'inline') => new Promise(resolve => {
|
|
914
|
-
const overlay = placement === 'popup'
|
|
915
|
-
? tui.showOverlay(new PickerPopup(rows => picker.card(rows), () => terminal.rows, theme), {
|
|
916
|
-
width: popupWidth(terminal.columns),
|
|
917
|
-
maxHeight: POPUP_MAX_HEIGHT,
|
|
918
|
-
anchor: 'center',
|
|
919
|
-
margin: 1,
|
|
920
|
-
// The surface's own listener reads every press before a focused
|
|
921
|
-
// component does, so the box needs no focus to be driven; taking it
|
|
922
|
-
// would only move focus away from where the reader left it.
|
|
923
|
-
nonCapturing: true,
|
|
924
|
-
})
|
|
925
|
-
: undefined;
|
|
926
|
-
pendingPicker = {
|
|
927
|
-
picker,
|
|
928
|
-
settle: resolve,
|
|
929
|
-
vet,
|
|
930
|
-
card: overlay === undefined ? () => picker.card() : undefined,
|
|
931
|
-
release: overlay === undefined ? undefined : () => overlay.hide(),
|
|
932
|
-
};
|
|
933
|
-
// A picker owns the keyboard exactly as a gate does: nothing moves until
|
|
934
|
-
// the reader chooses, so it is the same kind of wait.
|
|
935
|
-
herdr.block(picker.card().title);
|
|
936
|
-
editor.disableSubmit = true;
|
|
937
|
-
tui.setFocus(null);
|
|
938
|
-
tui.requestRender();
|
|
939
|
-
});
|
|
940
|
-
/**
|
|
941
|
-
* Open the key map over the surface.
|
|
942
|
-
*
|
|
943
|
-
* Nothing here is a pick: a row names an action and the keys reaching it, so
|
|
944
|
-
* the id the list settles on is thrown away. What the reader came for is the
|
|
945
|
-
* list itself, and the filter that narrows it.
|
|
946
|
-
*/
|
|
947
|
-
const openKeyMap = (layer) => {
|
|
948
|
-
void openPicker(new KeymapPicker(() => keymap, layer), undefined, 'popup');
|
|
949
|
-
};
|
|
950
|
-
/**
|
|
951
|
-
* Choose a theme from a list the screen follows.
|
|
952
|
-
*
|
|
953
|
-
* Enter writes the choice through the settings document, the same path a typed
|
|
954
|
-
* name takes, so what lands is what the reader was looking at. Leaving the list
|
|
955
|
-
* restores the theme in force, because the document never changed while they
|
|
956
|
-
* looked.
|
|
957
|
-
*/
|
|
958
|
-
const openThemePicker = async () => {
|
|
959
|
-
const picked = await openPicker(new ThemePicker(() => themeLibrary, () => appliedSection?.theme, () => keymap, theme => showTheme(theme?.name)));
|
|
960
|
-
if (picked === undefined) {
|
|
961
|
-
showTheme(undefined);
|
|
962
|
-
return;
|
|
963
|
-
}
|
|
964
|
-
// The row stays on screen until the document carries it: restoring first
|
|
965
|
-
// would flash the theme the reader just left. The write clears the preview as
|
|
966
|
-
// it lands, and a write that fails says so, leaving a theme that is still one
|
|
967
|
-
// of theirs rather than shades nothing chose.
|
|
968
|
-
chooseTheme(picked);
|
|
969
|
-
};
|
|
970
|
-
/**
|
|
971
|
-
* Reverse search over recorded prompts, seeded with whatever is in the bar.
|
|
972
|
-
*
|
|
973
|
-
* A pick replaces the draft; a cancel leaves it exactly as it was, because the
|
|
974
|
-
* list was opened to look rather than to lose what is already typed.
|
|
975
|
-
*/
|
|
976
|
-
const openHistoryPicker = async () => {
|
|
977
|
-
if (!historyEnabled) {
|
|
978
|
-
model.notice('prompt history is disabled in ' + TUI_SETTINGS_NAMESPACE + ' settings');
|
|
979
|
-
tui.requestRender();
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
if (promptHistory.entries().length === 0) {
|
|
983
|
-
const blocked = promptHistory.blockedReason();
|
|
984
|
-
model.notice(blocked === undefined ? 'no prompt history yet' : 'prompt history is unavailable: ' + blocked);
|
|
985
|
-
tui.requestRender();
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
// The expanded text is what the reader wrote; a large paste sits in the bar
|
|
989
|
-
// as a marker, and seeding with it would filter out the prompt it came from.
|
|
990
|
-
const picked = await openPicker(new HistoryPicker(() => promptHistory.entries(), editor.getExpandedText(), () => keymap));
|
|
991
|
-
if (picked === undefined)
|
|
992
|
-
return;
|
|
993
|
-
editor.setText(picked);
|
|
994
|
-
tui.requestRender();
|
|
995
|
-
};
|
|
996
|
-
/** The roster as the picker paints it, refreshed when the picker opens. */
|
|
997
|
-
let presetRows = [];
|
|
998
|
-
/** Choose the mode a session that has not started yet will run. */
|
|
999
|
-
const askForPreset = async (currentId) => {
|
|
1000
|
-
if (agentPresets === undefined)
|
|
1001
|
-
return undefined;
|
|
1002
|
-
presetRows = await agentPresets.list();
|
|
1003
|
-
return await openPicker(new PresetPicker(() => presetRows, () => currentId, () => keymap));
|
|
1004
|
-
};
|
|
1005
|
-
/**
|
|
1006
|
-
* The prompt bank for the session this surface drives.
|
|
1007
|
-
*
|
|
1008
|
-
* The surface owns the editor, the picker, and the notices, so the bank is
|
|
1009
|
-
* handed the few things it needs to reach them and nothing else: the commands
|
|
1010
|
-
* stay free of terminal state and are exercised without one in the tests.
|
|
1011
|
-
*/
|
|
1012
|
-
stash = new PromptStash({
|
|
1013
|
-
getEditorText: () => editor.getExpandedText(),
|
|
1014
|
-
setEditorText: text => {
|
|
1015
|
-
editor.setText(text);
|
|
1016
|
-
tui.requestRender();
|
|
1017
|
-
},
|
|
1018
|
-
// A question answers in this editor, so a draft written into a borrowed bar
|
|
1019
|
-
// would become somebody's answer instead of a parked prompt. An editor
|
|
1020
|
-
// holding the draft in another program owns it just as firmly: a pop that
|
|
1021
|
-
// landed then would be deleted from the bank and then overwritten.
|
|
1022
|
-
editorIsAvailable: () => !promptBar.isBorrowed() && !handedOver,
|
|
1023
|
-
notice: message => model.notice(message),
|
|
1024
|
-
pick: (entries, label) => openPicker(new StashPicker(entries, label, () => keymap)),
|
|
1025
|
-
confirm: async (count) => confirmedClear(await openPicker(new StashConfirmPicker(count, () => keymap))),
|
|
412
|
+
preset: resolved.preset,
|
|
413
|
+
presetRoster: agentPresets,
|
|
414
|
+
missingOptional: () => describeMissingOptional(probe),
|
|
415
|
+
session: sessionLifecycle,
|
|
416
|
+
transcript: sessionView,
|
|
417
|
+
route: modelChoice,
|
|
418
|
+
modes: presetChoice,
|
|
419
|
+
work: backgroundWork,
|
|
420
|
+
staged: stagedTurns,
|
|
421
|
+
memory: promptMemory,
|
|
422
|
+
appearance,
|
|
423
|
+
terminal: terminalLifecycle,
|
|
424
|
+
modals: modalInput,
|
|
425
|
+
statusFacts: () => statusFacts(),
|
|
426
|
+
stash: () => stash,
|
|
1026
427
|
render: () => tui.requestRender(),
|
|
1027
|
-
},
|
|
1028
|
-
// The bank follows the session this surface drives, not the directory it
|
|
1029
|
-
// runs in: two terminals in one checkout keep separate drafts, and a resume
|
|
1030
|
-
// finds the ones it parked. Read per command so a switch retargets it.
|
|
1031
|
-
{ sessionId: () => String(activeSession) });
|
|
1032
|
-
/**
|
|
1033
|
-
* The reader's own editor, opened over the draft the bar holds.
|
|
1034
|
-
*
|
|
1035
|
-
* The screen is handed over rather than drawn beside: an editor needs the
|
|
1036
|
-
* terminal, so this is the one moment the surface is not the process painting
|
|
1037
|
-
* on it. Every failure is reported as a notice and leaves the bar as it was,
|
|
1038
|
-
* because the caller is a key press with nowhere to put an error.
|
|
1039
|
-
*/
|
|
1040
|
-
const externalEditor = new ExternalEditor({
|
|
1041
|
-
suspend: () => {
|
|
1042
|
-
handedOver = true;
|
|
1043
|
-
try {
|
|
1044
|
-
// The frame is left in place rather than repainted into the normal
|
|
1045
|
-
// buffer: the editor is about to paint over that same screen.
|
|
1046
|
-
tui.stop({ preserveScreen: true });
|
|
1047
|
-
}
|
|
1048
|
-
catch (error) {
|
|
1049
|
-
// A stop that failed leaves the screen ours; leaving the flag up would
|
|
1050
|
-
// suppress every later title and defer every exit for good.
|
|
1051
|
-
handedOver = false;
|
|
1052
|
-
throw error;
|
|
1053
|
-
}
|
|
1054
|
-
},
|
|
1055
|
-
resume: () => {
|
|
1056
|
-
// Whatever the host unloaded is not coming back: starting it again would
|
|
1057
|
-
// paint on a terminal this process is done with, into listeners that are gone.
|
|
1058
|
-
if (disposed)
|
|
1059
|
-
return;
|
|
1060
|
-
try {
|
|
1061
|
-
tui.start();
|
|
1062
|
-
}
|
|
1063
|
-
finally {
|
|
1064
|
-
handedOver = false;
|
|
1065
|
-
}
|
|
1066
|
-
// Entering the alternate screen clears it, and any render asked for while
|
|
1067
|
-
// the child owned the terminal was dropped after setting the very flag that
|
|
1068
|
-
// makes the next ordinary request a no-op: without a forced one the reader
|
|
1069
|
-
// would get a blank screen with a working keyboard under it.
|
|
1070
|
-
tui.requestRender(true);
|
|
1071
|
-
// The title is state this surface owns and the handoff swallowed any change
|
|
1072
|
-
// to it, so a turn that ended while the editor was open would leave
|
|
1073
|
-
// "working" up until the next turn.
|
|
1074
|
-
writeTerminal(windowTitle(process.cwd(), turnOpen ? 'working' : 'ready'));
|
|
1075
|
-
},
|
|
1076
|
-
notice: message => model.notice(message),
|
|
1077
428
|
});
|
|
1078
|
-
/**
|
|
1079
|
-
const loadTitles = async (history, sessions, titles) => {
|
|
1080
|
-
const queue = [...sessions];
|
|
1081
|
-
const worker = async () => {
|
|
1082
|
-
for (;;) {
|
|
1083
|
-
const next = queue.shift();
|
|
1084
|
-
if (next === undefined)
|
|
1085
|
-
return;
|
|
1086
|
-
try {
|
|
1087
|
-
const title = await readSessionTitle(history, next);
|
|
1088
|
-
if (title === undefined)
|
|
1089
|
-
continue;
|
|
1090
|
-
titles.set(next.id, title);
|
|
1091
|
-
tui.requestRender();
|
|
1092
|
-
}
|
|
1093
|
-
catch {
|
|
1094
|
-
// A log this build cannot read stays listed by id; the picker is not
|
|
1095
|
-
// the place to explain storage, and one bad session is not the list.
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
};
|
|
1099
|
-
await Promise.all(Array.from({ length: TITLE_CONCURRENCY }, worker));
|
|
1100
|
-
};
|
|
1101
|
-
const storedSessions = async () => {
|
|
1102
|
-
const history = createSessionHistory(ctx);
|
|
1103
|
-
if (history === undefined) {
|
|
1104
|
-
model.notice('this profile has no session storage, so there is nothing to resume');
|
|
1105
|
-
tui.requestRender();
|
|
1106
|
-
return undefined;
|
|
1107
|
-
}
|
|
1108
|
-
try {
|
|
1109
|
-
const sessions = await history.list(PICKER_LIMIT);
|
|
1110
|
-
if (sessions.length === 0) {
|
|
1111
|
-
model.notice('no stored sessions to resume');
|
|
1112
|
-
tui.requestRender();
|
|
1113
|
-
return undefined;
|
|
1114
|
-
}
|
|
1115
|
-
return { history, sessions };
|
|
1116
|
-
}
|
|
1117
|
-
catch (error) {
|
|
1118
|
-
model.notice(`could not list stored sessions: ${error instanceof Error ? error.message : String(error)}`);
|
|
1119
|
-
tui.requestRender();
|
|
1120
|
-
return undefined;
|
|
1121
|
-
}
|
|
1122
|
-
};
|
|
1123
|
-
const chooseSession = async () => {
|
|
1124
|
-
const listed = await storedSessions();
|
|
1125
|
-
return listed === undefined ? undefined : askForSession(listed.history, listed.sessions);
|
|
1126
|
-
};
|
|
1127
|
-
/**
|
|
1128
|
-
* Replay a stored session so a resumed run opens on the conversation the
|
|
1129
|
-
* reader left, not on an empty screen: the durable log is the transcript.
|
|
1130
|
-
*/
|
|
1131
|
-
/**
|
|
1132
|
-
* Fold one session's history into the transcript.
|
|
1133
|
-
*
|
|
1134
|
-
* A live session answers from memory, which is the only source that includes
|
|
1135
|
-
* events not yet flushed and the only one that works for a child that has not
|
|
1136
|
-
* materialized; a session this process is not running falls back to storage.
|
|
1137
|
-
*/
|
|
429
|
+
/** The live session this process runs, which is the only source holding an unflushed tail. */
|
|
1138
430
|
const liveSession = (id) => ctx.get('sessions')?.get?.(id);
|
|
1139
|
-
|
|
1140
|
-
* The fold's place in the viewed session's durable sequence.
|
|
1141
|
-
*
|
|
1142
|
-
* A resumed session is folded while its agent's loop is already live, so the
|
|
1143
|
-
* same event can reach the surface twice: once on the stream and once from the
|
|
1144
|
-
* log the fold is reading. The sequence number every durable event carries is
|
|
1145
|
-
* what tells the two apart.
|
|
1146
|
-
*/
|
|
1147
|
-
const foldCursor = new FoldCursor();
|
|
1148
|
-
/**
|
|
1149
|
-
* Fold one durable event into the view, unless a staged cursor hides it.
|
|
1150
|
-
*
|
|
1151
|
-
* Undo never deletes, so the log keeps the hidden suffix; this filter is the
|
|
1152
|
-
* one place the transcript and the log disagree about where the session ends.
|
|
1153
|
-
*/
|
|
1154
|
-
const applyDurable = (session, event) => {
|
|
1155
|
-
if (stagedCut !== undefined && session === activeSession && typeof event.seq === 'number' && event.seq >= stagedCut)
|
|
1156
|
-
return;
|
|
1157
|
-
if (foldCursor.accept(session, event))
|
|
1158
|
-
applyEvent(event);
|
|
1159
|
-
};
|
|
1160
|
-
const foldHistory = async (id, through) => {
|
|
1161
|
-
// Resolved before the fold so every card reads its tool through the scope
|
|
1162
|
-
// that actually registered it; a stored session nobody runs has none.
|
|
1163
|
-
presentScope = ctx.agents?.get(id);
|
|
1164
|
-
// Every fold starts a cleared transcript, so a session already known to the
|
|
1165
|
-
// cursor is read from its first event rather than from where it left off.
|
|
1166
|
-
foldCursor.reset();
|
|
1167
|
-
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
1168
|
-
if (inMemory !== undefined) {
|
|
1169
|
-
const events = through === undefined ? inMemory : inMemory.slice(0, through);
|
|
1170
|
-
for (const event of events)
|
|
1171
|
-
applyDurable(id, event);
|
|
1172
|
-
return events.length;
|
|
1173
|
-
}
|
|
1174
|
-
// Only a live session can be staged: undo acts on the agent this terminal
|
|
1175
|
-
// drives, and a stored log has no cursor to hide a suffix from.
|
|
1176
|
-
const history = createSessionHistory(ctx);
|
|
1177
|
-
if (history === undefined)
|
|
1178
|
-
return 0;
|
|
1179
|
-
const events = await history.read(id);
|
|
1180
|
-
for (const event of events)
|
|
1181
|
-
applyDurable(id, event);
|
|
1182
|
-
return events.length;
|
|
1183
|
-
};
|
|
1184
|
-
const replayHistory = async (id) => {
|
|
1185
|
-
try {
|
|
1186
|
-
const folded = await foldHistory(id);
|
|
1187
|
-
if (folded > 0)
|
|
1188
|
-
model.notice(`replayed ${folded} events from the stored log`);
|
|
1189
|
-
}
|
|
1190
|
-
catch (error) {
|
|
1191
|
-
model.notice(`could not replay this session: ${error instanceof Error ? error.message : String(error)}`);
|
|
1192
|
-
}
|
|
1193
|
-
};
|
|
1194
|
-
/**
|
|
1195
|
-
* Show another session in the transcript without leaving this one.
|
|
1196
|
-
*
|
|
1197
|
-
* A delegation is an ordinary session, so the reader can read what a child is
|
|
1198
|
-
* doing rather than only that it exists; the agent this terminal drives does
|
|
1199
|
-
* not change, which keeps commands, approvals, and the bell where they were.
|
|
1200
|
-
*/
|
|
1201
|
-
const showSession = async (id) => {
|
|
1202
|
-
const previous = viewedSession;
|
|
1203
|
-
model.reset();
|
|
1204
|
-
work.reset();
|
|
1205
|
-
viewedSession = id;
|
|
1206
|
-
try {
|
|
1207
|
-
await foldHistory(id);
|
|
1208
|
-
}
|
|
1209
|
-
catch (error) {
|
|
1210
|
-
model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
|
|
1211
|
-
}
|
|
1212
|
-
const returned = id === activeSession && previous !== activeSession;
|
|
1213
|
-
model.marker(returned ? 'back to the session this terminal drives' : `viewing ${id}`);
|
|
1214
|
-
tui.requestRender();
|
|
1215
|
-
};
|
|
1216
|
-
const showAgentSession = async () => {
|
|
1217
|
-
await showSession(activeSession);
|
|
1218
|
-
};
|
|
1219
|
-
/** Feed one durable event to everything that folds it. */
|
|
1220
|
-
const applyEvent = (event) => {
|
|
1221
|
-
model.apply(event);
|
|
1222
|
-
work.apply(event);
|
|
1223
|
-
};
|
|
1224
|
-
/** Every event of a session, from memory when this process runs it. */
|
|
1225
|
-
const sessionEvents = async (id) => {
|
|
1226
|
-
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
1227
|
-
if (inMemory !== undefined)
|
|
1228
|
-
return inMemory;
|
|
1229
|
-
const history = createSessionHistory(ctx);
|
|
1230
|
-
return history === undefined ? [] : history.read(id);
|
|
1231
|
-
};
|
|
1232
|
-
/** The closed turns of the session this terminal drives, newest last. */
|
|
1233
|
-
const currentTurns = async () => turnsOf(await sessionEvents(activeSession));
|
|
1234
|
-
/** Fold the transcript through the staged cut, or the whole log at the tip. */
|
|
1235
|
-
const redrawStaged = async () => {
|
|
1236
|
-
model.reset();
|
|
1237
|
-
work.reset();
|
|
1238
|
-
await foldHistory(activeSession, stagedCut);
|
|
1239
|
-
};
|
|
1240
|
-
/** Park queued prompts one per stash entry, newest first so popping replays queue order. */
|
|
1241
|
-
const parkQueued = async (queued) => {
|
|
1242
|
-
for (const text of [...queued].reverse())
|
|
1243
|
-
await stash?.stashEditor(text);
|
|
1244
|
-
};
|
|
1245
|
-
/** Resolver for the turn/end an undo is waiting on, set only while waiting. */
|
|
1246
|
-
let turnSettled;
|
|
1247
|
-
/** Wait for the interrupted turn to close, bounded so undo can give up honestly. */
|
|
1248
|
-
const waitForTurnEnd = (timeoutMs) => new Promise(resolve => {
|
|
1249
|
-
const timer = setTimeout(() => {
|
|
1250
|
-
turnSettled = undefined;
|
|
1251
|
-
resolve(false);
|
|
1252
|
-
}, timeoutMs);
|
|
1253
|
-
turnSettled = () => {
|
|
1254
|
-
clearTimeout(timer);
|
|
1255
|
-
turnSettled = undefined;
|
|
1256
|
-
resolve(true);
|
|
1257
|
-
};
|
|
1258
|
-
});
|
|
1259
|
-
/**
|
|
1260
|
-
* Stop the running turn and empty the inbox so the cut lands on a closed
|
|
1261
|
-
* turn/end; queued prompts are parked first because cancelling drops them.
|
|
1262
|
-
*
|
|
1263
|
-
* Returns undefined when the reader's words cannot be kept: a queue with no
|
|
1264
|
-
* stash to park it in, or a turn that will not close. Both leave the cursor
|
|
1265
|
-
* untouched, so the transcript keeps showing what the model actually saw; a
|
|
1266
|
-
* number is how many queued prompts were parked before the turn was stopped.
|
|
1267
|
-
*/
|
|
1268
|
-
const settleForUndo = async () => {
|
|
1269
|
-
const queued = queuedPrompts();
|
|
1270
|
-
if (!turnOpen && queued.length === 0)
|
|
1271
|
-
return 0;
|
|
1272
|
-
if (queued.length > 0 && stash === undefined) {
|
|
1273
|
-
model.notice('queued prompts have no stash to park in; undo cancelled');
|
|
1274
|
-
tui.requestRender();
|
|
1275
|
-
return undefined;
|
|
1276
|
-
}
|
|
1277
|
-
agent?.interrupt();
|
|
1278
|
-
if (queued.length > 0)
|
|
1279
|
-
await parkQueued(queued);
|
|
1280
|
-
if (turnOpen && !(await waitForTurnEnd(UNDO_TURN_SETTLE_MS))) {
|
|
1281
|
-
model.notice('could not stop the turn; undo cancelled');
|
|
1282
|
-
tui.requestRender();
|
|
1283
|
-
return undefined;
|
|
1284
|
-
}
|
|
1285
|
-
return queued.length;
|
|
1286
|
-
};
|
|
1287
|
-
const runUndoCommand = () => {
|
|
1288
|
-
if (undoPending)
|
|
1289
|
-
return;
|
|
1290
|
-
undoPending = true;
|
|
1291
|
-
void (async () => {
|
|
1292
|
-
if (viewedSession !== activeSession) {
|
|
1293
|
-
model.notice('undo works on the session this terminal drives · ctrl+b comes back');
|
|
1294
|
-
tui.requestRender();
|
|
1295
|
-
return;
|
|
1296
|
-
}
|
|
1297
|
-
const turns = await currentTurns();
|
|
1298
|
-
if (undoState.hidden >= turns.length && !turnOpen) {
|
|
1299
|
-
model.notice('nothing to undo');
|
|
1300
|
-
tui.requestRender();
|
|
1301
|
-
return;
|
|
1302
|
-
}
|
|
1303
|
-
const draft = editor.getExpandedText();
|
|
1304
|
-
// Text this state itself restored is not the reader's draft, so undoing
|
|
1305
|
-
// again must not park it and end up with two copies.
|
|
1306
|
-
const holdsDraft = draft !== '' && draft !== undoState.lastRestored;
|
|
1307
|
-
if (holdsDraft && stash === undefined) {
|
|
1308
|
-
model.notice('the bar holds a draft and there is no stash to park it in; undo cancelled');
|
|
1309
|
-
tui.requestRender();
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
const parked = await settleForUndo();
|
|
1313
|
-
if (parked === undefined)
|
|
1314
|
-
return;
|
|
1315
|
-
const settled = await currentTurns();
|
|
1316
|
-
const next = undoStep(undoState, settled);
|
|
1317
|
-
if (next === undefined) {
|
|
1318
|
-
model.notice('nothing to undo');
|
|
1319
|
-
tui.requestRender();
|
|
1320
|
-
return;
|
|
1321
|
-
}
|
|
1322
|
-
if (holdsDraft) {
|
|
1323
|
-
await stash?.stashEditor(draft);
|
|
1324
|
-
model.notice('the draft in the bar was parked in the stash');
|
|
1325
|
-
}
|
|
1326
|
-
undoState = next;
|
|
1327
|
-
stagedCut = hiddenTail(next, settled)?.seedCount;
|
|
1328
|
-
await redrawStaged();
|
|
1329
|
-
editor.setText(next.lastRestored);
|
|
1330
|
-
// The parking count rides the undo notice: a notice of its own would be
|
|
1331
|
-
// replaced before the frame could show it.
|
|
1332
|
-
const parkedNote = parked === 0 ? '' : ` · ${parked} queued prompt${parked === 1 ? '' : 's'} parked in the stash`;
|
|
1333
|
-
model.notice(`undo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden${parkedNote} · prefix r redo`);
|
|
1334
|
-
tui.requestRender();
|
|
1335
|
-
})()
|
|
1336
|
-
.catch((error) => {
|
|
1337
|
-
model.notice(`undo failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1338
|
-
tui.requestRender();
|
|
1339
|
-
})
|
|
1340
|
-
.finally(() => {
|
|
1341
|
-
undoPending = false;
|
|
1342
|
-
});
|
|
1343
|
-
};
|
|
1344
|
-
const runRedoCommand = () => {
|
|
1345
|
-
void (async () => {
|
|
1346
|
-
if (viewedSession !== activeSession) {
|
|
1347
|
-
model.notice('redo works on the session this terminal drives · ctrl+b comes back');
|
|
1348
|
-
tui.requestRender();
|
|
1349
|
-
return;
|
|
1350
|
-
}
|
|
1351
|
-
const turns = await currentTurns();
|
|
1352
|
-
const before = editor.getExpandedText();
|
|
1353
|
-
const restored = undoState.lastRestored;
|
|
1354
|
-
const next = redoStep(undoState, turns);
|
|
1355
|
-
if (next === undefined) {
|
|
1356
|
-
model.notice('nothing to redo');
|
|
1357
|
-
tui.requestRender();
|
|
1358
|
-
return;
|
|
1359
|
-
}
|
|
1360
|
-
undoState = next;
|
|
1361
|
-
stagedCut = hiddenTail(next, turns)?.seedCount;
|
|
1362
|
-
await redrawStaged();
|
|
1363
|
-
// A composer the reader edited is theirs; only text this state wrote is replaced.
|
|
1364
|
-
if (before === restored)
|
|
1365
|
-
editor.setText(next.lastRestored);
|
|
1366
|
-
model.notice(next.hidden === 0
|
|
1367
|
-
? 'redo · back at the newest prompt'
|
|
1368
|
-
: `redo · ${next.hidden} prompt${next.hidden === 1 ? '' : 's'} hidden`);
|
|
1369
|
-
tui.requestRender();
|
|
1370
|
-
})().catch((error) => {
|
|
1371
|
-
model.notice(`redo failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1372
|
-
tui.requestRender();
|
|
1373
|
-
});
|
|
1374
|
-
};
|
|
1375
|
-
/**
|
|
1376
|
-
* Send the reader's prompt, branching first when the transcript is staged.
|
|
1377
|
-
*
|
|
1378
|
-
* Context is derived from the log, so a send while turns are hidden has to
|
|
1379
|
-
* continue in a child seeded with the visible prefix; submitting in place would
|
|
1380
|
-
* show the model the prompts the reader undid. Every send passes through here so
|
|
1381
|
-
* the branch has one commit point, not one per caller.
|
|
1382
|
-
*/
|
|
1383
|
-
const commitStagedSend = async (text) => {
|
|
1384
|
-
try {
|
|
1385
|
-
const previous = agent;
|
|
1386
|
-
if (previous === undefined) {
|
|
1387
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
1388
|
-
tui.requestRender();
|
|
1389
|
-
return;
|
|
1390
|
-
}
|
|
1391
|
-
if (undoState.hidden === 0) {
|
|
1392
|
-
previous.submit(text);
|
|
1393
|
-
return;
|
|
1394
|
-
}
|
|
1395
|
-
const source = activeSession;
|
|
1396
|
-
const events = await sessionEvents(source);
|
|
1397
|
-
const turns = turnsOf(events);
|
|
1398
|
-
const tail = hiddenTail(undoState, turns);
|
|
1399
|
-
const seed = tail === undefined ? [] : events.slice(0, tail.seedCount);
|
|
1400
|
-
const childId = SessionId(`tui-session-${randomUUID()}`);
|
|
1401
|
-
agent = undefined;
|
|
1402
|
-
turnOpen = false;
|
|
1403
|
-
turnStartedAt = undefined;
|
|
1404
|
-
presentScope = undefined;
|
|
1405
|
-
model.reset();
|
|
1406
|
-
work.reset();
|
|
1407
|
-
roster.reset();
|
|
1408
|
-
await previous.dispose();
|
|
1409
|
-
const opened = await openAgent(childId, false, seed.length === 0 ? undefined : { from: source, events: seed });
|
|
1410
|
-
model.notice(`continuing in a new branch · ${source} keeps the undone turns`);
|
|
1411
|
-
opened.submit(text);
|
|
1412
|
-
}
|
|
1413
|
-
catch (error) {
|
|
1414
|
-
model.notice(`could not send: ${error instanceof Error ? error.message : String(error)}`);
|
|
1415
|
-
tui.requestRender();
|
|
1416
|
-
}
|
|
1417
|
-
};
|
|
1418
|
-
/**
|
|
1419
|
-
* The mode one agent joins.
|
|
1420
|
-
*
|
|
1421
|
-
* A resumed session keeps the composition its own log recorded, because
|
|
1422
|
-
* swapping it would leave logged tool calls the new composition cannot make —
|
|
1423
|
-
* the same reason the harness refuses a switch once a turn has run. A session
|
|
1424
|
-
* that recorded none, which is every session written before modes existed,
|
|
1425
|
-
* takes the seat; a branch inherits the mode its parent is running.
|
|
1426
|
-
*/
|
|
1427
|
-
const presetFor = async (id, resume, fork) => {
|
|
1428
|
-
if (agentPresets === undefined)
|
|
1429
|
-
return undefined;
|
|
1430
|
-
if (fork !== undefined)
|
|
1431
|
-
return agentPresets.current(liveSession(fork.from)) ?? seatMode();
|
|
1432
|
-
if (!resume)
|
|
1433
|
-
return seatMode();
|
|
1434
|
-
const history = createSessionHistory(ctx);
|
|
1435
|
-
const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
|
|
1436
|
-
if (stored === undefined)
|
|
1437
|
-
return seatMode();
|
|
1438
|
-
const resolvedStored = await resolveStored(id, stored);
|
|
1439
|
-
if (resolvedStored === undefined) {
|
|
1440
|
-
// The composition this session recorded is gone. Naming one explicitly is
|
|
1441
|
-
// the reader's only way forward, so that is the one case an override is
|
|
1442
|
-
// taken for a stored session.
|
|
1443
|
-
if (requestedPreset !== undefined)
|
|
1444
|
-
return (await agentPresets.resolve(requestedPreset)).id;
|
|
1445
|
-
throw new Error(`session ${id} runs mode "${stored}", which this roster no longer offers; name another with --preset`);
|
|
1446
|
-
}
|
|
1447
|
-
if (requestedPreset !== undefined && requestedPreset !== resolvedStored) {
|
|
1448
|
-
throw new Error(`session ${id} runs mode "${resolvedStored}", so --preset ${requestedPreset} does not apply; /preset ${requestedPreset} switches it before its first turn`);
|
|
1449
|
-
}
|
|
1450
|
-
return resolvedStored;
|
|
1451
|
-
};
|
|
1452
|
-
/** The stored mode's roster row, or undefined when the roster no longer offers it. */
|
|
1453
|
-
const resolveStored = async (id, stored) => {
|
|
1454
|
-
try {
|
|
1455
|
-
return (await agentPresets?.resolve(stored))?.id;
|
|
1456
|
-
}
|
|
1457
|
-
catch {
|
|
1458
|
-
return undefined;
|
|
1459
|
-
}
|
|
1460
|
-
};
|
|
1461
|
-
const openAgent = async (id, resume, fork) => {
|
|
1462
|
-
// Settled before the transcript is touched, so a refusal leaves neither a
|
|
1463
|
-
// half-replayed session nor a half-composed agent behind.
|
|
1464
|
-
const preset = await presetFor(id, resume, fork);
|
|
1465
|
-
const handle = await startAgent(ctx, {
|
|
1466
|
-
sessionId: id,
|
|
1467
|
-
resume,
|
|
1468
|
-
model: resolved.model,
|
|
1469
|
-
provider: resolved.provider,
|
|
1470
|
-
cwd: process.cwd(),
|
|
1471
|
-
preset,
|
|
1472
|
-
setup: async (agentCtx) => {
|
|
1473
|
-
modelSwitch.install(agentCtx);
|
|
1474
|
-
if (preset !== undefined)
|
|
1475
|
-
await agentPresets?.mount(agentCtx, preset);
|
|
1476
|
-
},
|
|
1477
|
-
...(fork === undefined ? {} : { fork }),
|
|
1478
|
-
});
|
|
1479
|
-
sessionOpened = true;
|
|
1480
|
-
activeSession = id;
|
|
1481
|
-
viewedSession = id;
|
|
1482
|
-
agent = handle;
|
|
1483
|
-
// A cursor counts the turns of one log; the session just opened has its own.
|
|
1484
|
-
undoState = resetUndo(id);
|
|
1485
|
-
stagedCut = undefined;
|
|
1486
|
-
// The bank follows the session, so the footer stops counting the drafts of
|
|
1487
|
-
// the session just left and the next command reads this session's file.
|
|
1488
|
-
void stash?.open();
|
|
1489
|
-
// agent/status is emitted on transitions only, so a driver that was
|
|
1490
|
-
// already running when this surface attached — a resume that wakes
|
|
1491
|
-
// straight away — would otherwise stay unreported until it stops. The
|
|
1492
|
-
// status is read once here and settled before the session identity, so the
|
|
1493
|
-
// report that carries the new session id states the driver's real phase
|
|
1494
|
-
// rather than the one the previous session left behind.
|
|
1495
|
-
herdr.driver(handle.agent.status);
|
|
1496
|
-
// The agent's own id is reported rather than the requested one: a resume can
|
|
1497
|
-
// be answered by the session the log actually holds.
|
|
1498
|
-
herdr.session({
|
|
1499
|
-
id: String(handle.sessionId),
|
|
1500
|
-
cwd: process.cwd(),
|
|
1501
|
-
reason: sessionStartReason({ forked: fork !== undefined, resumed: resume }),
|
|
1502
|
-
});
|
|
1503
|
-
// A session with no history to fold still has to present its first live
|
|
1504
|
-
// card through the right scope, so the scope is set before any event can.
|
|
1505
|
-
presentScope = handle.agent;
|
|
1506
|
-
// Replayed only once the agent exists, because the fold reads every card
|
|
1507
|
-
// through the scope the preset mounted; a fold before that scope existed
|
|
1508
|
-
// degraded each replayed card to a bare generic row. The agent's loop is
|
|
1509
|
-
// live by now, so the fold and the stream race over the same events; the
|
|
1510
|
-
// durable sequence number is what keeps one event from landing twice.
|
|
1511
|
-
if (resume)
|
|
1512
|
-
await replayHistory(id);
|
|
1513
|
-
// A branch inherits the conversation the reader was already reading, so it
|
|
1514
|
-
// opens on that history rather than on an empty screen.
|
|
1515
|
-
if (fork !== undefined)
|
|
1516
|
-
await foldHistory(id);
|
|
1517
|
-
disposers.push(() => {
|
|
1518
|
-
void handle.dispose();
|
|
1519
|
-
});
|
|
1520
|
-
installCompletion();
|
|
1521
|
-
model.notice(`session ${handle.sessionId}${resume ? ' (resumed)' : ''}`);
|
|
1522
|
-
tui.requestRender();
|
|
1523
|
-
// Returned so a caller that replaced the handle can send through the new
|
|
1524
|
-
// one without a fresh read of state TypeScript can no longer widen.
|
|
1525
|
-
return handle;
|
|
1526
|
-
};
|
|
1527
|
-
/** Move the surface to another stored session without leaving the terminal. */
|
|
1528
|
-
const switchSession = async (id) => {
|
|
1529
|
-
const previous = agent;
|
|
1530
|
-
agent = undefined;
|
|
1531
|
-
// Drop the outgoing scope before its agent is disposed, so no card folded
|
|
1532
|
-
// during the transition can read a torn-down world.
|
|
1533
|
-
presentScope = undefined;
|
|
1534
|
-
turnOpen = false;
|
|
1535
|
-
// The outgoing turn's clock dies with its agent; leaving it set would time
|
|
1536
|
-
// the session being joined by work it never ran.
|
|
1537
|
-
turnStartedAt = undefined;
|
|
1538
|
-
model.reset();
|
|
1539
|
-
work.reset();
|
|
1540
|
-
roster.reset();
|
|
1541
|
-
if (previous !== undefined)
|
|
1542
|
-
await previous.dispose();
|
|
1543
|
-
await openAgent(id, true);
|
|
1544
|
-
};
|
|
1545
|
-
/**
|
|
1546
|
-
* Show or choose the route the next step will use.
|
|
1547
|
-
*
|
|
1548
|
-
* The choice is session-scoped: it changes nothing about the settings a later
|
|
1549
|
-
* run reads, and the loop logs its own durable notice when the route a request
|
|
1550
|
-
* actually used changes.
|
|
1551
|
-
*/
|
|
1552
|
-
/**
|
|
1553
|
-
* Show, read, or kill a background job.
|
|
1554
|
-
*
|
|
1555
|
-
* A terminal has no second window, so a job started by the model is otherwise
|
|
1556
|
-
* invisible: the board is the only place a reader can see what is still
|
|
1557
|
-
* running and stop it.
|
|
1558
|
-
*/
|
|
1559
|
-
/**
|
|
1560
|
-
* List or stop the delegations this session started.
|
|
1561
|
-
*
|
|
1562
|
-
* A child runs in the same process as an ordinary agent, so a stop is the
|
|
1563
|
-
* cancel a reader's Ctrl+C sends to the parent; nothing here reaches into the
|
|
1564
|
-
* child's own session, which keeps its own transcript either way.
|
|
1565
|
-
*/
|
|
1566
|
-
const runSubagentsCommand = (argument) => {
|
|
1567
|
-
const command = parseSubagentsArgument(argument);
|
|
1568
|
-
switch (command.kind) {
|
|
1569
|
-
case 'list':
|
|
1570
|
-
model.notice(describeSubagents(roster.list(), Date.now()));
|
|
1571
|
-
tui.requestRender();
|
|
1572
|
-
return;
|
|
1573
|
-
case 'open': {
|
|
1574
|
-
const run = resolveRun(roster.list(), command.id);
|
|
1575
|
-
if (run === undefined) {
|
|
1576
|
-
model.notice(`${command.id}: no single child matches; /subagents lists them`);
|
|
1577
|
-
tui.requestRender();
|
|
1578
|
-
return;
|
|
1579
|
-
}
|
|
1580
|
-
void showSession(SessionId(run.id));
|
|
1581
|
-
return;
|
|
1582
|
-
}
|
|
1583
|
-
case 'kill': {
|
|
1584
|
-
const stopped = subagentControl?.stop(command.id) ?? false;
|
|
1585
|
-
model.notice(stopped
|
|
1586
|
-
? `${command.id}: stop requested`
|
|
1587
|
-
: `${command.id}: no live child with that id`);
|
|
1588
|
-
tui.requestRender();
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
case 'invalid':
|
|
1592
|
-
model.notice(`/subagents: ${command.reason}`);
|
|
1593
|
-
tui.requestRender();
|
|
1594
|
-
return;
|
|
1595
|
-
}
|
|
1596
|
-
};
|
|
1597
|
-
const runJobsCommand = (argument) => {
|
|
1598
|
-
if (jobDirectory === undefined) {
|
|
1599
|
-
model.notice('this profile has no job registry, so there is nothing to list');
|
|
1600
|
-
tui.requestRender();
|
|
1601
|
-
return;
|
|
1602
|
-
}
|
|
1603
|
-
if (agent === undefined) {
|
|
1604
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
1605
|
-
tui.requestRender();
|
|
1606
|
-
return;
|
|
1607
|
-
}
|
|
1608
|
-
const command = parseJobsArgument(argument);
|
|
1609
|
-
switch (command.kind) {
|
|
1610
|
-
case 'list':
|
|
1611
|
-
refreshJobs();
|
|
1612
|
-
model.notice(describeJobs(jobs, Date.now()));
|
|
1613
|
-
tui.requestRender();
|
|
1614
|
-
return;
|
|
1615
|
-
case 'read': {
|
|
1616
|
-
const result = jobDirectory.read(agent.agent, command.id);
|
|
1617
|
-
const text = result?.text.trim() ?? '';
|
|
1618
|
-
model.notice(text === ''
|
|
1619
|
-
? `${command.id}: no output yet`
|
|
1620
|
-
: `${command.id} output\n${text.split('\n').slice(-JOB_READ_LINES).join('\n')}`);
|
|
1621
|
-
refreshJobs();
|
|
1622
|
-
return;
|
|
1623
|
-
}
|
|
1624
|
-
case 'kill': {
|
|
1625
|
-
const outcome = jobDirectory.kill(agent.agent, command.id);
|
|
1626
|
-
model.notice(outcome === undefined
|
|
1627
|
-
? `${command.id}: no such job`
|
|
1628
|
-
: outcome === 'requested' ? `${command.id}: stop requested` : `${command.id} had already finished`);
|
|
1629
|
-
refreshJobs();
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
case 'invalid':
|
|
1633
|
-
model.notice(`/jobs: ${command.reason}`);
|
|
1634
|
-
tui.requestRender();
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
};
|
|
1638
|
-
/**
|
|
1639
|
-
* Give this session a title.
|
|
1640
|
-
*
|
|
1641
|
-
* The title is what the resume picker shows and what every other surface
|
|
1642
|
-
* displays, so a reader who has several sessions can name the one they are in
|
|
1643
|
-
* without waiting for the harness to guess.
|
|
1644
|
-
*/
|
|
1645
|
-
/**
|
|
1646
|
-
* Write what the reader can see to a file.
|
|
1647
|
-
*
|
|
1648
|
-
* The base's `/export` downloads the log through the browser, which a
|
|
1649
|
-
* terminal has no way to do, so this dumps the transcript the reader is
|
|
1650
|
-
* looking at — the thing worth pasting into a message.
|
|
1651
|
-
*/
|
|
1652
|
-
const runExportCommand = (argument) => {
|
|
1653
|
-
const path = resolve(argument === '' ? defaultExportFile(String(activeSession)) : argument);
|
|
1654
|
-
try {
|
|
1655
|
-
writeFileSync(path, transcriptToText(model.entries()), 'utf8');
|
|
1656
|
-
model.notice(`transcript written to ${path}`);
|
|
1657
|
-
}
|
|
1658
|
-
catch (error) {
|
|
1659
|
-
model.notice(`could not write ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1660
|
-
}
|
|
1661
|
-
tui.requestRender();
|
|
1662
|
-
};
|
|
1663
|
-
/**
|
|
1664
|
-
* Branch this conversation and continue in the branch.
|
|
1665
|
-
*
|
|
1666
|
-
* The branch is a real session with its own identity that inherits a prefix
|
|
1667
|
-
* of this one, so a reader can try something without spending the
|
|
1668
|
-
* conversation they already had. The cut is anchored to the last completed
|
|
1669
|
-
* turn, because half an exchange is not a state to hand a model.
|
|
1670
|
-
*/
|
|
1671
|
-
/** Start a fresh session without leaving the terminal. */
|
|
1672
|
-
const runNewCommand = (title) => {
|
|
1673
|
-
if (agent === undefined) {
|
|
1674
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
1675
|
-
tui.requestRender();
|
|
1676
|
-
return;
|
|
1677
|
-
}
|
|
1678
|
-
void (async () => {
|
|
1679
|
-
const previous = agent;
|
|
1680
|
-
agent = undefined;
|
|
1681
|
-
turnOpen = false;
|
|
1682
|
-
turnStartedAt = undefined;
|
|
1683
|
-
model.reset();
|
|
1684
|
-
work.reset();
|
|
1685
|
-
roster.reset();
|
|
1686
|
-
if (previous !== undefined)
|
|
1687
|
-
await previous.dispose();
|
|
1688
|
-
await openAgent(SessionId(`tui-session-${randomUUID()}`), false);
|
|
1689
|
-
if (title !== '')
|
|
1690
|
-
runRenameCommand(title);
|
|
1691
|
-
model.notice('started a new session');
|
|
1692
|
-
tui.requestRender();
|
|
1693
|
-
})().catch((error) => {
|
|
1694
|
-
model.notice(`could not start a session: ${error instanceof Error ? error.message : String(error)}`);
|
|
1695
|
-
tui.requestRender();
|
|
1696
|
-
});
|
|
1697
|
-
};
|
|
1698
|
-
/**
|
|
1699
|
-
* Compose this session's agent again without leaving the conversation.
|
|
1700
|
-
*
|
|
1701
|
-
* A preset's standing mount only re-reads its composition file for an agent
|
|
1702
|
-
* that joins after the file changed, so an edited preset, skill, or prompt
|
|
1703
|
-
* file reaches a running session only by joining anew. The durable log is
|
|
1704
|
-
* replayed afterwards, so the reader keeps the conversation they were reading.
|
|
1705
|
-
*/
|
|
1706
|
-
const runReloadCommand = () => {
|
|
1707
|
-
// A failed reload leaves no agent behind, so the command has to be usable
|
|
1708
|
-
// again: the retry is what makes a broken composition file recoverable,
|
|
1709
|
-
// while a surface that has opened no session yet is still starting.
|
|
1710
|
-
if (agent === undefined && !sessionOpened) {
|
|
1711
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
1712
|
-
tui.requestRender();
|
|
1713
|
-
return;
|
|
1714
|
-
}
|
|
1715
|
-
const queued = queuedPrompts();
|
|
1716
|
-
// Work the reader would lose is a decision, and the interrupt key already
|
|
1717
|
-
// owns that decision: it stops the turn and hands queued words back to the
|
|
1718
|
-
// bar. Reopening the session drops the inbox, so a reload that would take
|
|
1719
|
-
// those words asks for the key instead of asking a question of its own.
|
|
1720
|
-
if (turnOpen || queued.length > 0) {
|
|
1721
|
-
model.notice(queued.length > 0
|
|
1722
|
-
? `${queued.length} queued ${queued.length === 1 ? 'prompt' : 'prompts'} would be dropped — ctrl+c hands them back to the bar, then /reload`
|
|
1723
|
-
: 'a turn is running — ctrl+c interrupts it first (delivered text is kept), then /reload');
|
|
1724
|
-
tui.requestRender();
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
const id = activeSession;
|
|
1728
|
-
void (async () => {
|
|
1729
|
-
// The same transition a session switch takes, aimed at the session
|
|
1730
|
-
// already open: dispose, then join its preset generation anew.
|
|
1731
|
-
await switchSession(id);
|
|
1732
|
-
model.notice("reloaded this session's composition; the transcript was replayed");
|
|
1733
|
-
tui.requestRender();
|
|
1734
|
-
})().catch((error) => {
|
|
1735
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
1736
|
-
model.notice(`could not reload: ${reason} — fix the composition and /reload again`);
|
|
1737
|
-
tui.requestRender();
|
|
1738
|
-
});
|
|
1739
|
-
};
|
|
1740
|
-
/** Show the todo list the agent has been keeping. */
|
|
1741
|
-
const runTodoCommand = () => {
|
|
1742
|
-
model.notice(describeTodos(work.state().todos));
|
|
1743
|
-
tui.requestRender();
|
|
1744
|
-
};
|
|
1745
|
-
/**
|
|
1746
|
-
* Put the last answer on the reader's clipboard.
|
|
1747
|
-
*
|
|
1748
|
-
* The clipboard belongs to the terminal, so this asks it through OSC 52 —
|
|
1749
|
-
* which is also the only route that works over SSH.
|
|
1750
|
-
*/
|
|
1751
|
-
const runCopyCommand = () => {
|
|
1752
|
-
const last = [...model.entries()].reverse().find(entry => entry.kind === 'assistant');
|
|
1753
|
-
if (last === undefined || last.kind !== 'assistant') {
|
|
1754
|
-
model.notice('nothing to copy yet');
|
|
1755
|
-
tui.requestRender();
|
|
1756
|
-
return;
|
|
1757
|
-
}
|
|
1758
|
-
terminal.write(clipboardSequence(last.text));
|
|
1759
|
-
model.notice(`copied ${last.text.length} characters through the terminal`);
|
|
1760
|
-
tui.requestRender();
|
|
1761
|
-
};
|
|
1762
|
-
const runForkCommand = (title) => {
|
|
1763
|
-
if (agent === undefined) {
|
|
1764
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
1765
|
-
tui.requestRender();
|
|
1766
|
-
return;
|
|
1767
|
-
}
|
|
1768
|
-
void (async () => {
|
|
1769
|
-
const source = activeSession;
|
|
1770
|
-
const events = await sessionEvents(source);
|
|
1771
|
-
const point = forkPoint(events);
|
|
1772
|
-
if (point === undefined) {
|
|
1773
|
-
model.notice('nothing to fork yet: this session has no completed turn');
|
|
1774
|
-
tui.requestRender();
|
|
1775
|
-
return;
|
|
1776
|
-
}
|
|
1777
|
-
const childId = SessionId(`tui-session-${randomUUID()}`);
|
|
1778
|
-
const previous = agent;
|
|
1779
|
-
agent = undefined;
|
|
1780
|
-
turnOpen = false;
|
|
1781
|
-
turnStartedAt = undefined;
|
|
1782
|
-
model.reset();
|
|
1783
|
-
work.reset();
|
|
1784
|
-
roster.reset();
|
|
1785
|
-
if (previous !== undefined)
|
|
1786
|
-
await previous.dispose();
|
|
1787
|
-
await openAgent(childId, false, { from: source, events: events.slice(0, point.inheritedEvents) });
|
|
1788
|
-
if (title !== '')
|
|
1789
|
-
runRenameCommand(title);
|
|
1790
|
-
model.notice(`forked from ${source} at event ${point.boundarySeq} — ${point.inheritedEvents} inherited`);
|
|
1791
|
-
tui.requestRender();
|
|
1792
|
-
})().catch((error) => {
|
|
1793
|
-
model.notice(`could not fork: ${error instanceof Error ? error.message : String(error)}`);
|
|
1794
|
-
tui.requestRender();
|
|
1795
|
-
});
|
|
1796
|
-
};
|
|
1797
|
-
const runRenameCommand = (title) => {
|
|
1798
|
-
if (title === '') {
|
|
1799
|
-
model.notice('use /rename <title>; the title is what the resume picker shows');
|
|
1800
|
-
tui.requestRender();
|
|
1801
|
-
return;
|
|
1802
|
-
}
|
|
1803
|
-
const session = ctx.get('sessions')?.get?.(activeSession);
|
|
1804
|
-
const titles = ctx.get('sessionTitle');
|
|
1805
|
-
if (session === undefined || typeof titles?.rename !== 'function') {
|
|
1806
|
-
model.notice('this profile has no session-title service, so this session cannot be renamed');
|
|
1807
|
-
tui.requestRender();
|
|
1808
|
-
return;
|
|
1809
|
-
}
|
|
1810
|
-
try {
|
|
1811
|
-
const accepted = titles.rename(session, title);
|
|
1812
|
-
model.notice(`session renamed to "${typeof accepted?.title === 'string' ? accepted.title : title}"`);
|
|
1813
|
-
}
|
|
1814
|
-
catch (error) {
|
|
1815
|
-
model.notice(`could not rename: ${error instanceof Error ? error.message : String(error)}`);
|
|
1816
|
-
}
|
|
1817
|
-
tui.requestRender();
|
|
1818
|
-
};
|
|
1819
|
-
const runModelCommand = (argument) => {
|
|
1820
|
-
if (catalog === undefined) {
|
|
1821
|
-
model.notice('this profile has no llm service, so models cannot be listed or switched');
|
|
1822
|
-
tui.requestRender();
|
|
1823
|
-
return;
|
|
1824
|
-
}
|
|
1825
|
-
const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
|
|
1826
|
-
switch (command.kind) {
|
|
1827
|
-
case 'current':
|
|
1828
|
-
// Choosing by eye is the point of a terminal selector; the picker
|
|
1829
|
-
// heads itself with the route the next step will actually use.
|
|
1830
|
-
void openModelPicker();
|
|
1831
|
-
return;
|
|
1832
|
-
case 'list-models':
|
|
1833
|
-
void catalog.models(command.provider).then(entries => {
|
|
1834
|
-
model.notice(entries.length === 0
|
|
1835
|
-
? `${command.provider} advertises no models; an id may still work`
|
|
1836
|
-
: `${command.provider}: ${entries.map(entry => entry.id).join(' ')}`);
|
|
1837
|
-
tui.requestRender();
|
|
1838
|
-
}).catch((error) => {
|
|
1839
|
-
model.notice(`could not list models: ${error instanceof Error ? error.message : String(error)}`);
|
|
1840
|
-
tui.requestRender();
|
|
1841
|
-
});
|
|
1842
|
-
return;
|
|
1843
|
-
case 'switch': {
|
|
1844
|
-
const choice = command.choice;
|
|
1845
|
-
if (choice.reasoningEffort === undefined) {
|
|
1846
|
-
modelSwitch.choose(choice);
|
|
1847
|
-
model.notice(`model set to ${choice.provider}/${choice.model} for the next step`);
|
|
1848
|
-
tui.requestRender();
|
|
1849
|
-
return;
|
|
1850
|
-
}
|
|
1851
|
-
// The route decides which efforts exist, so an explicit one is checked
|
|
1852
|
-
// against the adapter before it is put in force: a typo must not become
|
|
1853
|
-
// a request the provider rejects.
|
|
1854
|
-
void (async () => {
|
|
1855
|
-
try {
|
|
1856
|
-
const info = await catalog.efforts(choice.provider, choice.model);
|
|
1857
|
-
const efforts = info?.efforts ?? [];
|
|
1858
|
-
if (!efforts.some(effort => effort.id === choice.reasoningEffort)) {
|
|
1859
|
-
model.notice(efforts.length === 0
|
|
1860
|
-
? `/model: ${choice.provider}/${choice.model} advertises no reasoning efforts`
|
|
1861
|
-
: `/model: ${choice.provider}/${choice.model} does not offer reasoning effort "${choice.reasoningEffort}" — offers: ${efforts.map(effort => effort.id).join(' ')}`);
|
|
1862
|
-
tui.requestRender();
|
|
1863
|
-
return;
|
|
1864
|
-
}
|
|
1865
|
-
modelSwitch.choose(choice);
|
|
1866
|
-
model.notice(`model set to ${choice.provider}/${choice.model} (${choice.reasoningEffort}) for the next step`);
|
|
1867
|
-
}
|
|
1868
|
-
catch (error) {
|
|
1869
|
-
model.notice(`/model: could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1870
|
-
}
|
|
1871
|
-
tui.requestRender();
|
|
1872
|
-
})();
|
|
1873
|
-
return;
|
|
1874
|
-
}
|
|
1875
|
-
case 'invalid':
|
|
1876
|
-
model.notice(`/model: ${command.reason}`);
|
|
1877
|
-
tui.requestRender();
|
|
1878
|
-
return;
|
|
1879
|
-
}
|
|
1880
|
-
};
|
|
1881
|
-
/** Whether a route's effort list is being read, so a second key cannot race it. */
|
|
1882
|
-
let openingEfforts = false;
|
|
1883
|
-
/** Put the reader's effort choice in force for the next step. */
|
|
1884
|
-
const applyEffort = (provider, modelId, effortId) => {
|
|
1885
|
-
modelSwitch.choose(effortId === PROVIDER_DEFAULT_EFFORT_ID
|
|
1886
|
-
? { provider, model: modelId }
|
|
1887
|
-
: { provider, model: modelId, reasoningEffort: effortId });
|
|
1888
|
-
model.notice(`reasoning effort for ${provider}/${modelId} set to ${effortId === PROVIDER_DEFAULT_EFFORT_ID ? 'provider default' : effortId} for the next step`);
|
|
1889
|
-
tui.requestRender();
|
|
1890
|
-
};
|
|
1891
|
-
/** Whether a model picker's catalog is being read, so a second key cannot race it. */
|
|
1892
|
-
let openingModels = false;
|
|
1893
|
-
/**
|
|
1894
|
-
* The route the next step would actually use.
|
|
1895
|
-
*
|
|
1896
|
-
* Without a choice of its own the surface reports the composition default,
|
|
1897
|
-
* not that it has no opinion: the picker's heading and its marked row have to
|
|
1898
|
-
* agree with the status line about the route in force.
|
|
1899
|
-
*/
|
|
1900
|
-
const effectiveRoute = () => {
|
|
1901
|
-
const chosen = modelSwitch.current();
|
|
1902
|
-
if (chosen !== undefined)
|
|
1903
|
-
return chosen;
|
|
1904
|
-
const facts = statusFacts();
|
|
1905
|
-
if (facts.provider === undefined || facts.model === undefined)
|
|
1906
|
-
return undefined;
|
|
1907
|
-
return {
|
|
1908
|
-
provider: facts.provider,
|
|
1909
|
-
model: facts.model,
|
|
1910
|
-
...facts.effort === undefined ? {} : { reasoningEffort: facts.effort },
|
|
1911
|
-
};
|
|
1912
|
-
};
|
|
1913
|
-
/** Put the reader's route choice in force for the next step. */
|
|
1914
|
-
const applyRoute = (route) => {
|
|
1915
|
-
const current = effectiveRoute();
|
|
1916
|
-
// The levels belong to the route, so a switch clears an explicit effort
|
|
1917
|
-
// while re-picking the route already in force is not a switch.
|
|
1918
|
-
const keep = current !== undefined && current.provider === route.provider && current.model === route.model
|
|
1919
|
-
? current.reasoningEffort
|
|
1920
|
-
: undefined;
|
|
1921
|
-
modelSwitch.choose({
|
|
1922
|
-
provider: route.provider,
|
|
1923
|
-
model: route.model,
|
|
1924
|
-
...keep === undefined ? {} : { reasoningEffort: keep },
|
|
1925
|
-
});
|
|
1926
|
-
model.notice(`model set to ${route.provider}/${route.model} for the next step`);
|
|
1927
|
-
tui.requestRender();
|
|
1928
|
-
};
|
|
1929
|
-
/**
|
|
1930
|
-
* Offer the levels a route advertises, after the route is already in force.
|
|
1931
|
-
*
|
|
1932
|
-
* Cancelling the list is a real choice — the reader keeps the model with the
|
|
1933
|
-
* provider's own default — which is why the route is applied first. The list
|
|
1934
|
-
* is read before the picker opens because the rows are the route's own
|
|
1935
|
-
* metadata; a menu painted before that arrived could offer a level the
|
|
1936
|
-
* request would then be refused for.
|
|
1937
|
-
*/
|
|
1938
|
-
const offerRouteEfforts = async (route) => {
|
|
1939
|
-
if (catalog === undefined)
|
|
1940
|
-
return;
|
|
1941
|
-
try {
|
|
1942
|
-
const info = await catalog.efforts(route.provider, route.model);
|
|
1943
|
-
const efforts = info?.efforts ?? [];
|
|
1944
|
-
if (efforts.length === 0)
|
|
1945
|
-
return;
|
|
1946
|
-
const current = effectiveRoute();
|
|
1947
|
-
const effective = current !== undefined && current.provider === route.provider && current.model === route.model
|
|
1948
|
-
? current.reasoningEffort
|
|
1949
|
-
: undefined;
|
|
1950
|
-
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, effective), `reasoning effort · ${route.provider}/${route.model}`, () => keymap));
|
|
1951
|
-
if (picked !== undefined)
|
|
1952
|
-
applyEffort(route.provider, route.model, picked);
|
|
1953
|
-
}
|
|
1954
|
-
catch (error) {
|
|
1955
|
-
model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
1956
|
-
}
|
|
1957
|
-
tui.requestRender();
|
|
1958
|
-
};
|
|
1959
|
-
/**
|
|
1960
|
-
* Offer every model the configured routes advertise.
|
|
1961
|
-
*
|
|
1962
|
-
* Rows come from the routes the llm service registered — the providers this
|
|
1963
|
-
* deployment configured — and each provider's models join the open list as
|
|
1964
|
-
* its catalog resolves, so the picker is filterable before the slowest
|
|
1965
|
-
* adapter answers. A route whose catalog cannot be read stays reachable by
|
|
1966
|
-
* name through the text form rather than by an explanation in the list.
|
|
1967
|
-
*/
|
|
1968
|
-
const openModelPicker = async () => {
|
|
1969
|
-
if (catalog === undefined) {
|
|
1970
|
-
model.notice('this profile has no llm service, so models cannot be listed or switched');
|
|
1971
|
-
tui.requestRender();
|
|
1972
|
-
return;
|
|
1973
|
-
}
|
|
1974
|
-
const providers = catalog.providers();
|
|
1975
|
-
if (providers.length === 0) {
|
|
1976
|
-
model.notice('no provider is configured; add one before choosing a model');
|
|
1977
|
-
tui.requestRender();
|
|
1978
|
-
return;
|
|
1979
|
-
}
|
|
1980
|
-
if (openingModels)
|
|
1981
|
-
return;
|
|
1982
|
-
openingModels = true;
|
|
1983
|
-
try {
|
|
1984
|
-
const routes = [];
|
|
1985
|
-
for (const provider of providers) {
|
|
1986
|
-
void catalog.models(provider.id).then(entries => {
|
|
1987
|
-
if (entries.length === 0)
|
|
1988
|
-
return;
|
|
1989
|
-
routes.push(...entries.map(entry => ({ provider: provider.id, model: entry.id, name: entry.name })));
|
|
1990
|
-
tui.requestRender();
|
|
1991
|
-
}).catch(() => {
|
|
1992
|
-
// One adapter's discovery failure is not the list's to explain.
|
|
1993
|
-
});
|
|
1994
|
-
}
|
|
1995
|
-
const picked = await openPicker(new ModelPicker(() => routes, effectiveRoute, () => keymap));
|
|
1996
|
-
if (picked === undefined)
|
|
1997
|
-
return;
|
|
1998
|
-
const route = readModelRouteKey(picked);
|
|
1999
|
-
if (route === undefined)
|
|
2000
|
-
return;
|
|
2001
|
-
applyRoute(route);
|
|
2002
|
-
await offerRouteEfforts(route);
|
|
2003
|
-
}
|
|
2004
|
-
finally {
|
|
2005
|
-
openingModels = false;
|
|
2006
|
-
tui.requestRender();
|
|
2007
|
-
}
|
|
2008
|
-
};
|
|
2009
|
-
/**
|
|
2010
|
-
* Offer the efforts the route in force advertises.
|
|
2011
|
-
*
|
|
2012
|
-
* The list is read before the picker opens because the rows are the route's
|
|
2013
|
-
* own metadata; a menu painted before that arrived could offer a level the
|
|
2014
|
-
* request would then be refused for.
|
|
2015
|
-
*/
|
|
2016
|
-
const openEffortPicker = async () => {
|
|
2017
|
-
if (catalog === undefined) {
|
|
2018
|
-
model.notice('this profile has no llm service, so reasoning efforts cannot be read');
|
|
2019
|
-
tui.requestRender();
|
|
2020
|
-
return;
|
|
2021
|
-
}
|
|
2022
|
-
const facts = statusFacts();
|
|
2023
|
-
if (facts.provider === undefined || facts.model === undefined) {
|
|
2024
|
-
model.notice('no model route is in use; /model <provider>/<model> picks one first');
|
|
2025
|
-
tui.requestRender();
|
|
2026
|
-
return;
|
|
2027
|
-
}
|
|
2028
|
-
if (openingEfforts)
|
|
2029
|
-
return;
|
|
2030
|
-
openingEfforts = true;
|
|
2031
|
-
try {
|
|
2032
|
-
const info = await catalog.efforts(facts.provider, facts.model);
|
|
2033
|
-
const efforts = info?.efforts ?? [];
|
|
2034
|
-
if (efforts.length === 0) {
|
|
2035
|
-
model.notice(`${facts.provider}/${facts.model} advertises no reasoning efforts`);
|
|
2036
|
-
return;
|
|
2037
|
-
}
|
|
2038
|
-
const picked = await openPicker(new EffortPicker(() => effortChoices(efforts, facts.effort), `reasoning effort · ${facts.provider}/${facts.model}`, () => keymap));
|
|
2039
|
-
if (picked !== undefined)
|
|
2040
|
-
applyEffort(facts.provider, facts.model, picked);
|
|
2041
|
-
}
|
|
2042
|
-
catch (error) {
|
|
2043
|
-
model.notice(`could not read reasoning efforts: ${error instanceof Error ? error.message : String(error)}`);
|
|
2044
|
-
}
|
|
2045
|
-
finally {
|
|
2046
|
-
openingEfforts = false;
|
|
2047
|
-
tui.requestRender();
|
|
2048
|
-
}
|
|
2049
|
-
};
|
|
2050
|
-
/**
|
|
2051
|
-
* Show or choose the mode this session runs.
|
|
2052
|
-
*
|
|
2053
|
-
* The mode decides which tools, prompt sections, and skills exist at all, so
|
|
2054
|
-
* it is fixed once a turn has run: the harness refuses the swap and this
|
|
2055
|
-
* surface explains why rather than pretending otherwise. Before the first
|
|
2056
|
-
* turn the switch is recorded in the log, which is what keeps the transcript
|
|
2057
|
-
* honest about the composition later turns ran under.
|
|
2058
|
-
*/
|
|
2059
|
-
const runPresetCommand = (argument) => {
|
|
2060
|
-
if (agentPresets === undefined) {
|
|
2061
|
-
model.notice('this profile has no agent roster, so there is no mode to choose');
|
|
2062
|
-
tui.requestRender();
|
|
2063
|
-
return;
|
|
2064
|
-
}
|
|
2065
|
-
if (agent === undefined) {
|
|
2066
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
2067
|
-
tui.requestRender();
|
|
2068
|
-
return;
|
|
2069
|
-
}
|
|
2070
|
-
const session = agent.agent.session;
|
|
2071
|
-
const current = agentPresets.current(session);
|
|
2072
|
-
const command = parsePresetArgument(argument);
|
|
2073
|
-
if (command.kind === 'pick') {
|
|
2074
|
-
if (agentPresets.started(session)) {
|
|
2075
|
-
model.notice(`this session runs ${current === undefined ? 'a mode' : `"${current}"`} and has already started, so its mode is fixed — /new starts a fresh session`);
|
|
2076
|
-
tui.requestRender();
|
|
2077
|
-
return;
|
|
2078
|
-
}
|
|
2079
|
-
void askForPreset(current).then(picked => picked === undefined ? undefined : applyPreset(picked));
|
|
2080
|
-
return;
|
|
2081
|
-
}
|
|
2082
|
-
void applyPreset(command.id);
|
|
2083
|
-
};
|
|
2084
|
-
/** Join this session to another mode and remember it for the next session. */
|
|
2085
|
-
const applyPreset = async (id) => {
|
|
2086
|
-
if (agentPresets === undefined || agent === undefined)
|
|
2087
|
-
return;
|
|
2088
|
-
try {
|
|
2089
|
-
const chosen = await agentPresets.select(agent.agent, id);
|
|
2090
|
-
seat = chosen;
|
|
2091
|
-
// The durable selection is folded as a marker on the session it belongs
|
|
2092
|
-
// to, so a reader watching a child has to come back to see it.
|
|
2093
|
-
if (viewedSession !== activeSession)
|
|
2094
|
-
await showSession(activeSession);
|
|
2095
|
-
}
|
|
2096
|
-
catch (error) {
|
|
2097
|
-
model.notice(`could not switch the mode: ${error instanceof Error ? error.message : String(error)}`);
|
|
2098
|
-
}
|
|
2099
|
-
tui.requestRender();
|
|
2100
|
-
};
|
|
2101
|
-
const helpText = () => {
|
|
2102
|
-
const current = agent?.agent;
|
|
2103
|
-
const registered = current === undefined || registry() === undefined
|
|
2104
|
-
? []
|
|
2105
|
-
: registry()?.list(current).map(command => `/${command.name}`) ?? [];
|
|
2106
|
-
const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
|
|
2107
|
-
return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${surfaceKeysLine(keymap)} · ${chordKeysLine(keymap)}`;
|
|
2108
|
-
};
|
|
2109
|
-
/**
|
|
2110
|
-
* Whether the agent this surface is driving is in plan mode.
|
|
2111
|
-
*
|
|
2112
|
-
* The dock's fold is the fallback, for a composition without the plan
|
|
2113
|
-
* package: with the controller present its answer is the agent's own state
|
|
2114
|
-
* rather than a replay of the events this surface happened to see.
|
|
2115
|
-
*/
|
|
2116
|
-
const planState = () => {
|
|
2117
|
-
const current = agent?.agent;
|
|
2118
|
-
if (current === undefined)
|
|
2119
|
-
return undefined;
|
|
2120
|
-
const presets = ctx.get('agentPresets');
|
|
2121
|
-
return readPlanState({
|
|
2122
|
-
direct: name => ctx.get(name),
|
|
2123
|
-
forAgent: (target, name) => presets?.serviceFor(target, name),
|
|
2124
|
-
}, current);
|
|
2125
|
-
};
|
|
2126
|
-
const planActive = () => planSelectedActive(planState(), work.state().planMode);
|
|
2127
|
-
const runCommand = (name, line) => {
|
|
2128
|
-
const current = agent;
|
|
2129
|
-
const commands = registry();
|
|
2130
|
-
if (current === undefined) {
|
|
2131
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
2132
|
-
tui.requestRender();
|
|
2133
|
-
return;
|
|
2134
|
-
}
|
|
2135
|
-
if (commands === undefined || commands.find(current.agent, name) === undefined) {
|
|
2136
|
-
model.notice(`unknown command: /${name} — ${helpText()}`);
|
|
2137
|
-
tui.requestRender();
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
const controller = new AbortController();
|
|
2141
|
-
void commands.execute(current.agent, line, [], controller.signal).then(execution => {
|
|
2142
|
-
const result = execution?.result;
|
|
2143
|
-
if (result === undefined)
|
|
2144
|
-
return;
|
|
2145
|
-
model.notice(result.kind === 'error' ? `/${name} failed: ${result.text ?? 'no detail'}` : `/${name} ${result.text ?? 'done'}`);
|
|
2146
|
-
tui.requestRender();
|
|
2147
|
-
}).catch((error) => {
|
|
2148
|
-
model.notice(`/${name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2149
|
-
tui.requestRender();
|
|
2150
|
-
});
|
|
2151
|
-
};
|
|
2152
|
-
/**
|
|
2153
|
-
* Put the deployment default in force before the first turn.
|
|
2154
|
-
*
|
|
2155
|
-
* The agent is created before the settings file has been read, so the route
|
|
2156
|
-
* its loop captured is the composition placeholder; the reader's default —
|
|
2157
|
-
* effort included — only exists by the time they can type. Adopting it here
|
|
2158
|
-
* is what makes the status line's route the one the request actually uses.
|
|
2159
|
-
*/
|
|
2160
|
-
const adoptDefaultRoute = () => {
|
|
2161
|
-
if (modelSwitch.current() !== undefined)
|
|
2162
|
-
return;
|
|
2163
|
-
const facts = statusFacts();
|
|
2164
|
-
if (facts.provider === undefined || facts.model === undefined)
|
|
2165
|
-
return;
|
|
2166
|
-
modelSwitch.adopt({
|
|
2167
|
-
provider: facts.provider,
|
|
2168
|
-
model: facts.model,
|
|
2169
|
-
...(facts.effort === undefined ? {} : { reasoningEffort: facts.effort }),
|
|
2170
|
-
});
|
|
2171
|
-
};
|
|
2172
|
-
/** Show where history is kept, or forget it; the file is global to this machine. */
|
|
2173
|
-
const runHistoryCommand = (argument) => {
|
|
2174
|
-
// The line that asked for this is recorded before the command runs, but that
|
|
2175
|
-
// write rides the store's queue; waiting for it lets the count describe the
|
|
2176
|
-
// file the reader has, not the state before their own line landed.
|
|
2177
|
-
void promptHistory.flush().then(() => {
|
|
2178
|
-
if (argument === '') {
|
|
2179
|
-
const count = promptHistory.entries().length;
|
|
2180
|
-
const blocked = promptHistory.blockedReason();
|
|
2181
|
-
model.notice([
|
|
2182
|
-
count + (count === 1 ? ' prompt recorded' : ' prompts recorded'),
|
|
2183
|
-
promptHistory.path(),
|
|
2184
|
-
blocked === undefined ? undefined : 'writes disabled: ' + blocked,
|
|
2185
|
-
].filter(part => part !== undefined).join(' · '));
|
|
2186
|
-
tui.requestRender();
|
|
2187
|
-
return;
|
|
2188
|
-
}
|
|
2189
|
-
if (argument !== 'clear') {
|
|
2190
|
-
model.notice('usage: /history shows where history is kept · /history clear forgets every prompt');
|
|
2191
|
-
tui.requestRender();
|
|
2192
|
-
return;
|
|
2193
|
-
}
|
|
2194
|
-
// A refused write cannot remove anything, so saying "forgot 0 prompts"
|
|
2195
|
-
// would describe a successful clear the file never had.
|
|
2196
|
-
const blocked = promptHistory.blockedReason();
|
|
2197
|
-
if (blocked !== undefined) {
|
|
2198
|
-
model.notice('prompt history is unavailable: ' + blocked);
|
|
2199
|
-
tui.requestRender();
|
|
2200
|
-
return;
|
|
2201
|
-
}
|
|
2202
|
-
return promptHistory.clear().then(removed => {
|
|
2203
|
-
model.notice('forgot ' + removed + (removed === 1 ? ' prompt' : ' prompts'));
|
|
2204
|
-
tui.requestRender();
|
|
2205
|
-
});
|
|
2206
|
-
}).catch(error => {
|
|
2207
|
-
// The store keeps what the file still holds, so the reader is told the
|
|
2208
|
-
// clear failed rather than being shown a count that never landed.
|
|
2209
|
-
model.notice('could not clear prompt history: ' + (error instanceof Error ? error.message : String(error)));
|
|
2210
|
-
tui.requestRender();
|
|
2211
|
-
});
|
|
2212
|
-
};
|
|
2213
|
-
/**
|
|
2214
|
-
* Carry out one classified line, wherever it was asked for.
|
|
2215
|
-
*
|
|
2216
|
-
* A chord asks for the same things the command line does, so both arrive
|
|
2217
|
-
* here: a chord cannot behave differently from the command it stands for.
|
|
2218
|
-
*/
|
|
2219
|
-
const runSubmission = (submission) => {
|
|
2220
|
-
switch (submission.kind) {
|
|
2221
|
-
case 'empty':
|
|
2222
|
-
return;
|
|
2223
|
-
case 'quit':
|
|
2224
|
-
requestExit(0);
|
|
2225
|
-
return;
|
|
2226
|
-
case 'model':
|
|
2227
|
-
runModelCommand(submission.argument);
|
|
2228
|
-
return;
|
|
2229
|
-
case 'preset':
|
|
2230
|
-
runPresetCommand(submission.argument);
|
|
2231
|
-
return;
|
|
2232
|
-
case 'jobs':
|
|
2233
|
-
runJobsCommand(submission.argument);
|
|
2234
|
-
return;
|
|
2235
|
-
case 'rename':
|
|
2236
|
-
runRenameCommand(submission.title);
|
|
2237
|
-
return;
|
|
2238
|
-
case 'export':
|
|
2239
|
-
runExportCommand(submission.path);
|
|
2240
|
-
return;
|
|
2241
|
-
case 'subagents':
|
|
2242
|
-
runSubagentsCommand(submission.argument);
|
|
2243
|
-
return;
|
|
2244
|
-
case 'fork':
|
|
2245
|
-
runForkCommand(submission.title);
|
|
2246
|
-
return;
|
|
2247
|
-
case 'new':
|
|
2248
|
-
runNewCommand(submission.title);
|
|
2249
|
-
return;
|
|
2250
|
-
case 'reload':
|
|
2251
|
-
runReloadCommand();
|
|
2252
|
-
return;
|
|
2253
|
-
case 'todo':
|
|
2254
|
-
runTodoCommand();
|
|
2255
|
-
return;
|
|
2256
|
-
case 'theme': {
|
|
2257
|
-
const argument = submission.argument;
|
|
2258
|
-
// A bare command is the list: a theme is judged by looking at it, so
|
|
2259
|
-
// choosing one belongs in a list the screen follows rather than in a name
|
|
2260
|
-
// the reader has to already know.
|
|
2261
|
-
if (argument === '') {
|
|
2262
|
-
void openThemePicker();
|
|
2263
|
-
return;
|
|
2264
|
-
}
|
|
2265
|
-
const [head = '', ...rest] = argument.split(/\s+/u);
|
|
2266
|
-
// The table answers the other question a theme raises — which layer drew a
|
|
2267
|
-
// shade — and stays reachable by name now that the list has the command.
|
|
2268
|
-
if (head === 'tokens') {
|
|
2269
|
-
for (const line of renderThemeTable(toOverrides(readSection(), themeLibrary), themeLibrary))
|
|
2270
|
-
model.notice(line);
|
|
2271
|
-
tui.requestRender();
|
|
2272
|
-
return;
|
|
2273
|
-
}
|
|
2274
|
-
// The one way a built-in becomes editable. Its file ships inside the
|
|
2275
|
-
// package and the next version replaces it, so a reader who wants to
|
|
2276
|
-
// change one needs a copy that is theirs — and the copy is a theme the
|
|
2277
|
-
// moment it lands, which is why the table is re-read rather than patched.
|
|
2278
|
-
if (head === 'export') {
|
|
2279
|
-
const chosen = rest.join(' ').trim();
|
|
2280
|
-
if (chosen === '') {
|
|
2281
|
-
model.notice(`theme export · which built-in? ${builtinNames(themeLibrary).join(' · ')}`);
|
|
2282
|
-
tui.requestRender();
|
|
2283
|
-
return;
|
|
2284
|
-
}
|
|
2285
|
-
const outcome = exportTheme(themeLibrary, chosen);
|
|
2286
|
-
if (outcome.ok) {
|
|
2287
|
-
themeLibrary = loadThemes(themesHome, builtinThemesDir());
|
|
2288
|
-
model.notice(`theme · exported ${chosen} to ${outcome.path} · /theme ${outcome.select} applies it`);
|
|
2289
|
-
}
|
|
2290
|
-
else {
|
|
2291
|
-
model.notice(outcome.problem);
|
|
2292
|
-
}
|
|
2293
|
-
tui.requestRender();
|
|
2294
|
-
return;
|
|
2295
|
-
}
|
|
2296
|
-
// A name nothing answers to is refused by name, like an unknown key
|
|
2297
|
-
// layer: the reader asked for something, so the answer lists names.
|
|
2298
|
-
if (themeLibrary.get(head) === undefined) {
|
|
2299
|
-
model.notice(`unknown theme "${head}" · themes: ${themeLibrary.names().join(' · ')}`);
|
|
2300
|
-
tui.requestRender();
|
|
2301
|
-
return;
|
|
2302
|
-
}
|
|
2303
|
-
chooseTheme(head);
|
|
2304
|
-
return;
|
|
2305
|
-
}
|
|
2306
|
-
case 'keys': {
|
|
2307
|
-
const layer = submission.argument === '' ? undefined : keymapLayer(submission.argument);
|
|
2308
|
-
// A layer that does not exist is not a filter that matches nothing: the
|
|
2309
|
-
// reader asked for something by name, so the answer names the names.
|
|
2310
|
-
if (submission.argument !== '' && layer === undefined) {
|
|
2311
|
-
model.notice(`unknown layer "${submission.argument}" · ${KEYMAP_LAYERS.join(' ')}`);
|
|
2312
|
-
tui.requestRender();
|
|
2313
|
-
return;
|
|
2314
|
-
}
|
|
2315
|
-
openKeyMap(layer);
|
|
2316
|
-
return;
|
|
2317
|
-
}
|
|
2318
|
-
case 'copy':
|
|
2319
|
-
runCopyCommand();
|
|
2320
|
-
return;
|
|
2321
|
-
case 'history':
|
|
2322
|
-
runHistoryCommand(submission.argument);
|
|
2323
|
-
return;
|
|
2324
|
-
case 'stash':
|
|
2325
|
-
// Submitting a command consumes the line it was typed on, so this path
|
|
2326
|
-
// can only carry a draft it was given; parking the bar's own draft is
|
|
2327
|
-
// what the chord is for.
|
|
2328
|
-
if (submission.argument.trim() === '')
|
|
2329
|
-
model.notice('usage: /stash <draft>, or ctrl+x then s to park the editor');
|
|
2330
|
-
else
|
|
2331
|
-
void stash?.stashEditor(submission.argument);
|
|
2332
|
-
return;
|
|
2333
|
-
case 'stash-draft':
|
|
2334
|
-
void stash?.stashEditor();
|
|
2335
|
-
return;
|
|
2336
|
-
case 'stash-pop':
|
|
2337
|
-
void stash?.pop(submission.selector);
|
|
2338
|
-
return;
|
|
2339
|
-
case 'stash-apply':
|
|
2340
|
-
void stash?.apply(submission.selector);
|
|
2341
|
-
return;
|
|
2342
|
-
case 'stash-list':
|
|
2343
|
-
void stash?.list(String(activeSession));
|
|
2344
|
-
return;
|
|
2345
|
-
case 'stash-drop':
|
|
2346
|
-
void stash?.drop(submission.selector);
|
|
2347
|
-
return;
|
|
2348
|
-
case 'stash-clear':
|
|
2349
|
-
void stash?.clear();
|
|
2350
|
-
return;
|
|
2351
|
-
case 'editor':
|
|
2352
|
-
void externalEditor.edit(editor.getExpandedText()).then(text => {
|
|
2353
|
-
if (text !== undefined) {
|
|
2354
|
-
// A gate can open while the child owns the screen, and the bar then
|
|
2355
|
-
// holds somebody's answer: the edited draft waits behind it instead
|
|
2356
|
-
// of being written into a question the reader never answered.
|
|
2357
|
-
if (promptBar.isBorrowed())
|
|
2358
|
-
promptBar.replaceHeld(text);
|
|
2359
|
-
else
|
|
2360
|
-
editor.setText(text);
|
|
2361
|
-
tui.requestRender();
|
|
2362
|
-
}
|
|
2363
|
-
const pendingExit = deferredExit;
|
|
2364
|
-
deferredExit = undefined;
|
|
2365
|
-
if (pendingExit !== undefined)
|
|
2366
|
-
requestExit(pendingExit.code, pendingExit.reason);
|
|
2367
|
-
});
|
|
2368
|
-
return;
|
|
2369
|
-
case 'status': {
|
|
2370
|
-
const facts = statusFacts();
|
|
2371
|
-
const context = facts.contextTokens === undefined
|
|
2372
|
-
? undefined
|
|
2373
|
-
: `context ${formatTokens(facts.contextTokens)}${facts.contextWindow === undefined ? '' : `/${formatTokens(facts.contextWindow)}`}`;
|
|
2374
|
-
model.notice([
|
|
2375
|
-
`session ${activeSession}`,
|
|
2376
|
-
viewedSession === activeSession ? undefined : `viewing ${viewedSession}`,
|
|
2377
|
-
facts.model === undefined
|
|
2378
|
-
? undefined
|
|
2379
|
-
: `model ${facts.provider === undefined ? '' : `${facts.provider}/`}${facts.model}${facts.effort === undefined ? '' : ` (${facts.effort})`}`,
|
|
2380
|
-
facts.agentPreset === undefined ? undefined : `mode ${facts.agentPreset}`,
|
|
2381
|
-
facts.preset === undefined ? undefined : `permissions ${facts.preset}`,
|
|
2382
|
-
context,
|
|
2383
|
-
facts.uncachedInputTokens === undefined && facts.outputTokens === undefined
|
|
2384
|
-
? undefined
|
|
2385
|
-
: `tokens in ${formatTokens(facts.uncachedInputTokens ?? 0)} out ${formatTokens(facts.outputTokens ?? 0)}`,
|
|
2386
|
-
`cwd ${facts.cwd}`,
|
|
2387
|
-
].filter(part => part !== undefined).join(' · '));
|
|
2388
|
-
tui.requestRender();
|
|
2389
|
-
return;
|
|
2390
|
-
}
|
|
2391
|
-
case 'clear':
|
|
2392
|
-
model.reset();
|
|
2393
|
-
work.reset();
|
|
2394
|
-
tui.requestRender();
|
|
2395
|
-
return;
|
|
2396
|
-
case 'undo':
|
|
2397
|
-
runUndoCommand();
|
|
2398
|
-
return;
|
|
2399
|
-
case 'redo':
|
|
2400
|
-
runRedoCommand();
|
|
2401
|
-
return;
|
|
2402
|
-
case 'help':
|
|
2403
|
-
model.notice(helpText());
|
|
2404
|
-
tui.requestRender();
|
|
2405
|
-
return;
|
|
2406
|
-
case 'resume':
|
|
2407
|
-
void chooseSession().then(picked => picked === undefined ? undefined : switchSession(picked)).catch((error) => {
|
|
2408
|
-
model.notice(`could not resume: ${error instanceof Error ? error.message : String(error)}`);
|
|
2409
|
-
tui.requestRender();
|
|
2410
|
-
});
|
|
2411
|
-
return;
|
|
2412
|
-
// Plan mode is a command the harness owns, so the chord asks the host for
|
|
2413
|
-
// the state and names the command for the other one: one key, both ways.
|
|
2414
|
-
case 'plan':
|
|
2415
|
-
runCommand('plan', planToggleLine(planActive()));
|
|
2416
|
-
return;
|
|
2417
|
-
case 'command':
|
|
2418
|
-
runCommand(submission.name, submission.line);
|
|
2419
|
-
return;
|
|
2420
|
-
case 'prompt':
|
|
2421
|
-
if (agent === undefined) {
|
|
2422
|
-
model.notice('the agent is still starting; try again in a moment');
|
|
2423
|
-
tui.requestRender();
|
|
2424
|
-
return;
|
|
2425
|
-
}
|
|
2426
|
-
// While a turn is running the human is steering it, not opening another.
|
|
2427
|
-
if (turnOpen)
|
|
2428
|
-
agent.steer(submission.text);
|
|
2429
|
-
else {
|
|
2430
|
-
adoptDefaultRoute();
|
|
2431
|
-
void commitStagedSend(submission.text);
|
|
2432
|
-
}
|
|
2433
|
-
}
|
|
2434
|
-
};
|
|
2435
|
-
editor.onSubmit = text => {
|
|
2436
|
-
const submission = classifySubmission(text);
|
|
2437
|
-
if (submission.kind === 'empty')
|
|
2438
|
-
return;
|
|
2439
|
-
// The library's own history feeds the up/down keys; the store below feeds
|
|
2440
|
-
// ghost completion and reverse search, and is global rather than per-session.
|
|
2441
|
-
editor.addToHistory(text);
|
|
2442
|
-
if (historyEnabled)
|
|
2443
|
-
promptHistory.record(text);
|
|
2444
|
-
runSubmission(submission);
|
|
2445
|
-
};
|
|
431
|
+
promptInput.attachSubmit();
|
|
2446
432
|
disposers.push(ctx.on('session/event', (session, event) => {
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
if (session.id === activeSession) {
|
|
2450
|
-
if (event.type === 'turn/start') {
|
|
2451
|
-
turnOpen = true;
|
|
2452
|
-
turnStartedAt = Date.now();
|
|
2453
|
-
writeTerminal(windowTitle(process.cwd(), 'working'));
|
|
2454
|
-
}
|
|
2455
|
-
if (event.type === 'turn/end') {
|
|
2456
|
-
const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
|
|
2457
|
-
turnOpen = false;
|
|
2458
|
-
turnStartedAt = undefined;
|
|
2459
|
-
// An undo may be parked waiting for exactly this boundary.
|
|
2460
|
-
turnSettled?.();
|
|
2461
|
-
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
2462
|
-
if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
|
|
2463
|
-
writeTerminal(BELL);
|
|
2464
|
-
// A job the turn started may have settled while the reader was watching
|
|
2465
|
-
// something else, and nothing else refreshes a live board.
|
|
2466
|
-
refreshJobs();
|
|
2467
|
-
}
|
|
2468
|
-
// A claim or a discard changes what is queued, and that belongs to this
|
|
2469
|
-
// session even while the transcript shows a child's conversation.
|
|
2470
|
-
if (event.type === 'agent/inbox/spliced')
|
|
2471
|
-
tui.requestRender();
|
|
2472
|
-
}
|
|
2473
|
-
if (session.id !== viewedSession)
|
|
2474
|
-
return;
|
|
2475
|
-
presentScope = ctx.agents?.get(session.id);
|
|
2476
|
-
applyDurable(session.id, event);
|
|
2477
|
-
tui.requestRender();
|
|
2478
|
-
}));
|
|
2479
|
-
/**
|
|
2480
|
-
* Subagent lifecycle arrives as a service event rather than a session event,
|
|
2481
|
-
* so it is decoration in the transcript: the durable record of a delegation
|
|
2482
|
-
* is the tool call that asked for it. The name is cast so a rename in the
|
|
2483
|
-
* harness cannot break compilation of this surface.
|
|
2484
|
-
*/
|
|
2485
|
-
const listenFor = (name, handler) => ctx.on(name, handler);
|
|
2486
|
-
// Herdr hears the driver rather than each turn: a run that chains turns
|
|
2487
|
-
// through a pending inbox is one stretch of work, and a turn boundary inside
|
|
2488
|
-
// it would read as done between two turns of an agent that is still working.
|
|
2489
|
-
// The turn listeners above keep the title and the bell, which are about the
|
|
2490
|
-
// reader's own conversation.
|
|
2491
|
-
disposers.push(listenFor('agent/status', payload => {
|
|
2492
|
-
// Read against the driven session at delivery time: the reader can switch
|
|
2493
|
-
// sessions between two transitions, and the row must follow the one this
|
|
2494
|
-
// terminal now drives.
|
|
2495
|
-
const status = driverReportFor(payload, activeSession);
|
|
2496
|
-
if (status === undefined)
|
|
2497
|
-
return;
|
|
2498
|
-
herdr.driver(status);
|
|
2499
|
-
}));
|
|
2500
|
-
disposers.push(listenFor('subagent/start', info => {
|
|
2501
|
-
roster.start(info);
|
|
2502
|
-
const record = asRecord(info);
|
|
2503
|
-
const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
|
|
2504
|
-
const id = typeof record?.id === 'string' ? record.id : '';
|
|
2505
|
-
model.marker(`subagent ${provider} started${id === '' ? '' : ` · ${id}`}`);
|
|
2506
|
-
tui.requestRender();
|
|
2507
|
-
}));
|
|
2508
|
-
disposers.push(listenFor('subagent/end', info => {
|
|
2509
|
-
roster.end(info);
|
|
2510
|
-
const record = asRecord(info);
|
|
2511
|
-
const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
|
|
2512
|
-
const stop = typeof record?.stopReason === 'string' ? record.stopReason : undefined;
|
|
2513
|
-
model.marker(`subagent ${provider} finished${stop === undefined ? '' : ` · ${stop}`}`);
|
|
2514
|
-
tui.requestRender();
|
|
433
|
+
sessionLifecycle.observe(session, event);
|
|
434
|
+
sessionView.observe(session.id, event);
|
|
2515
435
|
}));
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
disposers.push(
|
|
2520
|
-
if (request.agent.id !== activeSession)
|
|
2521
|
-
return next();
|
|
2522
|
-
return new Promise(resolve => {
|
|
2523
|
-
const gate = new ApprovalGate(request.toolName, request.reason, () => keymap);
|
|
2524
|
-
request.signal?.addEventListener('abort', () => {
|
|
2525
|
-
gate.cancel();
|
|
2526
|
-
if (pending?.gate === gate)
|
|
2527
|
-
closeGate();
|
|
2528
|
-
resolve('cancelled');
|
|
2529
|
-
}, { once: true });
|
|
2530
|
-
openGate({ kind: 'approval', gate, settle: resolve });
|
|
2531
|
-
});
|
|
2532
|
-
}));
|
|
2533
|
-
disposers.push(ctx.on('user-questions/request', (request, next) => {
|
|
2534
|
-
const agentId = request.agent?.id;
|
|
2535
|
-
if (agentId !== undefined && agentId !== activeSession)
|
|
2536
|
-
return next();
|
|
2537
|
-
const questions = toGateQuestions(request);
|
|
2538
|
-
if (questions.length === 0)
|
|
2539
|
-
return next();
|
|
2540
|
-
return new Promise(resolve => {
|
|
2541
|
-
const gate = promptBar.borrow(() => {
|
|
2542
|
-
// The bar is an answer's for as long as the gate holds it, so the menu
|
|
2543
|
-
// it once offered commands through is replaced before a key can reach it.
|
|
2544
|
-
const built = new QuestionGate(questions, editor, () => keymap);
|
|
2545
|
-
applyCompletion();
|
|
2546
|
-
return built;
|
|
2547
|
-
});
|
|
2548
|
-
// The seam takes mutable selection arrays and an optional custom field, so
|
|
2549
|
-
// the read-only gate answer is copied into that exact shape here.
|
|
2550
|
-
const settle = (answers) => {
|
|
2551
|
-
request.signal?.removeEventListener('abort', onAbort);
|
|
2552
|
-
resolve({
|
|
2553
|
-
answers: answers.map(answer => ({
|
|
2554
|
-
id: answer.id,
|
|
2555
|
-
selected: [...answer.selected],
|
|
2556
|
-
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
2557
|
-
})),
|
|
2558
|
-
});
|
|
2559
|
-
};
|
|
2560
|
-
// A question whose caller is gone has no reader, so the gate must release
|
|
2561
|
-
// the keyboard instead of collecting an answer the aborted call discards.
|
|
2562
|
-
const onAbort = () => {
|
|
2563
|
-
gate.cancel();
|
|
2564
|
-
if (pending?.gate === gate)
|
|
2565
|
-
closeGate();
|
|
2566
|
-
settle([]);
|
|
2567
|
-
};
|
|
2568
|
-
openGate({ kind: 'question', gate, settle });
|
|
2569
|
-
request.signal?.addEventListener('abort', onAbort, { once: true });
|
|
2570
|
-
// A signal that aborted before the listener existed never emits, so the
|
|
2571
|
-
// state has to be read once after subscribing.
|
|
2572
|
-
if (request.signal?.aborted === true)
|
|
2573
|
-
onAbort();
|
|
2574
|
-
});
|
|
2575
|
-
}));
|
|
2576
|
-
disposers.push(ctx.on('commands/change', () => installCompletion()));
|
|
436
|
+
disposers.push(...sessionLifecycle.driverListeners());
|
|
437
|
+
disposers.push(...backgroundWork.subagentListeners());
|
|
438
|
+
disposers.push(...modalInput.requestListeners());
|
|
439
|
+
disposers.push(...promptInput.commandListeners());
|
|
2577
440
|
// The board is live state: watch it directly rather than folding events.
|
|
2578
|
-
disposers.push(
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
// and the queue stay with the agent this terminal drives. A live delta or a
|
|
2585
|
-
// failure folded into the wrong model would print one session's words as
|
|
2586
|
-
// another's, and the durable copy that follows would never correct it.
|
|
2587
|
-
disposers.push(ctx.on('agent/error', payload => {
|
|
2588
|
-
if (payload.agent.id !== viewedSession)
|
|
2589
|
-
return;
|
|
2590
|
-
model.reportError(payload.error);
|
|
2591
|
-
tui.requestRender();
|
|
2592
|
-
}));
|
|
2593
|
-
disposers.push(ctx.on('agent/assistant-stream', payload => {
|
|
2594
|
-
if (payload.agent.id !== viewedSession)
|
|
2595
|
-
return;
|
|
2596
|
-
if (payload.frame.type !== 'chunk')
|
|
2597
|
-
return;
|
|
2598
|
-
model.applyStreamChunk(payload.frame.chunk);
|
|
2599
|
-
tui.requestRender();
|
|
2600
|
-
}));
|
|
2601
|
-
/**
|
|
2602
|
-
* Restyle a running session when the reader's section changes.
|
|
2603
|
-
*
|
|
2604
|
-
* The settings document is hot-reloaded by the host, so a reader watching a
|
|
2605
|
-
* shade land never has to leave the session to see it — which is what makes
|
|
2606
|
-
* tuning one bearable instead of a restart per attempt. The event is
|
|
2607
|
-
* namespace-filtered: another surface's preferences are not our repaint.
|
|
2608
|
-
*/
|
|
2609
|
-
disposers.push(ctx.on('settings/updated', ns => {
|
|
2610
|
-
if (String(ns) !== TUI_SETTINGS_NAMESPACE)
|
|
2611
|
-
return;
|
|
2612
|
-
applySettings();
|
|
2613
|
-
// Both caches hold rows under the old table, so they have to be told the
|
|
2614
|
-
// table moved; a repaint alone would reuse what they already stored.
|
|
2615
|
-
markdown.invalidate();
|
|
2616
|
-
view.invalidate();
|
|
2617
|
-
tui.requestRender();
|
|
2618
|
-
}));
|
|
2619
|
-
/**
|
|
2620
|
-
* The reader's own themes: created, reported, and then watched.
|
|
2621
|
-
*
|
|
2622
|
-
* Created because a directory that is not there is a command that cannot work —
|
|
2623
|
-
* `/theme export` names a path the reader should find where the surface said it
|
|
2624
|
-
* would be. Watched because a theme is a file they are editing by hand, so
|
|
2625
|
-
* saving one is how they ask for it; a session that needed a restart per shade
|
|
2626
|
-
* would make the whole table useless for tuning. Everything unreadable is
|
|
2627
|
-
* reported through the deferred notice rather than stderr, which the alternate
|
|
2628
|
-
* screen is drawn over.
|
|
2629
|
-
*/
|
|
2630
|
-
let reportedThemes = new Set();
|
|
2631
|
-
const reportThemes = () => {
|
|
2632
|
-
// Only what is newly wrong: the watcher re-reads the whole directory on every
|
|
2633
|
-
// save, so an unfixed file would otherwise repeat its complaint on each one,
|
|
2634
|
-
// and a reader who has just been told is not helped by being told again.
|
|
2635
|
-
const problems = themeLibrary.problems();
|
|
2636
|
-
for (const problem of problems) {
|
|
2637
|
-
if (!reportedThemes.has(problem))
|
|
2638
|
-
settingsNotice.post(problem);
|
|
2639
|
-
}
|
|
2640
|
-
reportedThemes = new Set(problems);
|
|
2641
|
-
};
|
|
2642
|
-
for (const problem of ensureThemesHome(themesHome))
|
|
2643
|
-
settingsNotice.post(problem);
|
|
2644
|
-
reportThemes();
|
|
2645
|
-
disposers.push(watchThemes(themesHome, () => {
|
|
2646
|
-
themeLibrary = loadThemes(themesHome, builtinThemesDir());
|
|
2647
|
-
reportThemes();
|
|
2648
|
-
// The file that was just saved may be the theme already in force, so the
|
|
2649
|
-
// table is rebuilt rather than only repainted.
|
|
2650
|
-
applySettings();
|
|
2651
|
-
markdown.invalidate();
|
|
2652
|
-
view.invalidate();
|
|
2653
|
-
tui.requestRender();
|
|
2654
|
-
}));
|
|
2655
|
-
const degraded = describeMissingOptional(probe);
|
|
2656
|
-
if (degraded !== undefined)
|
|
2657
|
-
model.notice(degraded);
|
|
2658
|
-
/**
|
|
2659
|
-
* Open the session this run was launched for.
|
|
2660
|
-
*
|
|
2661
|
-
* A bare `--resume` asks a question only the reader can answer, so the
|
|
2662
|
-
* picker runs before anything is created: the agent is then opened on the
|
|
2663
|
-
* chosen session rather than swapped afterwards, which would leave the first
|
|
2664
|
-
* one's turn half-started.
|
|
2665
|
-
*/
|
|
2666
|
-
const boot = async () => {
|
|
2667
|
-
if (!resolved.resumePicker) {
|
|
2668
|
-
await openAgent(resolved.sessionId, resolved.resume);
|
|
2669
|
-
return;
|
|
2670
|
-
}
|
|
2671
|
-
const picked = await chooseSession();
|
|
2672
|
-
if (picked === undefined) {
|
|
2673
|
-
requestExit(0);
|
|
2674
|
-
return;
|
|
2675
|
-
}
|
|
2676
|
-
await openAgent(picked, true);
|
|
2677
|
-
};
|
|
2678
|
-
/**
|
|
2679
|
-
* Take the screen, then open the session this run was launched for.
|
|
2680
|
-
*
|
|
2681
|
-
* A mode named on the command line is resolved FIRST: the alt screen swallows
|
|
2682
|
-
* the launcher's own error output, so a mode this roster does not offer has to
|
|
2683
|
-
* be answered while the shell still owns the terminal.
|
|
2684
|
-
*/
|
|
2685
|
-
const start = async () => {
|
|
2686
|
-
if (requestedPreset !== undefined && agentPresets !== undefined) {
|
|
2687
|
-
seat = (await agentPresets.resolve(requestedPreset)).id;
|
|
2688
|
-
}
|
|
2689
|
-
// A named mode that disagrees with the one this session recorded is refused
|
|
2690
|
-
// here as well as at the open, because the alternate screen closes over
|
|
2691
|
-
// whatever was painted on it: the reader would see the failure, not the
|
|
2692
|
-
// reason. With a picker the session is not known yet, so it waits for one.
|
|
2693
|
-
if (!resolved.resumePicker)
|
|
2694
|
-
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
2695
|
-
tui.start();
|
|
2696
|
-
writeTerminal(windowTitle(process.cwd(), 'ready'));
|
|
2697
|
-
// Claiming the pane's agent row does not wait for a session: the pane is
|
|
2698
|
-
// already on screen and already idle, and a session may still be chosen.
|
|
2699
|
-
herdr.publish();
|
|
2700
|
-
// A refused settings edit is only visible now that the surface owns the
|
|
2701
|
-
// screen; whatever the scope found before this point prints here instead.
|
|
2702
|
-
settingsNotice.open(message => model.notice(message));
|
|
2703
|
-
await boot();
|
|
2704
|
-
};
|
|
2705
|
-
void start().catch((error) => {
|
|
441
|
+
disposers.push(backgroundWork.watchJobs());
|
|
442
|
+
disposers.push(...sessionView.agentListeners());
|
|
443
|
+
disposers.push(appearance.settingsListener());
|
|
444
|
+
appearance.createThemesHome();
|
|
445
|
+
disposers.push(appearance.watchThemes());
|
|
446
|
+
void commands.start().catch((error) => {
|
|
2706
447
|
requestExit(1, error instanceof Error ? error.message : String(error));
|
|
2707
448
|
});
|
|
2708
449
|
}
|