@sagmans/dsh-tui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/cordis.patch.yml +123 -0
- package/lib/agent/fork.d.ts +24 -0
- package/lib/agent/fork.d.ts.map +1 -0
- package/lib/agent/fork.js +25 -0
- package/lib/agent/fork.js.map +1 -0
- package/lib/agent/history.d.ts +78 -0
- package/lib/agent/history.d.ts.map +1 -0
- package/lib/agent/history.js +179 -0
- package/lib/agent/history.js.map +1 -0
- package/lib/agent/host.d.ts +58 -0
- package/lib/agent/host.d.ts.map +1 -0
- package/lib/agent/host.js +59 -0
- package/lib/agent/host.js.map +1 -0
- package/lib/agent/model.d.ts +61 -0
- package/lib/agent/model.d.ts.map +1 -0
- package/lib/agent/model.js +92 -0
- package/lib/agent/model.js.map +1 -0
- package/lib/agent/present.d.ts +12 -0
- package/lib/agent/present.d.ts.map +1 -0
- package/lib/agent/present.js +52 -0
- package/lib/agent/present.js.map +1 -0
- package/lib/agent/presets.d.ts +75 -0
- package/lib/agent/presets.d.ts.map +1 -0
- package/lib/agent/presets.js +117 -0
- package/lib/agent/presets.js.map +1 -0
- package/lib/agent/projections.d.ts +16 -0
- package/lib/agent/projections.d.ts.map +1 -0
- package/lib/agent/projections.js +31 -0
- package/lib/agent/projections.js.map +1 -0
- package/lib/agent/status.d.ts +46 -0
- package/lib/agent/status.d.ts.map +1 -0
- package/lib/agent/status.js +67 -0
- package/lib/agent/status.js.map +1 -0
- package/lib/cards.d.ts +88 -0
- package/lib/cards.d.ts.map +1 -0
- package/lib/cards.js +218 -0
- package/lib/cards.js.map +1 -0
- package/lib/compat/probe.d.ts +33 -0
- package/lib/compat/probe.d.ts.map +1 -0
- package/lib/compat/probe.js +74 -0
- package/lib/compat/probe.js.map +1 -0
- package/lib/config.d.ts +13 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +49 -0
- package/lib/config.js.map +1 -0
- package/lib/contracts.d.ts +32 -0
- package/lib/contracts.d.ts.map +1 -0
- package/lib/contracts.js +2 -0
- package/lib/contracts.js.map +1 -0
- package/lib/export.d.ts +15 -0
- package/lib/export.d.ts.map +1 -0
- package/lib/export.js +52 -0
- package/lib/export.js.map +1 -0
- package/lib/gates.d.ts +85 -0
- package/lib/gates.d.ts.map +1 -0
- package/lib/gates.js +216 -0
- package/lib/gates.js.map +1 -0
- package/lib/identity.d.ts +33 -0
- package/lib/identity.d.ts.map +1 -0
- package/lib/identity.js +49 -0
- package/lib/identity.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +1196 -0
- package/lib/index.js.map +1 -0
- package/lib/input/completion.d.ts +22 -0
- package/lib/input/completion.d.ts.map +1 -0
- package/lib/input/completion.js +27 -0
- package/lib/input/completion.js.map +1 -0
- package/lib/input/submission.d.ts +62 -0
- package/lib/input/submission.d.ts.map +1 -0
- package/lib/input/submission.js +80 -0
- package/lib/input/submission.js.map +1 -0
- package/lib/jobs.d.ts +64 -0
- package/lib/jobs.d.ts.map +1 -0
- package/lib/jobs.js +123 -0
- package/lib/jobs.js.map +1 -0
- package/lib/startup.d.ts +17 -0
- package/lib/startup.d.ts.map +1 -0
- package/lib/startup.js +67 -0
- package/lib/startup.js.map +1 -0
- package/lib/subagents.d.ts +74 -0
- package/lib/subagents.d.ts.map +1 -0
- package/lib/subagents.js +141 -0
- package/lib/subagents.js.map +1 -0
- package/lib/terminal/bell.d.ts +18 -0
- package/lib/terminal/bell.d.ts.map +1 -0
- package/lib/terminal/bell.js +16 -0
- package/lib/terminal/bell.js.map +1 -0
- package/lib/terminal/clipboard.d.ts +11 -0
- package/lib/terminal/clipboard.d.ts.map +1 -0
- package/lib/terminal/clipboard.js +18 -0
- package/lib/terminal/clipboard.js.map +1 -0
- package/lib/terminal/restore.d.ts +17 -0
- package/lib/terminal/restore.d.ts.map +1 -0
- package/lib/terminal/restore.js +43 -0
- package/lib/terminal/restore.js.map +1 -0
- package/lib/terminal/title.d.ts +22 -0
- package/lib/terminal/title.d.ts.map +1 -0
- package/lib/terminal/title.js +34 -0
- package/lib/terminal/title.js.map +1 -0
- package/lib/text.d.ts +19 -0
- package/lib/text.d.ts.map +1 -0
- package/lib/text.js +61 -0
- package/lib/text.js.map +1 -0
- package/lib/theme.d.ts +47 -0
- package/lib/theme.d.ts.map +1 -0
- package/lib/theme.js +84 -0
- package/lib/theme.js.map +1 -0
- package/lib/transcript.d.ts +91 -0
- package/lib/transcript.d.ts.map +1 -0
- package/lib/transcript.js +315 -0
- package/lib/transcript.js.map +1 -0
- package/lib/ui/dock.d.ts +36 -0
- package/lib/ui/dock.d.ts.map +1 -0
- package/lib/ui/dock.js +110 -0
- package/lib/ui/dock.js.map +1 -0
- package/lib/ui/markdown.d.ts +17 -0
- package/lib/ui/markdown.d.ts.map +1 -0
- package/lib/ui/markdown.js +35 -0
- package/lib/ui/markdown.js.map +1 -0
- package/lib/ui/picker.d.ts +103 -0
- package/lib/ui/picker.d.ts.map +1 -0
- package/lib/ui/picker.js +165 -0
- package/lib/ui/picker.js.map +1 -0
- package/lib/ui/rows.d.ts +30 -0
- package/lib/ui/rows.d.ts.map +1 -0
- package/lib/ui/rows.js +49 -0
- package/lib/ui/rows.js.map +1 -0
- package/lib/ui/status.d.ts +49 -0
- package/lib/ui/status.d.ts.map +1 -0
- package/lib/ui/status.js +90 -0
- package/lib/ui/status.js.map +1 -0
- package/lib/ui/view.d.ts +56 -0
- package/lib/ui/view.d.ts.map +1 -0
- package/lib/ui/view.js +184 -0
- package/lib/ui/view.js.map +1 -0
- package/lib/work.d.ts +39 -0
- package/lib/work.d.ts.map +1 -0
- package/lib/work.js +97 -0
- package/lib/work.js.map +1 -0
- package/package.json +109 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { Editor, ProcessTerminal, ScrollView, TuiAltScreen, VStack, 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 { createPresetRoster, parsePresetArgument } from "./agent/presets.js";
|
|
8
|
+
import { createToolPresenter } from "./agent/present.js";
|
|
9
|
+
import { forkPoint } from "./agent/fork.js";
|
|
10
|
+
import { PROFILE_NAME, resumeHint } from "./identity.js";
|
|
11
|
+
import { createStatusFacts } from "./agent/status.js";
|
|
12
|
+
import { ModelSwitch, createModelCatalog, parseModelArgument } from "./agent/model.js";
|
|
13
|
+
import { JOB_READ_LINES, createJobDirectory, describeJobs, parseJobsArgument } from "./jobs.js";
|
|
14
|
+
import { SubagentRoster, createSubagentControl, describeSubagents, parseSubagentsArgument, resolveRun, } from "./subagents.js";
|
|
15
|
+
import { describeMissingOptional, describeMissingRequired, probeComposition } from "./compat/probe.js";
|
|
16
|
+
import { ApprovalGate, QuestionGate, toGateQuestions } from "./gates.js";
|
|
17
|
+
import { createCompletionProvider } from "./input/completion.js";
|
|
18
|
+
import { LOCAL_COMMANDS, classifySubmission } from "./input/submission.js";
|
|
19
|
+
import { resolveConfig } from "./config.js";
|
|
20
|
+
import { createRestoreRegistry } from "./terminal/restore.js";
|
|
21
|
+
import { BELL, shouldRingBell } from "./terminal/bell.js";
|
|
22
|
+
import { clipboardSequence } from "./terminal/clipboard.js";
|
|
23
|
+
import { CLEAR_TITLE, windowTitle } from "./terminal/title.js";
|
|
24
|
+
import { defaultExportFile, transcriptToText } from "./export.js";
|
|
25
|
+
import { colorEnabled, createTheme } from "./theme.js";
|
|
26
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
27
|
+
import { TranscriptModel } from "./transcript.js";
|
|
28
|
+
import { WorkFold, describeTodos } from "./work.js";
|
|
29
|
+
import { WorkDock } from "./ui/dock.js";
|
|
30
|
+
import { MarkdownRenderer } from "./ui/markdown.js";
|
|
31
|
+
import { PresetPicker, SessionPicker } from "./ui/picker.js";
|
|
32
|
+
import { StatusBar, formatTokens } from "./ui/status.js";
|
|
33
|
+
import { TranscriptView } from "./ui/view.js";
|
|
34
|
+
export const name = 'tui';
|
|
35
|
+
/** `agents` is the only service the surface cannot run without. */
|
|
36
|
+
export const inject = ['agents'];
|
|
37
|
+
/** Keys the surface answers itself, listed wherever the reader asks for help. */
|
|
38
|
+
const LOCAL_KEYS = 'ctrl+o tool detail · ctrl+t reasoning · ctrl+b back to this session · ctrl+c interrupt or exit';
|
|
39
|
+
/** The one thing to say about a view a reader did not open. */
|
|
40
|
+
const LOCAL_KEYS_BACK = 'ctrl+b returns';
|
|
41
|
+
/** Stored sessions titled at once when the picker opens. */
|
|
42
|
+
const TITLE_CONCURRENCY = 4;
|
|
43
|
+
/** How often the running-state clock repaints while a turn is open. */
|
|
44
|
+
const STATUS_TICK_MS = 1000;
|
|
45
|
+
function asRecord(value) {
|
|
46
|
+
return typeof value === 'object' && value !== null ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Refuse to run without a real terminal.
|
|
50
|
+
*
|
|
51
|
+
* A terminal surface that silently degrades to line mode hides deployment
|
|
52
|
+
* mistakes and changes interaction semantics, so an unattended invocation
|
|
53
|
+
* fails loud instead: a caller that wants one-shot output uses headless mode.
|
|
54
|
+
*/
|
|
55
|
+
export function assertInteractiveTerminal(input = {}) {
|
|
56
|
+
const stdin = input.stdinIsTTY ?? process.stdin.isTTY === true;
|
|
57
|
+
const stdout = input.stdoutIsTTY ?? process.stdout.isTTY === true;
|
|
58
|
+
if (!stdin || !stdout) {
|
|
59
|
+
throw new Error('dsh-tui: both stdin and stdout must be TTYs; run this profile from a terminal or SSH session');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Mount the terminal surface over the composed agent plane.
|
|
64
|
+
*
|
|
65
|
+
* The plugin owns input and presentation only: agent lifecycle, session
|
|
66
|
+
* persistence, tool execution, and the model-facing question tool stay with
|
|
67
|
+
* the composition rows around it.
|
|
68
|
+
*/
|
|
69
|
+
export function apply(ctx, config) {
|
|
70
|
+
const resolved = resolveConfig(config);
|
|
71
|
+
assertInteractiveTerminal();
|
|
72
|
+
// Fail before taking the screen over: a missing row is a composition mistake,
|
|
73
|
+
// and the reader deserves the row name rather than a stack trace mid-turn.
|
|
74
|
+
const probe = probeComposition(service => ctx.get(service) !== undefined);
|
|
75
|
+
if (probe.missingRequired.length > 0)
|
|
76
|
+
throw new Error(describeMissingRequired(probe));
|
|
77
|
+
const appExit = ctx.get('appExit');
|
|
78
|
+
if (appExit === undefined) {
|
|
79
|
+
throw new Error('dsh-tui: the dsh launcher must provide appExit; start this surface with dsh --profile tui');
|
|
80
|
+
}
|
|
81
|
+
const theme = createTheme(colorEnabled(resolved.color));
|
|
82
|
+
const model = new TranscriptModel(createToolPresenter(ctx));
|
|
83
|
+
const work = new WorkFold();
|
|
84
|
+
const modelSwitch = new ModelSwitch();
|
|
85
|
+
const agentPresets = createPresetRoster(ctx);
|
|
86
|
+
/**
|
|
87
|
+
* The mode a session the reader starts from here on joins.
|
|
88
|
+
*
|
|
89
|
+
* A launch flag seeds it and a successful switch updates it, so `/new` and
|
|
90
|
+
* `/fork` land in the mode the reader last chose; nothing is written to
|
|
91
|
+
* settings, because the mode of a session is not a property of the machine.
|
|
92
|
+
*/
|
|
93
|
+
/** The mode named on the command line, which is the only one that may conflict. */
|
|
94
|
+
const requestedPreset = resolved.preset;
|
|
95
|
+
let seat = requestedPreset ?? agentPresets?.defaultId;
|
|
96
|
+
const catalog = createModelCatalog(ctx);
|
|
97
|
+
const jobDirectory = createJobDirectory(ctx);
|
|
98
|
+
let jobs = [];
|
|
99
|
+
const roster = new SubagentRoster();
|
|
100
|
+
const subagentControl = createSubagentControl(ctx);
|
|
101
|
+
const markdown = new MarkdownRenderer(theme.markdown);
|
|
102
|
+
/** Rows the reader has opened. The model stays untouched; only the view reads this. */
|
|
103
|
+
const viewState = { expandCards: false, expandReasoning: false };
|
|
104
|
+
const restore = createRestoreRegistry();
|
|
105
|
+
const terminal = new ProcessTerminal();
|
|
106
|
+
const tui = new TuiAltScreen(terminal);
|
|
107
|
+
/** The session this surface drives: commands, approvals, and the bell belong to it. */
|
|
108
|
+
let activeSession = resolved.sessionId;
|
|
109
|
+
/** The session the transcript is showing, which can be one of its children. */
|
|
110
|
+
let viewedSession = resolved.sessionId;
|
|
111
|
+
let pending;
|
|
112
|
+
let pendingPicker;
|
|
113
|
+
/** A pick being vetted owns the keyboard: a key would answer what is unanswered. */
|
|
114
|
+
let vetting = false;
|
|
115
|
+
const view = new TranscriptView(model, theme, markdown, {
|
|
116
|
+
state: () => viewState,
|
|
117
|
+
gate: () => pending?.gate.card(),
|
|
118
|
+
picker: () => pendingPicker?.picker.card(),
|
|
119
|
+
});
|
|
120
|
+
const editor = new Editor(tui, theme.editor);
|
|
121
|
+
const disposers = [];
|
|
122
|
+
let agent;
|
|
123
|
+
let turnOpen = false;
|
|
124
|
+
let turnStartedAt;
|
|
125
|
+
let exited = false;
|
|
126
|
+
/** Whether a session was really opened, which is what an exit hint can name. */
|
|
127
|
+
let sessionOpened = false;
|
|
128
|
+
/** Re-read the job board; it is live state, so nothing else can fold it. */
|
|
129
|
+
const refreshJobs = () => {
|
|
130
|
+
jobs = agent === undefined || jobDirectory === undefined ? [] : jobDirectory.list(agent.agent);
|
|
131
|
+
tui.requestRender();
|
|
132
|
+
};
|
|
133
|
+
const statusFacts = createStatusFacts(ctx, {
|
|
134
|
+
sessionId: () => activeSession,
|
|
135
|
+
activity: () => ({ running: turnOpen, startedAt: turnStartedAt }),
|
|
136
|
+
override: () => modelSwitch.current(),
|
|
137
|
+
home: process.env.HOME,
|
|
138
|
+
});
|
|
139
|
+
const statusBar = new StatusBar(statusFacts, theme);
|
|
140
|
+
const dock = new WorkDock(() => work.state(), theme, () => jobs, () => roster.list());
|
|
141
|
+
// Only a running turn has anything to say over time, so the clock stops with it.
|
|
142
|
+
const statusTicker = setInterval(() => {
|
|
143
|
+
if (turnOpen)
|
|
144
|
+
tui.requestRender();
|
|
145
|
+
}, STATUS_TICK_MS);
|
|
146
|
+
disposers.push(() => clearInterval(statusTicker));
|
|
147
|
+
restore.add(() => tui.stop());
|
|
148
|
+
ctx.effect(() => () => {
|
|
149
|
+
restore.restore();
|
|
150
|
+
for (const dispose of disposers.reverse())
|
|
151
|
+
dispose();
|
|
152
|
+
});
|
|
153
|
+
tui.setLayoutRoot(new VStack([
|
|
154
|
+
{
|
|
155
|
+
component: new ScrollView(view, { follow: 'end', primary: true, overscroll: 'chain' }),
|
|
156
|
+
basis: 0,
|
|
157
|
+
grow: 1,
|
|
158
|
+
minSize: 1,
|
|
159
|
+
},
|
|
160
|
+
// Work state earns rows only when there is some: a dock that always
|
|
161
|
+
// occupied a row would cost every conversation one line of transcript.
|
|
162
|
+
{ component: dock, basis: 'auto', shrink: 0, minSize: 0 },
|
|
163
|
+
{ component: new VStack([{ component: editor, basis: 'auto', shrink: 1, minSize: 1 }]), basis: 'auto', shrink: 1, minSize: 1 },
|
|
164
|
+
{ component: statusBar, basis: 'auto', shrink: 0, minSize: 1 },
|
|
165
|
+
]));
|
|
166
|
+
tui.setFocus(editor);
|
|
167
|
+
const requestExit = (code, reason) => {
|
|
168
|
+
if (exited)
|
|
169
|
+
return;
|
|
170
|
+
exited = true;
|
|
171
|
+
clearInterval(statusTicker);
|
|
172
|
+
// Hand the window label back before the screen does, so a shell that sets
|
|
173
|
+
// its own title can take over cleanly.
|
|
174
|
+
terminal.write(CLEAR_TITLE);
|
|
175
|
+
restore.restore();
|
|
176
|
+
// Everything below is written after the release: the alternate screen closes
|
|
177
|
+
// over whatever was painted on it, and a failure nobody can read is not a
|
|
178
|
+
// failure that was reported.
|
|
179
|
+
if (reason !== undefined)
|
|
180
|
+
terminal.write(`\ndsh-tui: ${reason}\n`);
|
|
181
|
+
// The hint is computed here rather than read from the context because only
|
|
182
|
+
// the surface knows which session it is leaving: a fork or a switch moves it.
|
|
183
|
+
// A run that opened nothing has nothing to offer back: the identity it was
|
|
184
|
+
// launched with names no log, so pointing at it would send the reader to a
|
|
185
|
+
// conversation that does not exist.
|
|
186
|
+
if (sessionOpened)
|
|
187
|
+
terminal.write(`\n${resumeHint(String(activeSession), PROFILE_NAME)}\n`);
|
|
188
|
+
appExit(code);
|
|
189
|
+
};
|
|
190
|
+
const openGate = (next) => {
|
|
191
|
+
pending = next;
|
|
192
|
+
// A gate owns the keyboard: the editor must not collect the decision keys.
|
|
193
|
+
editor.disableSubmit = true;
|
|
194
|
+
tui.setFocus(null);
|
|
195
|
+
tui.requestRender();
|
|
196
|
+
};
|
|
197
|
+
const closeGate = () => {
|
|
198
|
+
pending = undefined;
|
|
199
|
+
editor.disableSubmit = false;
|
|
200
|
+
tui.setFocus(editor);
|
|
201
|
+
tui.requestRender();
|
|
202
|
+
};
|
|
203
|
+
disposers.push(tui.addInputListener(data => {
|
|
204
|
+
// A key arrives as a press and a release once the surface asks the terminal
|
|
205
|
+
// to report key events, and this library drops the release only for the
|
|
206
|
+
// focused component: a listener sees both halves, so an arrow key that is
|
|
207
|
+
// acted on twice steps a picker two rows and answers a question two options
|
|
208
|
+
// on. Falls through rather than consuming, which leaves a component that
|
|
209
|
+
// asked for releases its own half.
|
|
210
|
+
if (isKeyRelease(data))
|
|
211
|
+
return undefined;
|
|
212
|
+
if (pending !== undefined) {
|
|
213
|
+
if (pending.kind === 'approval') {
|
|
214
|
+
const outcome = pending.gate.handleKey(data);
|
|
215
|
+
if (outcome === undefined)
|
|
216
|
+
tui.requestRender();
|
|
217
|
+
else {
|
|
218
|
+
pending.settle(outcome);
|
|
219
|
+
closeGate();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const answers = pending.gate.handleKey(data);
|
|
224
|
+
if (answers === undefined)
|
|
225
|
+
tui.requestRender();
|
|
226
|
+
else {
|
|
227
|
+
pending.settle(answers);
|
|
228
|
+
closeGate();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { consume: true };
|
|
232
|
+
}
|
|
233
|
+
if (pendingPicker !== undefined) {
|
|
234
|
+
if (vetting)
|
|
235
|
+
return { consume: true };
|
|
236
|
+
const action = pendingPicker.picker.handleKey(data);
|
|
237
|
+
if (action === undefined) {
|
|
238
|
+
tui.requestRender();
|
|
239
|
+
return { consume: true };
|
|
240
|
+
}
|
|
241
|
+
if (action.kind === 'cancel') {
|
|
242
|
+
settlePicker(undefined);
|
|
243
|
+
return { consume: true };
|
|
244
|
+
}
|
|
245
|
+
const vet = pendingPicker.vet;
|
|
246
|
+
if (vet === undefined) {
|
|
247
|
+
settlePicker(action.id);
|
|
248
|
+
return { consume: true };
|
|
249
|
+
}
|
|
250
|
+
vetting = true;
|
|
251
|
+
void (async () => {
|
|
252
|
+
let reason;
|
|
253
|
+
try {
|
|
254
|
+
reason = await vet(action.id);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
// A check that fails must not take the keyboard with it.
|
|
258
|
+
vetting = false;
|
|
259
|
+
}
|
|
260
|
+
if (pendingPicker === undefined)
|
|
261
|
+
return;
|
|
262
|
+
if (reason === undefined) {
|
|
263
|
+
settlePicker(action.id);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
pendingPicker.picker.setNote(reason);
|
|
267
|
+
tui.requestRender();
|
|
268
|
+
})();
|
|
269
|
+
return { consume: true };
|
|
270
|
+
}
|
|
271
|
+
// Detail the reader asked for is always available, even mid-turn: the
|
|
272
|
+
// collapsed view is a default, not the only state.
|
|
273
|
+
if (matchesKey(data, 'ctrl+o')) {
|
|
274
|
+
viewState.expandCards = !viewState.expandCards;
|
|
275
|
+
tui.requestRender();
|
|
276
|
+
return { consume: true };
|
|
277
|
+
}
|
|
278
|
+
if (matchesKey(data, 'ctrl+t')) {
|
|
279
|
+
viewState.expandReasoning = !viewState.expandReasoning;
|
|
280
|
+
tui.requestRender();
|
|
281
|
+
return { consume: true };
|
|
282
|
+
}
|
|
283
|
+
if (matchesKey(data, 'ctrl+b')) {
|
|
284
|
+
if (viewedSession !== activeSession)
|
|
285
|
+
void showAgentSession();
|
|
286
|
+
return { consume: true };
|
|
287
|
+
}
|
|
288
|
+
// In raw mode Ctrl+C never reaches the process as SIGINT, so the surface
|
|
289
|
+
// decides: stop the work in flight, or leave when there is none.
|
|
290
|
+
if (!matchesKey(data, 'ctrl+c'))
|
|
291
|
+
return undefined;
|
|
292
|
+
if (turnOpen) {
|
|
293
|
+
agent?.interrupt();
|
|
294
|
+
model.notice('interrupt requested');
|
|
295
|
+
tui.requestRender();
|
|
296
|
+
return { consume: true };
|
|
297
|
+
}
|
|
298
|
+
requestExit(0);
|
|
299
|
+
return { consume: true };
|
|
300
|
+
}));
|
|
301
|
+
const registry = () => ctx.get('commands');
|
|
302
|
+
/**
|
|
303
|
+
* Offer completion for whatever this session can run right now.
|
|
304
|
+
*
|
|
305
|
+
* The registry is agent-scoped and still empty while the agent starts, so the
|
|
306
|
+
* menu is rebuilt when the agent arrives and whenever a package registers
|
|
307
|
+
* another command.
|
|
308
|
+
*/
|
|
309
|
+
const installCompletion = () => {
|
|
310
|
+
const current = agent?.agent;
|
|
311
|
+
const commands = registry();
|
|
312
|
+
if (current === undefined || commands === undefined)
|
|
313
|
+
return;
|
|
314
|
+
editor.setAutocompleteProvider(createCompletionProvider(commands.list(current), process.cwd()));
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Take the keyboard for a picker.
|
|
318
|
+
*
|
|
319
|
+
* The list is already in hand, so the picker is interactive immediately while
|
|
320
|
+
* the titles that make its rows recognizable stream in behind it.
|
|
321
|
+
*/
|
|
322
|
+
const askForSession = async (history, sessions) => {
|
|
323
|
+
const titles = new Map();
|
|
324
|
+
void loadTitles(history, sessions, titles);
|
|
325
|
+
const picked = await openPicker(new SessionPicker(sessions, () => titles), refuseReason);
|
|
326
|
+
return picked === undefined ? undefined : SessionId(picked);
|
|
327
|
+
};
|
|
328
|
+
/** Give the keyboard back to the editor and answer whoever opened the picker. */
|
|
329
|
+
const settlePicker = (id) => {
|
|
330
|
+
const settle = pendingPicker?.settle;
|
|
331
|
+
pendingPicker = undefined;
|
|
332
|
+
editor.disableSubmit = false;
|
|
333
|
+
tui.setFocus(editor);
|
|
334
|
+
settle?.(id);
|
|
335
|
+
tui.requestRender();
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Why this run cannot open a stored session, or undefined when it can.
|
|
339
|
+
*
|
|
340
|
+
* A session runs the mode its own log recorded, so a `--preset` that
|
|
341
|
+
* disagrees with it can only be refused: the picker asks first so the refusal
|
|
342
|
+
* lands in the list rather than after the terminal has been handed back.
|
|
343
|
+
*/
|
|
344
|
+
const refuseReason = async (id) => {
|
|
345
|
+
if (requestedPreset === undefined || agentPresets === undefined)
|
|
346
|
+
return undefined;
|
|
347
|
+
try {
|
|
348
|
+
await presetFor(SessionId(id), true, undefined);
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
return error instanceof Error ? error.message : String(error);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
/** Take the keyboard for a picker and answer with the id it settled on. */
|
|
356
|
+
const openPicker = (picker, vet) => new Promise(resolve => {
|
|
357
|
+
pendingPicker = { picker, settle: resolve, vet };
|
|
358
|
+
editor.disableSubmit = true;
|
|
359
|
+
tui.setFocus(null);
|
|
360
|
+
tui.requestRender();
|
|
361
|
+
});
|
|
362
|
+
/** The roster as the picker paints it, refreshed when the picker opens. */
|
|
363
|
+
let presetRows = [];
|
|
364
|
+
/** Choose the mode a session that has not started yet will run. */
|
|
365
|
+
const askForPreset = async (currentId) => {
|
|
366
|
+
if (agentPresets === undefined)
|
|
367
|
+
return undefined;
|
|
368
|
+
presetRows = await agentPresets.list();
|
|
369
|
+
return await openPicker(new PresetPicker(() => presetRows, () => currentId));
|
|
370
|
+
};
|
|
371
|
+
/** Title the listed sessions without making the reader wait for the slowest log. */
|
|
372
|
+
const loadTitles = async (history, sessions, titles) => {
|
|
373
|
+
const queue = [...sessions];
|
|
374
|
+
const worker = async () => {
|
|
375
|
+
for (;;) {
|
|
376
|
+
const next = queue.shift();
|
|
377
|
+
if (next === undefined)
|
|
378
|
+
return;
|
|
379
|
+
try {
|
|
380
|
+
const title = await readSessionTitle(history, next);
|
|
381
|
+
if (title === undefined)
|
|
382
|
+
continue;
|
|
383
|
+
titles.set(next.id, title);
|
|
384
|
+
tui.requestRender();
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// A log this build cannot read stays listed by id; the picker is not
|
|
388
|
+
// the place to explain storage, and one bad session is not the list.
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
await Promise.all(Array.from({ length: TITLE_CONCURRENCY }, worker));
|
|
393
|
+
};
|
|
394
|
+
const storedSessions = async () => {
|
|
395
|
+
const history = createSessionHistory(ctx);
|
|
396
|
+
if (history === undefined) {
|
|
397
|
+
model.notice('this profile has no session storage, so there is nothing to resume');
|
|
398
|
+
tui.requestRender();
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const sessions = await history.list(PICKER_LIMIT);
|
|
403
|
+
if (sessions.length === 0) {
|
|
404
|
+
model.notice('no stored sessions to resume');
|
|
405
|
+
tui.requestRender();
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
return { history, sessions };
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
model.notice(`could not list stored sessions: ${error instanceof Error ? error.message : String(error)}`);
|
|
412
|
+
tui.requestRender();
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const chooseSession = async () => {
|
|
417
|
+
const listed = await storedSessions();
|
|
418
|
+
return listed === undefined ? undefined : askForSession(listed.history, listed.sessions);
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* Replay a stored session so a resumed run opens on the conversation the
|
|
422
|
+
* reader left, not on an empty screen: the durable log is the transcript.
|
|
423
|
+
*/
|
|
424
|
+
/**
|
|
425
|
+
* Fold one session's history into the transcript.
|
|
426
|
+
*
|
|
427
|
+
* A live session answers from memory, which is the only source that includes
|
|
428
|
+
* events not yet flushed and the only one that works for a child that has not
|
|
429
|
+
* materialized; a session this process is not running falls back to storage.
|
|
430
|
+
*/
|
|
431
|
+
const liveSession = (id) => ctx.get('sessions')?.get?.(id);
|
|
432
|
+
const foldHistory = async (id) => {
|
|
433
|
+
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
434
|
+
if (inMemory !== undefined) {
|
|
435
|
+
for (const event of inMemory)
|
|
436
|
+
applyEvent(event);
|
|
437
|
+
return inMemory.length;
|
|
438
|
+
}
|
|
439
|
+
const history = createSessionHistory(ctx);
|
|
440
|
+
if (history === undefined)
|
|
441
|
+
return 0;
|
|
442
|
+
const events = await history.read(id);
|
|
443
|
+
for (const event of events)
|
|
444
|
+
applyEvent(event);
|
|
445
|
+
return events.length;
|
|
446
|
+
};
|
|
447
|
+
const replayHistory = async (id) => {
|
|
448
|
+
try {
|
|
449
|
+
const folded = await foldHistory(id);
|
|
450
|
+
if (folded > 0)
|
|
451
|
+
model.notice(`replayed ${folded} events from the stored log`);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
model.notice(`could not replay this session: ${error instanceof Error ? error.message : String(error)}`);
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
/**
|
|
458
|
+
* Show another session in the transcript without leaving this one.
|
|
459
|
+
*
|
|
460
|
+
* A delegation is an ordinary session, so the reader can read what a child is
|
|
461
|
+
* doing rather than only that it exists; the agent this terminal drives does
|
|
462
|
+
* not change, which keeps commands, approvals, and the bell where they were.
|
|
463
|
+
*/
|
|
464
|
+
const showSession = async (id) => {
|
|
465
|
+
const previous = viewedSession;
|
|
466
|
+
model.reset();
|
|
467
|
+
work.reset();
|
|
468
|
+
viewedSession = id;
|
|
469
|
+
try {
|
|
470
|
+
await foldHistory(id);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
model.notice(`could not read that session: ${error instanceof Error ? error.message : String(error)}`);
|
|
474
|
+
}
|
|
475
|
+
const returned = id === activeSession && previous !== activeSession;
|
|
476
|
+
model.marker(returned
|
|
477
|
+
? 'back to the session this terminal drives'
|
|
478
|
+
: `viewing ${id}${id === activeSession ? '' : ` — ${LOCAL_KEYS_BACK}`}`);
|
|
479
|
+
tui.requestRender();
|
|
480
|
+
};
|
|
481
|
+
const showAgentSession = async () => {
|
|
482
|
+
await showSession(activeSession);
|
|
483
|
+
};
|
|
484
|
+
/** Feed one durable event to everything that folds it. */
|
|
485
|
+
const applyEvent = (event) => {
|
|
486
|
+
model.apply(event);
|
|
487
|
+
work.apply(event);
|
|
488
|
+
};
|
|
489
|
+
/** Every event of a session, from memory when this process runs it. */
|
|
490
|
+
const sessionEvents = async (id) => {
|
|
491
|
+
const inMemory = liveSession(id)?.snapshotEvents?.();
|
|
492
|
+
if (inMemory !== undefined)
|
|
493
|
+
return inMemory;
|
|
494
|
+
const history = createSessionHistory(ctx);
|
|
495
|
+
return history === undefined ? [] : history.read(id);
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* The mode one agent joins.
|
|
499
|
+
*
|
|
500
|
+
* A resumed session keeps the composition its own log recorded, because
|
|
501
|
+
* swapping it would leave logged tool calls the new composition cannot make —
|
|
502
|
+
* the same reason the harness refuses a switch once a turn has run. A session
|
|
503
|
+
* that recorded none, which is every session written before modes existed,
|
|
504
|
+
* takes the seat; a branch inherits the mode its parent is running.
|
|
505
|
+
*/
|
|
506
|
+
const presetFor = async (id, resume, fork) => {
|
|
507
|
+
if (agentPresets === undefined)
|
|
508
|
+
return undefined;
|
|
509
|
+
if (fork !== undefined)
|
|
510
|
+
return agentPresets.current(liveSession(fork.from)) ?? seat;
|
|
511
|
+
if (!resume)
|
|
512
|
+
return seat;
|
|
513
|
+
const history = createSessionHistory(ctx);
|
|
514
|
+
const stored = history === undefined ? undefined : await presetOfStoredSession(history, id);
|
|
515
|
+
if (stored === undefined)
|
|
516
|
+
return seat;
|
|
517
|
+
const resolvedStored = await resolveStored(id, stored);
|
|
518
|
+
if (resolvedStored === undefined) {
|
|
519
|
+
// The composition this session recorded is gone. Naming one explicitly is
|
|
520
|
+
// the reader's only way forward, so that is the one case an override is
|
|
521
|
+
// taken for a stored session.
|
|
522
|
+
if (requestedPreset !== undefined)
|
|
523
|
+
return (await agentPresets.resolve(requestedPreset)).id;
|
|
524
|
+
throw new Error(`session ${id} runs mode "${stored}", which this roster no longer offers; name another with --preset`);
|
|
525
|
+
}
|
|
526
|
+
if (requestedPreset !== undefined && requestedPreset !== resolvedStored) {
|
|
527
|
+
throw new Error(`session ${id} runs mode "${resolvedStored}", so --preset ${requestedPreset} does not apply; /preset ${requestedPreset} switches it before its first turn`);
|
|
528
|
+
}
|
|
529
|
+
return resolvedStored;
|
|
530
|
+
};
|
|
531
|
+
/** The stored mode's roster row, or undefined when the roster no longer offers it. */
|
|
532
|
+
const resolveStored = async (id, stored) => {
|
|
533
|
+
try {
|
|
534
|
+
return (await agentPresets?.resolve(stored))?.id;
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return undefined;
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
const openAgent = async (id, resume, fork) => {
|
|
541
|
+
// Settled before the transcript is touched, so a refusal leaves neither a
|
|
542
|
+
// half-replayed session nor a half-composed agent behind.
|
|
543
|
+
const preset = await presetFor(id, resume, fork);
|
|
544
|
+
if (resume)
|
|
545
|
+
await replayHistory(id);
|
|
546
|
+
const handle = await startAgent(ctx, {
|
|
547
|
+
sessionId: id,
|
|
548
|
+
resume,
|
|
549
|
+
model: resolved.model,
|
|
550
|
+
provider: resolved.provider,
|
|
551
|
+
cwd: process.cwd(),
|
|
552
|
+
preset,
|
|
553
|
+
setup: async (agentCtx) => {
|
|
554
|
+
modelSwitch.install(agentCtx);
|
|
555
|
+
if (preset !== undefined)
|
|
556
|
+
await agentPresets?.mount(agentCtx, preset);
|
|
557
|
+
},
|
|
558
|
+
...(fork === undefined ? {} : { fork }),
|
|
559
|
+
});
|
|
560
|
+
sessionOpened = true;
|
|
561
|
+
activeSession = id;
|
|
562
|
+
viewedSession = id;
|
|
563
|
+
agent = handle;
|
|
564
|
+
// A branch inherits the conversation the reader was already reading, so it
|
|
565
|
+
// opens on that history rather than on an empty screen.
|
|
566
|
+
if (fork !== undefined)
|
|
567
|
+
await foldHistory(id);
|
|
568
|
+
disposers.push(() => {
|
|
569
|
+
void handle.dispose();
|
|
570
|
+
});
|
|
571
|
+
installCompletion();
|
|
572
|
+
model.notice(`session ${handle.sessionId}${resume ? ' (resumed)' : ''}`);
|
|
573
|
+
tui.requestRender();
|
|
574
|
+
};
|
|
575
|
+
/** Move the surface to another stored session without leaving the terminal. */
|
|
576
|
+
const switchSession = async (id) => {
|
|
577
|
+
const previous = agent;
|
|
578
|
+
agent = undefined;
|
|
579
|
+
turnOpen = false;
|
|
580
|
+
model.reset();
|
|
581
|
+
work.reset();
|
|
582
|
+
roster.reset();
|
|
583
|
+
if (previous !== undefined)
|
|
584
|
+
await previous.dispose();
|
|
585
|
+
await openAgent(id, true);
|
|
586
|
+
};
|
|
587
|
+
/**
|
|
588
|
+
* Show or choose the route the next step will use.
|
|
589
|
+
*
|
|
590
|
+
* The choice is session-scoped: it changes nothing about the settings a later
|
|
591
|
+
* run reads, and the loop logs its own durable notice when the route a request
|
|
592
|
+
* actually used changes.
|
|
593
|
+
*/
|
|
594
|
+
/**
|
|
595
|
+
* Show, read, or kill a background job.
|
|
596
|
+
*
|
|
597
|
+
* A terminal has no second window, so a job started by the model is otherwise
|
|
598
|
+
* invisible: the board is the only place a reader can see what is still
|
|
599
|
+
* running and stop it.
|
|
600
|
+
*/
|
|
601
|
+
/**
|
|
602
|
+
* List or stop the delegations this session started.
|
|
603
|
+
*
|
|
604
|
+
* A child runs in the same process as an ordinary agent, so a stop is the
|
|
605
|
+
* cancel a reader's Ctrl+C sends to the parent; nothing here reaches into the
|
|
606
|
+
* child's own session, which keeps its own transcript either way.
|
|
607
|
+
*/
|
|
608
|
+
const runSubagentsCommand = (argument) => {
|
|
609
|
+
const command = parseSubagentsArgument(argument);
|
|
610
|
+
switch (command.kind) {
|
|
611
|
+
case 'list':
|
|
612
|
+
model.notice(describeSubagents(roster.list(), Date.now()));
|
|
613
|
+
tui.requestRender();
|
|
614
|
+
return;
|
|
615
|
+
case 'open': {
|
|
616
|
+
const run = resolveRun(roster.list(), command.id);
|
|
617
|
+
if (run === undefined) {
|
|
618
|
+
model.notice(`${command.id}: no single child matches; /subagents lists them`);
|
|
619
|
+
tui.requestRender();
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
void showSession(SessionId(run.id));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
case 'kill': {
|
|
626
|
+
const stopped = subagentControl?.stop(command.id) ?? false;
|
|
627
|
+
model.notice(stopped
|
|
628
|
+
? `${command.id}: stop requested`
|
|
629
|
+
: `${command.id}: no live child with that id`);
|
|
630
|
+
tui.requestRender();
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
case 'invalid':
|
|
634
|
+
model.notice(`/subagents: ${command.reason}`);
|
|
635
|
+
tui.requestRender();
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
const runJobsCommand = (argument) => {
|
|
640
|
+
if (jobDirectory === undefined) {
|
|
641
|
+
model.notice('this profile has no job registry, so there is nothing to list');
|
|
642
|
+
tui.requestRender();
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (agent === undefined) {
|
|
646
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
647
|
+
tui.requestRender();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const command = parseJobsArgument(argument);
|
|
651
|
+
switch (command.kind) {
|
|
652
|
+
case 'list':
|
|
653
|
+
refreshJobs();
|
|
654
|
+
model.notice(describeJobs(jobs, Date.now()));
|
|
655
|
+
tui.requestRender();
|
|
656
|
+
return;
|
|
657
|
+
case 'read': {
|
|
658
|
+
const result = jobDirectory.read(agent.agent, command.id);
|
|
659
|
+
const text = result?.text.trim() ?? '';
|
|
660
|
+
model.notice(text === ''
|
|
661
|
+
? `${command.id}: no output yet`
|
|
662
|
+
: `${command.id} output\n${text.split('\n').slice(-JOB_READ_LINES).join('\n')}`);
|
|
663
|
+
refreshJobs();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
case 'kill': {
|
|
667
|
+
const outcome = jobDirectory.kill(agent.agent, command.id);
|
|
668
|
+
model.notice(outcome === undefined
|
|
669
|
+
? `${command.id}: no such job`
|
|
670
|
+
: outcome === 'requested' ? `${command.id}: stop requested` : `${command.id} had already finished`);
|
|
671
|
+
refreshJobs();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
case 'invalid':
|
|
675
|
+
model.notice(`/jobs: ${command.reason}`);
|
|
676
|
+
tui.requestRender();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
/**
|
|
681
|
+
* Give this session a title.
|
|
682
|
+
*
|
|
683
|
+
* The title is what the resume picker shows and what every other surface
|
|
684
|
+
* displays, so a reader who has several sessions can name the one they are in
|
|
685
|
+
* without waiting for the harness to guess.
|
|
686
|
+
*/
|
|
687
|
+
/**
|
|
688
|
+
* Write what the reader can see to a file.
|
|
689
|
+
*
|
|
690
|
+
* The base's `/export` downloads the log through the browser, which a
|
|
691
|
+
* terminal has no way to do, so this dumps the transcript the reader is
|
|
692
|
+
* looking at — the thing worth pasting into a message.
|
|
693
|
+
*/
|
|
694
|
+
const runExportCommand = (argument) => {
|
|
695
|
+
const path = resolve(argument === '' ? defaultExportFile(String(activeSession)) : argument);
|
|
696
|
+
try {
|
|
697
|
+
writeFileSync(path, transcriptToText(model.entries()), 'utf8');
|
|
698
|
+
model.notice(`transcript written to ${path}`);
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
model.notice(`could not write ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
702
|
+
}
|
|
703
|
+
tui.requestRender();
|
|
704
|
+
};
|
|
705
|
+
/**
|
|
706
|
+
* Branch this conversation and continue in the branch.
|
|
707
|
+
*
|
|
708
|
+
* The branch is a real session with its own identity that inherits a prefix
|
|
709
|
+
* of this one, so a reader can try something without spending the
|
|
710
|
+
* conversation they already had. The cut is anchored to the last completed
|
|
711
|
+
* turn, because half an exchange is not a state to hand a model.
|
|
712
|
+
*/
|
|
713
|
+
/** Start a fresh session without leaving the terminal. */
|
|
714
|
+
const runNewCommand = (title) => {
|
|
715
|
+
if (agent === undefined) {
|
|
716
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
717
|
+
tui.requestRender();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
void (async () => {
|
|
721
|
+
const previous = agent;
|
|
722
|
+
agent = undefined;
|
|
723
|
+
turnOpen = false;
|
|
724
|
+
turnStartedAt = undefined;
|
|
725
|
+
model.reset();
|
|
726
|
+
work.reset();
|
|
727
|
+
roster.reset();
|
|
728
|
+
if (previous !== undefined)
|
|
729
|
+
await previous.dispose();
|
|
730
|
+
await openAgent(SessionId(`tui-session-${randomUUID()}`), false);
|
|
731
|
+
if (title !== '')
|
|
732
|
+
runRenameCommand(title);
|
|
733
|
+
model.notice('started a new session');
|
|
734
|
+
tui.requestRender();
|
|
735
|
+
})().catch((error) => {
|
|
736
|
+
model.notice(`could not start a session: ${error instanceof Error ? error.message : String(error)}`);
|
|
737
|
+
tui.requestRender();
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
/** Show the todo list the agent has been keeping. */
|
|
741
|
+
const runTodoCommand = () => {
|
|
742
|
+
model.notice(describeTodos(work.state().todos));
|
|
743
|
+
tui.requestRender();
|
|
744
|
+
};
|
|
745
|
+
/**
|
|
746
|
+
* Put the last answer on the reader's clipboard.
|
|
747
|
+
*
|
|
748
|
+
* The clipboard belongs to the terminal, so this asks it through OSC 52 —
|
|
749
|
+
* which is also the only route that works over SSH.
|
|
750
|
+
*/
|
|
751
|
+
const runCopyCommand = () => {
|
|
752
|
+
const last = [...model.entries()].reverse().find(entry => entry.kind === 'assistant');
|
|
753
|
+
if (last === undefined || last.kind !== 'assistant') {
|
|
754
|
+
model.notice('nothing to copy yet');
|
|
755
|
+
tui.requestRender();
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
terminal.write(clipboardSequence(last.text));
|
|
759
|
+
model.notice(`copied ${last.text.length} characters through the terminal`);
|
|
760
|
+
tui.requestRender();
|
|
761
|
+
};
|
|
762
|
+
const runForkCommand = (title) => {
|
|
763
|
+
if (agent === undefined) {
|
|
764
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
765
|
+
tui.requestRender();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
void (async () => {
|
|
769
|
+
const source = activeSession;
|
|
770
|
+
const events = await sessionEvents(source);
|
|
771
|
+
const point = forkPoint(events);
|
|
772
|
+
if (point === undefined) {
|
|
773
|
+
model.notice('nothing to fork yet: this session has no completed turn');
|
|
774
|
+
tui.requestRender();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const childId = SessionId(`tui-session-${randomUUID()}`);
|
|
778
|
+
const previous = agent;
|
|
779
|
+
agent = undefined;
|
|
780
|
+
turnOpen = false;
|
|
781
|
+
turnStartedAt = undefined;
|
|
782
|
+
model.reset();
|
|
783
|
+
work.reset();
|
|
784
|
+
roster.reset();
|
|
785
|
+
if (previous !== undefined)
|
|
786
|
+
await previous.dispose();
|
|
787
|
+
await openAgent(childId, false, { from: source, events: events.slice(0, point.inheritedEvents) });
|
|
788
|
+
if (title !== '')
|
|
789
|
+
runRenameCommand(title);
|
|
790
|
+
model.notice(`forked from ${source} at event ${point.boundarySeq} — ${point.inheritedEvents} inherited`);
|
|
791
|
+
tui.requestRender();
|
|
792
|
+
})().catch((error) => {
|
|
793
|
+
model.notice(`could not fork: ${error instanceof Error ? error.message : String(error)}`);
|
|
794
|
+
tui.requestRender();
|
|
795
|
+
});
|
|
796
|
+
};
|
|
797
|
+
const runRenameCommand = (title) => {
|
|
798
|
+
if (title === '') {
|
|
799
|
+
model.notice('use /rename <title>; the title is what the resume picker shows');
|
|
800
|
+
tui.requestRender();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const session = ctx.get('sessions')?.get?.(activeSession);
|
|
804
|
+
const titles = ctx.get('sessionTitle');
|
|
805
|
+
if (session === undefined || typeof titles?.rename !== 'function') {
|
|
806
|
+
model.notice('this profile has no session-title service, so this session cannot be renamed');
|
|
807
|
+
tui.requestRender();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const accepted = titles.rename(session, title);
|
|
812
|
+
model.notice(`session renamed to "${typeof accepted?.title === 'string' ? accepted.title : title}"`);
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
model.notice(`could not rename: ${error instanceof Error ? error.message : String(error)}`);
|
|
816
|
+
}
|
|
817
|
+
tui.requestRender();
|
|
818
|
+
};
|
|
819
|
+
const runModelCommand = (argument) => {
|
|
820
|
+
if (catalog === undefined) {
|
|
821
|
+
model.notice('this profile has no llm service, so models cannot be listed or switched');
|
|
822
|
+
tui.requestRender();
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const command = parseModelArgument(argument, catalog.providers(), modelSwitch.current());
|
|
826
|
+
switch (command.kind) {
|
|
827
|
+
case 'current': {
|
|
828
|
+
const facts = statusFacts();
|
|
829
|
+
const current = modelSwitch.current();
|
|
830
|
+
const route = current === undefined
|
|
831
|
+
// Without a choice of its own the surface reports what the next step
|
|
832
|
+
// would actually use, not that it has no opinion.
|
|
833
|
+
? `${facts.model ?? 'unset'}${facts.effort === undefined ? '' : ` (${facts.effort})`} · composition default`
|
|
834
|
+
: `${current.provider}/${current.model}${current.reasoningEffort === undefined ? '' : ` (${current.reasoningEffort})`}`;
|
|
835
|
+
const providers = catalog.providers().map(provider => provider.id);
|
|
836
|
+
model.notice(`model ${route} · providers: ${providers.length === 0 ? 'none' : providers.join(', ')} · /model <provider>/<model> switches, /model <provider> lists its models`);
|
|
837
|
+
tui.requestRender();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
case 'list-models':
|
|
841
|
+
void catalog.models(command.provider).then(entries => {
|
|
842
|
+
model.notice(entries.length === 0
|
|
843
|
+
? `${command.provider} advertises no models; an id may still work`
|
|
844
|
+
: `${command.provider}: ${entries.map(entry => entry.id).join(' ')}`);
|
|
845
|
+
tui.requestRender();
|
|
846
|
+
}).catch((error) => {
|
|
847
|
+
model.notice(`could not list models: ${error instanceof Error ? error.message : String(error)}`);
|
|
848
|
+
tui.requestRender();
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
case 'switch':
|
|
852
|
+
modelSwitch.choose(command.choice);
|
|
853
|
+
model.notice(`model set to ${command.choice.provider}/${command.choice.model} for the next step`);
|
|
854
|
+
tui.requestRender();
|
|
855
|
+
return;
|
|
856
|
+
case 'invalid':
|
|
857
|
+
model.notice(`/model: ${command.reason}`);
|
|
858
|
+
tui.requestRender();
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
/**
|
|
863
|
+
* Show or choose the mode this session runs.
|
|
864
|
+
*
|
|
865
|
+
* The mode decides which tools, prompt sections, and skills exist at all, so
|
|
866
|
+
* it is fixed once a turn has run: the harness refuses the swap and this
|
|
867
|
+
* surface explains why rather than pretending otherwise. Before the first
|
|
868
|
+
* turn the switch is recorded in the log, which is what keeps the transcript
|
|
869
|
+
* honest about the composition later turns ran under.
|
|
870
|
+
*/
|
|
871
|
+
const runPresetCommand = (argument) => {
|
|
872
|
+
if (agentPresets === undefined) {
|
|
873
|
+
model.notice('this profile has no agent roster, so there is no mode to choose');
|
|
874
|
+
tui.requestRender();
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (agent === undefined) {
|
|
878
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
879
|
+
tui.requestRender();
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const session = agent.agent.session;
|
|
883
|
+
const current = agentPresets.current(session);
|
|
884
|
+
const command = parsePresetArgument(argument);
|
|
885
|
+
if (command.kind === 'pick') {
|
|
886
|
+
if (agentPresets.started(session)) {
|
|
887
|
+
model.notice(`this session runs ${current === undefined ? 'a mode' : `"${current}"`} and has already started, so its mode is fixed — /new starts a fresh session`);
|
|
888
|
+
tui.requestRender();
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
void askForPreset(current).then(picked => picked === undefined ? undefined : applyPreset(picked));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
void applyPreset(command.id);
|
|
895
|
+
};
|
|
896
|
+
/** Join this session to another mode and remember it for the next session. */
|
|
897
|
+
const applyPreset = async (id) => {
|
|
898
|
+
if (agentPresets === undefined || agent === undefined)
|
|
899
|
+
return;
|
|
900
|
+
try {
|
|
901
|
+
const chosen = await agentPresets.select(agent.agent, id);
|
|
902
|
+
seat = chosen;
|
|
903
|
+
// The durable selection is folded as a marker on the session it belongs
|
|
904
|
+
// to, so a reader watching a child has to come back to see it.
|
|
905
|
+
if (viewedSession !== activeSession)
|
|
906
|
+
await showSession(activeSession);
|
|
907
|
+
}
|
|
908
|
+
catch (error) {
|
|
909
|
+
model.notice(`could not switch the mode: ${error instanceof Error ? error.message : String(error)}`);
|
|
910
|
+
}
|
|
911
|
+
tui.requestRender();
|
|
912
|
+
};
|
|
913
|
+
const helpText = () => {
|
|
914
|
+
const current = agent?.agent;
|
|
915
|
+
const registered = current === undefined || registry() === undefined
|
|
916
|
+
? []
|
|
917
|
+
: registry()?.list(current).map(command => `/${command.name}`) ?? [];
|
|
918
|
+
const commands = registered.length === 0 ? 'none registered yet' : registered.join(' ');
|
|
919
|
+
return `commands: ${commands} · surface: ${LOCAL_COMMANDS.join(' ')} · keys: ${LOCAL_KEYS}`;
|
|
920
|
+
};
|
|
921
|
+
const runCommand = (name, line) => {
|
|
922
|
+
const current = agent;
|
|
923
|
+
const commands = registry();
|
|
924
|
+
if (current === undefined) {
|
|
925
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
926
|
+
tui.requestRender();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (commands === undefined || commands.find(current.agent, name) === undefined) {
|
|
930
|
+
model.notice(`unknown command: /${name} — ${helpText()}`);
|
|
931
|
+
tui.requestRender();
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const controller = new AbortController();
|
|
935
|
+
void commands.execute(current.agent, line, [], controller.signal).then(execution => {
|
|
936
|
+
const result = execution?.result;
|
|
937
|
+
if (result === undefined)
|
|
938
|
+
return;
|
|
939
|
+
model.notice(result.kind === 'error' ? `/${name} failed: ${result.text ?? 'no detail'}` : `/${name} ${result.text ?? 'done'}`);
|
|
940
|
+
tui.requestRender();
|
|
941
|
+
}).catch((error) => {
|
|
942
|
+
model.notice(`/${name} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
943
|
+
tui.requestRender();
|
|
944
|
+
});
|
|
945
|
+
};
|
|
946
|
+
editor.onSubmit = text => {
|
|
947
|
+
const submission = classifySubmission(text);
|
|
948
|
+
switch (submission.kind) {
|
|
949
|
+
case 'empty':
|
|
950
|
+
return;
|
|
951
|
+
case 'quit':
|
|
952
|
+
requestExit(0);
|
|
953
|
+
return;
|
|
954
|
+
case 'model':
|
|
955
|
+
runModelCommand(submission.argument);
|
|
956
|
+
return;
|
|
957
|
+
case 'preset':
|
|
958
|
+
runPresetCommand(submission.argument);
|
|
959
|
+
return;
|
|
960
|
+
case 'jobs':
|
|
961
|
+
runJobsCommand(submission.argument);
|
|
962
|
+
return;
|
|
963
|
+
case 'rename':
|
|
964
|
+
runRenameCommand(submission.title);
|
|
965
|
+
return;
|
|
966
|
+
case 'export':
|
|
967
|
+
runExportCommand(submission.path);
|
|
968
|
+
return;
|
|
969
|
+
case 'subagents':
|
|
970
|
+
runSubagentsCommand(submission.argument);
|
|
971
|
+
return;
|
|
972
|
+
case 'fork':
|
|
973
|
+
runForkCommand(submission.title);
|
|
974
|
+
return;
|
|
975
|
+
case 'new':
|
|
976
|
+
runNewCommand(submission.title);
|
|
977
|
+
return;
|
|
978
|
+
case 'todo':
|
|
979
|
+
runTodoCommand();
|
|
980
|
+
return;
|
|
981
|
+
case 'copy':
|
|
982
|
+
runCopyCommand();
|
|
983
|
+
return;
|
|
984
|
+
case 'status': {
|
|
985
|
+
const facts = statusFacts();
|
|
986
|
+
const context = facts.contextTokens === undefined
|
|
987
|
+
? undefined
|
|
988
|
+
: `context ${formatTokens(facts.contextTokens)}${facts.contextWindow === undefined ? '' : `/${formatTokens(facts.contextWindow)}`}`;
|
|
989
|
+
model.notice([
|
|
990
|
+
`session ${activeSession}`,
|
|
991
|
+
viewedSession === activeSession ? undefined : `viewing ${viewedSession}`,
|
|
992
|
+
facts.model === undefined
|
|
993
|
+
? undefined
|
|
994
|
+
: `model ${facts.provider === undefined ? '' : `${facts.provider}/`}${facts.model}${facts.effort === undefined ? '' : ` (${facts.effort})`}`,
|
|
995
|
+
facts.agentPreset === undefined ? undefined : `mode ${facts.agentPreset}`,
|
|
996
|
+
facts.preset === undefined ? undefined : `permissions ${facts.preset}`,
|
|
997
|
+
context,
|
|
998
|
+
facts.uncachedInputTokens === undefined && facts.outputTokens === undefined
|
|
999
|
+
? undefined
|
|
1000
|
+
: `tokens in ${formatTokens(facts.uncachedInputTokens ?? 0)} out ${formatTokens(facts.outputTokens ?? 0)}`,
|
|
1001
|
+
`cwd ${facts.cwd}`,
|
|
1002
|
+
].filter(part => part !== undefined).join(' · '));
|
|
1003
|
+
tui.requestRender();
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
case 'clear':
|
|
1007
|
+
model.reset();
|
|
1008
|
+
work.reset();
|
|
1009
|
+
tui.requestRender();
|
|
1010
|
+
return;
|
|
1011
|
+
case 'help':
|
|
1012
|
+
model.notice(helpText());
|
|
1013
|
+
tui.requestRender();
|
|
1014
|
+
return;
|
|
1015
|
+
case 'resume':
|
|
1016
|
+
void chooseSession().then(picked => picked === undefined ? undefined : switchSession(picked)).catch((error) => {
|
|
1017
|
+
model.notice(`could not resume: ${error instanceof Error ? error.message : String(error)}`);
|
|
1018
|
+
tui.requestRender();
|
|
1019
|
+
});
|
|
1020
|
+
return;
|
|
1021
|
+
case 'command':
|
|
1022
|
+
runCommand(submission.name, submission.line);
|
|
1023
|
+
return;
|
|
1024
|
+
case 'prompt':
|
|
1025
|
+
if (agent === undefined) {
|
|
1026
|
+
model.notice('the agent is still starting; try again in a moment');
|
|
1027
|
+
tui.requestRender();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
// While a turn is running the human is steering it, not opening another.
|
|
1031
|
+
if (turnOpen)
|
|
1032
|
+
agent.steer(submission.text);
|
|
1033
|
+
else
|
|
1034
|
+
agent.submit(submission.text);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
disposers.push(ctx.on('session/event', (session, event) => {
|
|
1038
|
+
// The surface state — activity, timer, title, bell, job board — belongs to
|
|
1039
|
+
// the agent this terminal drives, even while a child is on screen.
|
|
1040
|
+
if (session.id === activeSession) {
|
|
1041
|
+
if (event.type === 'turn/start') {
|
|
1042
|
+
turnOpen = true;
|
|
1043
|
+
turnStartedAt = Date.now();
|
|
1044
|
+
terminal.write(windowTitle(process.cwd(), 'working'));
|
|
1045
|
+
}
|
|
1046
|
+
if (event.type === 'turn/end') {
|
|
1047
|
+
const ranFor = turnStartedAt === undefined ? 0 : Date.now() - turnStartedAt;
|
|
1048
|
+
turnOpen = false;
|
|
1049
|
+
turnStartedAt = undefined;
|
|
1050
|
+
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1051
|
+
if (shouldRingBell({ bell: resolved.bell, ranForMs: ranFor, exiting: exited }))
|
|
1052
|
+
terminal.write(BELL);
|
|
1053
|
+
// A job the turn started may have settled while the reader was watching
|
|
1054
|
+
// something else, and nothing else refreshes a live board.
|
|
1055
|
+
refreshJobs();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
if (session.id !== viewedSession)
|
|
1059
|
+
return;
|
|
1060
|
+
applyEvent(event);
|
|
1061
|
+
tui.requestRender();
|
|
1062
|
+
}));
|
|
1063
|
+
/**
|
|
1064
|
+
* Subagent lifecycle arrives as a service event rather than a session event,
|
|
1065
|
+
* so it is decoration in the transcript: the durable record of a delegation
|
|
1066
|
+
* is the tool call that asked for it. The name is cast so a rename in the
|
|
1067
|
+
* harness cannot break compilation of this surface.
|
|
1068
|
+
*/
|
|
1069
|
+
const listenFor = (name, handler) => ctx.on(name, handler);
|
|
1070
|
+
disposers.push(listenFor('subagent/start', info => {
|
|
1071
|
+
roster.start(info);
|
|
1072
|
+
const record = asRecord(info);
|
|
1073
|
+
const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
|
|
1074
|
+
const id = typeof record?.id === 'string' ? record.id : '';
|
|
1075
|
+
model.marker(`subagent ${provider} started${id === '' ? '' : ` · ${id}`}`);
|
|
1076
|
+
tui.requestRender();
|
|
1077
|
+
}));
|
|
1078
|
+
disposers.push(listenFor('subagent/end', info => {
|
|
1079
|
+
roster.end(info);
|
|
1080
|
+
const record = asRecord(info);
|
|
1081
|
+
const provider = typeof record?.provider === 'string' ? record.provider : 'subagent';
|
|
1082
|
+
const stop = typeof record?.stopReason === 'string' ? record.stopReason : undefined;
|
|
1083
|
+
model.marker(`subagent ${provider} finished${stop === undefined ? '' : ` · ${stop}`}`);
|
|
1084
|
+
tui.requestRender();
|
|
1085
|
+
}));
|
|
1086
|
+
// Answering these two waterfalls is what makes a terminal surface usable at
|
|
1087
|
+
// all: without an answerer every gated tool fails closed, and the model's
|
|
1088
|
+
// questions never reach the human.
|
|
1089
|
+
disposers.push(ctx.on('approval/request', (request, next) => {
|
|
1090
|
+
if (request.agent.id !== activeSession)
|
|
1091
|
+
return next();
|
|
1092
|
+
return new Promise(resolve => {
|
|
1093
|
+
const gate = new ApprovalGate(request.toolName, request.reason);
|
|
1094
|
+
request.signal?.addEventListener('abort', () => {
|
|
1095
|
+
gate.cancel();
|
|
1096
|
+
if (pending?.gate === gate)
|
|
1097
|
+
closeGate();
|
|
1098
|
+
resolve('cancelled');
|
|
1099
|
+
}, { once: true });
|
|
1100
|
+
openGate({ kind: 'approval', gate, settle: resolve });
|
|
1101
|
+
});
|
|
1102
|
+
}));
|
|
1103
|
+
disposers.push(ctx.on('user-questions/request', (request, next) => {
|
|
1104
|
+
const agentId = request.agent?.id;
|
|
1105
|
+
if (agentId !== undefined && agentId !== activeSession)
|
|
1106
|
+
return next();
|
|
1107
|
+
const questions = toGateQuestions(request);
|
|
1108
|
+
if (questions.length === 0)
|
|
1109
|
+
return next();
|
|
1110
|
+
return new Promise(resolve => {
|
|
1111
|
+
const gate = new QuestionGate(questions);
|
|
1112
|
+
// The seam takes mutable selection arrays and an optional custom field, so
|
|
1113
|
+
// the read-only gate answer is copied into that exact shape here.
|
|
1114
|
+
openGate({
|
|
1115
|
+
kind: 'question',
|
|
1116
|
+
gate,
|
|
1117
|
+
settle: answers => resolve({
|
|
1118
|
+
answers: answers.map(answer => ({
|
|
1119
|
+
id: answer.id,
|
|
1120
|
+
selected: [...answer.selected],
|
|
1121
|
+
...(answer.custom === undefined ? {} : { custom: answer.custom }),
|
|
1122
|
+
})),
|
|
1123
|
+
}),
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
}));
|
|
1127
|
+
disposers.push(ctx.on('commands/change', () => installCompletion()));
|
|
1128
|
+
// The board is live state: watch it directly rather than folding events.
|
|
1129
|
+
disposers.push(jobDirectory?.watch(owner => {
|
|
1130
|
+
if (owner !== undefined && owner.id !== activeSession)
|
|
1131
|
+
return;
|
|
1132
|
+
refreshJobs();
|
|
1133
|
+
}) ?? (() => { }));
|
|
1134
|
+
disposers.push(ctx.on('agent/error', payload => {
|
|
1135
|
+
if (payload.agent.id !== activeSession)
|
|
1136
|
+
return;
|
|
1137
|
+
model.reportError(payload.error);
|
|
1138
|
+
tui.requestRender();
|
|
1139
|
+
}));
|
|
1140
|
+
disposers.push(ctx.on('agent/assistant-stream', payload => {
|
|
1141
|
+
if (payload.agent.id !== activeSession)
|
|
1142
|
+
return;
|
|
1143
|
+
if (payload.frame.type !== 'chunk')
|
|
1144
|
+
return;
|
|
1145
|
+
model.applyStreamChunk(payload.frame.chunk);
|
|
1146
|
+
tui.requestRender();
|
|
1147
|
+
}));
|
|
1148
|
+
const degraded = describeMissingOptional(probe);
|
|
1149
|
+
if (degraded !== undefined)
|
|
1150
|
+
model.notice(degraded);
|
|
1151
|
+
/**
|
|
1152
|
+
* Open the session this run was launched for.
|
|
1153
|
+
*
|
|
1154
|
+
* A bare `--resume` asks a question only the reader can answer, so the
|
|
1155
|
+
* picker runs before anything is created: the agent is then opened on the
|
|
1156
|
+
* chosen session rather than swapped afterwards, which would leave the first
|
|
1157
|
+
* one's turn half-started.
|
|
1158
|
+
*/
|
|
1159
|
+
const boot = async () => {
|
|
1160
|
+
if (!resolved.resumePicker) {
|
|
1161
|
+
await openAgent(resolved.sessionId, resolved.resume);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
const picked = await chooseSession();
|
|
1165
|
+
if (picked === undefined) {
|
|
1166
|
+
requestExit(0);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
await openAgent(picked, true);
|
|
1170
|
+
};
|
|
1171
|
+
/**
|
|
1172
|
+
* Take the screen, then open the session this run was launched for.
|
|
1173
|
+
*
|
|
1174
|
+
* A mode named on the command line is resolved FIRST: the alt screen swallows
|
|
1175
|
+
* the launcher's own error output, so a mode this roster does not offer has to
|
|
1176
|
+
* be answered while the shell still owns the terminal.
|
|
1177
|
+
*/
|
|
1178
|
+
const start = async () => {
|
|
1179
|
+
if (requestedPreset !== undefined && agentPresets !== undefined) {
|
|
1180
|
+
seat = (await agentPresets.resolve(requestedPreset)).id;
|
|
1181
|
+
}
|
|
1182
|
+
// A named mode that disagrees with the one this session recorded is refused
|
|
1183
|
+
// here as well as at the open, because the alternate screen closes over
|
|
1184
|
+
// whatever was painted on it: the reader would see the failure, not the
|
|
1185
|
+
// reason. With a picker the session is not known yet, so it waits for one.
|
|
1186
|
+
if (!resolved.resumePicker)
|
|
1187
|
+
await presetFor(resolved.sessionId, resolved.resume, undefined);
|
|
1188
|
+
tui.start();
|
|
1189
|
+
terminal.write(windowTitle(process.cwd(), 'ready'));
|
|
1190
|
+
await boot();
|
|
1191
|
+
};
|
|
1192
|
+
void start().catch((error) => {
|
|
1193
|
+
requestExit(1, error instanceof Error ? error.message : String(error));
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
//# sourceMappingURL=index.js.map
|