@ocis/myagent-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +357 -0
  2. package/dist/agent/context.d.ts +33 -0
  3. package/dist/agent/context.js +169 -0
  4. package/dist/agent/modes.d.ts +21 -0
  5. package/dist/agent/modes.js +84 -0
  6. package/dist/agent/prompt-builder.d.ts +9 -0
  7. package/dist/agent/prompt-builder.js +31 -0
  8. package/dist/agent/sessions.d.ts +38 -0
  9. package/dist/agent/sessions.js +130 -0
  10. package/dist/agent/todo.d.ts +18 -0
  11. package/dist/agent/todo.js +61 -0
  12. package/dist/agent/turn.d.ts +309 -0
  13. package/dist/agent/turn.js +1253 -0
  14. package/dist/approval/policy.d.ts +81 -0
  15. package/dist/approval/policy.js +157 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +156 -0
  18. package/dist/git/status.d.ts +89 -0
  19. package/dist/git/status.js +226 -0
  20. package/dist/headless.d.ts +72 -0
  21. package/dist/headless.js +330 -0
  22. package/dist/index.d.ts +60 -0
  23. package/dist/index.js +511 -0
  24. package/dist/protocol/client.d.ts +123 -0
  25. package/dist/protocol/client.js +250 -0
  26. package/dist/protocol/sse-frames.d.ts +6 -0
  27. package/dist/protocol/sse-frames.js +75 -0
  28. package/dist/protocol/types.d.ts +200 -0
  29. package/dist/protocol/types.js +8 -0
  30. package/dist/runtime.d.ts +38 -0
  31. package/dist/runtime.js +166 -0
  32. package/dist/sanitize.d.ts +1 -0
  33. package/dist/sanitize.js +21 -0
  34. package/dist/skills/discovery.d.ts +24 -0
  35. package/dist/skills/discovery.js +109 -0
  36. package/dist/tools/binary.d.ts +2 -0
  37. package/dist/tools/binary.js +22 -0
  38. package/dist/tools/diff.d.ts +1 -0
  39. package/dist/tools/diff.js +49 -0
  40. package/dist/tools/find.d.ts +2 -0
  41. package/dist/tools/find.js +61 -0
  42. package/dist/tools/fs.d.ts +2 -0
  43. package/dist/tools/fs.js +276 -0
  44. package/dist/tools/glob.d.ts +6 -0
  45. package/dist/tools/glob.js +131 -0
  46. package/dist/tools/grep.d.ts +3 -0
  47. package/dist/tools/grep.js +228 -0
  48. package/dist/tools/paths.d.ts +27 -0
  49. package/dist/tools/paths.js +124 -0
  50. package/dist/tools/registry.d.ts +13 -0
  51. package/dist/tools/registry.js +38 -0
  52. package/dist/tools/shell.d.ts +2 -0
  53. package/dist/tools/shell.js +136 -0
  54. package/dist/tools/skills.d.ts +2 -0
  55. package/dist/tools/skills.js +36 -0
  56. package/dist/tools/todo.d.ts +2 -0
  57. package/dist/tools/todo.js +43 -0
  58. package/dist/tools/transfer.d.ts +2 -0
  59. package/dist/tools/transfer.js +145 -0
  60. package/dist/tools/truncate.d.ts +12 -0
  61. package/dist/tools/truncate.js +46 -0
  62. package/dist/tools/types.d.ts +85 -0
  63. package/dist/tools/types.js +63 -0
  64. package/dist/ui/app.d.ts +39 -0
  65. package/dist/ui/app.js +1061 -0
  66. package/dist/ui/colors.d.ts +100 -0
  67. package/dist/ui/colors.js +169 -0
  68. package/dist/ui/components.d.ts +267 -0
  69. package/dist/ui/components.js +811 -0
  70. package/dist/ui/diff.d.ts +37 -0
  71. package/dist/ui/diff.js +143 -0
  72. package/dist/ui/format.d.ts +28 -0
  73. package/dist/ui/format.js +76 -0
  74. package/dist/ui/help.d.ts +6 -0
  75. package/dist/ui/help.js +45 -0
  76. package/dist/ui/highlight.d.ts +20 -0
  77. package/dist/ui/highlight.js +210 -0
  78. package/dist/ui/logo.d.ts +24 -0
  79. package/dist/ui/logo.js +106 -0
  80. package/dist/ui/model-list.d.ts +10 -0
  81. package/dist/ui/model-list.js +33 -0
  82. package/dist/ui/quit-confirm.d.ts +8 -0
  83. package/dist/ui/quit-confirm.js +40 -0
  84. package/dist/ui/select-popup.d.ts +30 -0
  85. package/dist/ui/select-popup.js +54 -0
  86. package/dist/ui/theme.d.ts +4 -0
  87. package/dist/ui/theme.js +41 -0
  88. package/dist/ui/tool-view.d.ts +20 -0
  89. package/dist/ui/tool-view.js +326 -0
  90. package/package.json +44 -0
  91. package/skills/git-commit/SKILL.md +27 -0
package/dist/ui/app.js ADDED
@@ -0,0 +1,1061 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Interactive TUI application: alt-screen layout with a persistent sidebar,
3
+ // the transcript, the editor and the footer.
4
+ //
5
+ // Owns the slash commands, keybindings, approvals, resume/render of remote
6
+ // history and the git status polling that feeds the right panel.
7
+ // ---------------------------------------------------------------------------
8
+ import { basename, join, resolve } from "node:path";
9
+ import { CombinedAutocompleteProvider, Box, Container, HStack, isViewportTUI, matchesKey, MouseRegion, ProcessTerminal, ScrollView, SelectList, Spacer, TuiAltScreen, VStack, visibleWidth, } from "@earendil-works/pi-tui";
10
+ import { AgentRunner, usageFromSession } from "../agent/turn.js";
11
+ import { MODES, nextMode, stripModeMarker } from "../agent/modes.js";
12
+ import { createSystemPromptBuilder } from "../agent/prompt-builder.js";
13
+ import { deriveTitle } from "../agent/sessions.js";
14
+ import { TodoStore } from "../agent/todo.js";
15
+ import { ApprovalPolicy } from "../approval/policy.js";
16
+ import { collectFileDiff, collectGitStatus, EMPTY_GIT_STATUS } from "../git/status.js";
17
+ import { writeFile } from "../runtime.js";
18
+ import { bgRgb, isColorEnabled, modeBorder, overlayBackgroundRgb, panelBackgroundRgb, style, userMessageBackgroundRgb } from "./colors.js";
19
+ import { helpLines } from "./help.js";
20
+ import { buildModelItems, currentModelId } from "./model-list.js";
21
+ import { createQuitConfirm } from "./quit-confirm.js";
22
+ import { selectCardWidth, selectDescriptionMinWidth, selectLabelColumn, selectVisibleRows } from "./select-popup.js";
23
+ import { editorTheme, selectListTheme } from "./theme.js";
24
+ import { parseToolArgs } from "./tool-view.js";
25
+ import { LogoComponent } from "./logo.js";
26
+ import { ApprovalFloatComponent, AssistantMessageComponent, DiffOverlay, FooterComponent, InfoOverlay, NoticeComponent, PromptEditor, QueuedMessagesComponent, ReasoningBlockComponent, SelectOverlay, SidebarComponent, ToolCallComponent, UserMessageComponent, } from "./components.js";
27
+ const SIDEBAR_MIN_WIDTH = 34;
28
+ const SIDEBAR_RATIO = 0.25;
29
+ const SIDEBAR_MIN_TERMINAL_WIDTH = 100;
30
+ /**
31
+ * Panel width: ~25% of the terminal, floored at a readable minimum. Grows
32
+ * with wide terminals instead of being capped, so the panel keeps its share
33
+ * as the window grows (the transcript already gets the larger part).
34
+ */
35
+ export function sidebarWidth(columns) {
36
+ return Math.max(SIDEBAR_MIN_WIDTH, Math.round(columns * SIDEBAR_RATIO));
37
+ }
38
+ export async function runTui(opts) {
39
+ const terminal = new ProcessTerminal();
40
+ // The editor draws its own inverse-video cursor cell; keep the terminal's
41
+ // hardware cursor hidden so no second (often bar-shaped) cursor shows up.
42
+ const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
43
+ const todos = new TodoStore();
44
+ const transcript = new Container();
45
+ const scroll = new ScrollView(transcript, { follow: "end", primary: true, scrollbar: "auto" });
46
+ let panelBg;
47
+ let overlayBg;
48
+ let userBg;
49
+ const sidebar = new SidebarComponent(emptySidebar(), {
50
+ todos,
51
+ viewportHeight: () => sidebarScroll.viewportHeight,
52
+ panelBg: () => panelBg,
53
+ });
54
+ // Scrollable so a short terminal never clips the panel (wheel or Alt+↑/↓).
55
+ // The MouseRegion sits inside the ScrollView: the layout offsets its box by
56
+ // scrollTop, so mouse events arrive in content coordinates.
57
+ const sidebarScroll = new ScrollView(new MouseRegion(sidebar, onSidebarMouse), {
58
+ scrollbar: "auto",
59
+ overscroll: "contain",
60
+ scrollbarTrackStyle: (text) => (panelBg ? bgRgb(panelBg, style.dim(text)) : style.dim(text)),
61
+ scrollbarThumbStyle: (text) => (panelBg ? bgRgb(panelBg, text) : text),
62
+ });
63
+ // The ASK/AUTO badge is clickable — same action as Shift+Tab. Guarded so a
64
+ // click can't toggle approval underneath an open picker/approval card.
65
+ const footer = new FooterComponent(emptyFooter(opts), () => {
66
+ if (!tui.hasOverlay())
67
+ toggleApproval();
68
+ });
69
+ // Idle Ctrl+C is two-step: the first press arms the hint in the footer, the
70
+ // second (within the window) exits, and expiry drops back to idle.
71
+ const quitConfirm = createQuitConfirm((armed) => {
72
+ footer.state.pendingQuit = armed;
73
+ tui.requestRender();
74
+ });
75
+ const editor = new PromptEditor(tui, editorTheme(modeBorder(opts.initialMode)), opts.initialMode, opts.initialThinking);
76
+ // Inset the prompt from the edges; the left column stays on the terminal's
77
+ // own background so it reads as one continuous surface.
78
+ const promptBox = new Box(2, 0);
79
+ promptBox.addChild(editor);
80
+ // Steers waiting on the server float between the transcript and the prompt.
81
+ const queuedMessages = new QueuedMessagesComponent();
82
+ let sidebarVisible = true;
83
+ // -------------------------------------------------------------------------
84
+ // Git status (right panel)
85
+ // -------------------------------------------------------------------------
86
+ let gitRefreshTimer = null;
87
+ const applyGit = (status) => {
88
+ sidebar.state.git = status;
89
+ footer.state.branch = status.branch;
90
+ tui.requestRender();
91
+ };
92
+ const refreshGit = () => {
93
+ if (gitRefreshTimer)
94
+ clearTimeout(gitRefreshTimer);
95
+ gitRefreshTimer = setTimeout(() => void collectGitStatus(opts.workspace).then(applyGit), 250);
96
+ gitRefreshTimer.unref?.();
97
+ };
98
+ void collectGitStatus(opts.workspace).then(applyGit);
99
+ // -------------------------------------------------------------------------
100
+ // Sidebar interaction: click a file for its diff, click "more" to expand
101
+ // -------------------------------------------------------------------------
102
+ let diffOverlay = null;
103
+ function onSidebarMouse(event) {
104
+ if (event.type !== "press" && event.type !== "click")
105
+ return undefined;
106
+ const target = sidebar.targetAt(event.y);
107
+ if (!target)
108
+ return undefined;
109
+ // Consume the press so the release is delivered back as a click.
110
+ if (event.type === "press")
111
+ return { render: false };
112
+ if (target.type === "toggle-changes") {
113
+ sidebar.toggleChanges();
114
+ tui.requestRender();
115
+ }
116
+ else {
117
+ void openFileDiff(target.file);
118
+ }
119
+ return {};
120
+ }
121
+ async function openFileDiff(file) {
122
+ const diff = await collectFileDiff(opts.workspace, file);
123
+ diffOverlay?.hide(); // one diff popup at a time
124
+ const overlay = new DiffOverlay(`${file.status} ${file.path}`, diff.text, {
125
+ // Re-read per render: after a zoom the card must fit the new screen.
126
+ viewportHeight: () => Math.max(1, tui.terminal.rows - 2), // frame edges
127
+ truncated: diff.truncated,
128
+ close: () => handle.hide(),
129
+ });
130
+ const handle = tui.showOverlay(overlay, { width: "100%", maxHeight: "100%" });
131
+ diffOverlay = handle;
132
+ }
133
+ // -------------------------------------------------------------------------
134
+ // Session metadata + index persistence
135
+ // -------------------------------------------------------------------------
136
+ let thinkingLevels = [];
137
+ let firstUserMessage = null;
138
+ const titledThreads = new Set();
139
+ const sessionIndex = opts.sessionIndex;
140
+ todos.onChanged(() => {
141
+ if (runner.threadId)
142
+ sessionIndex.setTodos(opts.workspace, runner.threadId, todos.toJSON());
143
+ tui.requestRender();
144
+ });
145
+ // -------------------------------------------------------------------------
146
+ // Transcript view state
147
+ //
148
+ // currentAssistant/currentReasoning point at the component receiving the
149
+ // live stream; any non-content event resets them so block boundaries are
150
+ // explicit instead of inferred from Container internals.
151
+ // -------------------------------------------------------------------------
152
+ let currentAssistant = null;
153
+ let currentReasoning = null;
154
+ const reasoningBlocks = [];
155
+ /** Every rendered tool call (live + replayed history), keyed by id — result
156
+ * updates and the Ctrl+O toggle. */
157
+ const toolCalls = new Map();
158
+ // Deliberate tradeoff: these grow unboundedly for the life of the session
159
+ // (cleared only by /new or a session switch). logLines/runLog back /export
160
+ // (the full transcript is the point), and toolCalls backs Ctrl+O over every
161
+ // rendered call — capping any of them would silently truncate the export or
162
+ // the toggle's reach. A session is bounded by what the user is willing to
163
+ // scroll; if this ever becomes a real footprint, cap runLog (debug-only)
164
+ // first and keep logLines intact.
165
+ const logLines = [];
166
+ let lastError = null;
167
+ let runLog = "";
168
+ function addTranscript(component) {
169
+ transcript.addChild(component);
170
+ tui.requestRender();
171
+ }
172
+ function addSpacer() {
173
+ transcript.addChild(new Spacer(1));
174
+ }
175
+ function resetStreamTargets() {
176
+ currentAssistant = null;
177
+ currentReasoning = null;
178
+ }
179
+ /** Cumulative usage is the runner/sidebar source; the footer shows one run. */
180
+ let runBaseline = null;
181
+ function applyUsage(usage, options = {}) {
182
+ runner.usage = usage;
183
+ sidebar.state.usage = usage;
184
+ footer.state.usage = options.resetFooter ? null : runUsageDelta(usage, runBaseline);
185
+ tui.requestRender();
186
+ }
187
+ const buildSystemPrompt = createSystemPromptBuilder({
188
+ workspace: opts.workspace,
189
+ skillDirs: opts.skillDirs,
190
+ noContextFiles: opts.noContextFiles,
191
+ extraInstructions: opts.extraInstructions,
192
+ });
193
+ // -------------------------------------------------------------------------
194
+ // Approval float (above the prompt)
195
+ // -------------------------------------------------------------------------
196
+ /**
197
+ * The approval currently being decided (null when none is open). NOT a
198
+ * modal overlay: the editor keeps focus, the card floats between the
199
+ * transcript and the prompt, and ctrl+y / ctrl+n answer it from the global
200
+ * key listener. The policy enforces the fixed 10-minute deadline and the
201
+ * cancellation (server release / stop) — its AbortSignal is this card's
202
+ * dismissal hook.
203
+ */
204
+ const approvalFloat = new ApprovalFloatComponent();
205
+ let approvalResolve = null;
206
+ /** Dismiss the card. The policy's race is the authority for the outcome. */
207
+ const clearApproval = () => {
208
+ approvalFloat.set(null);
209
+ approvalResolve = null;
210
+ tui.requestRender();
211
+ };
212
+ /** Answer the pending approval (ctrl+y / ctrl+n). */
213
+ const answerApproval = (outcome) => {
214
+ const resolve = approvalResolve;
215
+ if (!resolve)
216
+ return;
217
+ clearApproval();
218
+ resolve(outcome);
219
+ };
220
+ const approval = new ApprovalPolicy(opts.initialApproval, (request, signal) => new Promise((resolve) => {
221
+ approvalFloat.set(request);
222
+ approvalResolve = resolve;
223
+ // The policy settles the outcome itself (timeout / cancelled) — this
224
+ // listener only dismisses the card. The prompt promise stays pending
225
+ // and is dropped with the race.
226
+ signal.addEventListener("abort", () => {
227
+ if (approvalResolve === resolve)
228
+ clearApproval();
229
+ }, { once: true });
230
+ tui.requestRender();
231
+ }), () => runner.mode);
232
+ // -------------------------------------------------------------------------
233
+ // Runner + callbacks
234
+ // -------------------------------------------------------------------------
235
+ /** Render a user message into the transcript and release any matching float. */
236
+ const addUserMessage = (text, queued) => {
237
+ resetStreamTargets();
238
+ addTranscript(new UserMessageComponent(text, queued, { bg: () => userBg }));
239
+ addSpacer();
240
+ log(`> ${text}`);
241
+ queuedMessages.release(text);
242
+ };
243
+ const callbacks = {
244
+ onUserMessage: addUserMessage,
245
+ onSteerQueued: (text) => {
246
+ queuedMessages.add(text);
247
+ tui.requestRender();
248
+ },
249
+ // The server took the steered message into the running turn — it is part
250
+ // of the conversation now: release the float into the transcript.
251
+ onSteerAccepted: (text) => addUserMessage(text, false),
252
+ onContent: (delta) => {
253
+ currentReasoning = null;
254
+ if (!currentAssistant) {
255
+ currentAssistant = new AssistantMessageComponent();
256
+ addTranscript(currentAssistant);
257
+ }
258
+ currentAssistant.append(delta);
259
+ logRaw(delta);
260
+ // Streamed text must not depend on the spinner's 100ms tick to appear —
261
+ // every other handler requests its own render; content is no different.
262
+ tui.requestRender();
263
+ },
264
+ onReasoning: (delta) => {
265
+ currentAssistant = null;
266
+ if (!currentReasoning) {
267
+ currentReasoning = new ReasoningBlockComponent();
268
+ reasoningBlocks.push(currentReasoning);
269
+ addTranscript(currentReasoning);
270
+ }
271
+ currentReasoning.append(delta);
272
+ tui.requestRender();
273
+ },
274
+ onUsage: (usage) => applyUsage(usage),
275
+ onServerTool: (tool) => {
276
+ resetStreamTargets();
277
+ let component = toolCalls.get(tool.id);
278
+ if (!component) {
279
+ // Same component as client tools and replayed history — live server
280
+ // calls are click/Ctrl+O expandable exactly like after a rejoin.
281
+ component = new ToolCallComponent(tool.name, parseToolArgs(tool.arguments ?? "{}"));
282
+ toolCalls.set(tool.id, component);
283
+ addTranscript(component);
284
+ }
285
+ if (tool.phase === "result") {
286
+ component.setResult(typeof tool.result === "string" ? tool.result : JSON.stringify(tool.result ?? ""), tool.isError === true);
287
+ }
288
+ tui.requestRender();
289
+ },
290
+ onToolStart: (call, summary) => {
291
+ resetStreamTargets();
292
+ const component = new ToolCallComponent(call.name, parseToolArgs(call.arguments));
293
+ toolCalls.set(call.id, component);
294
+ addTranscript(component);
295
+ log(`⚙ ${call.name}: ${summary}`);
296
+ tui.requestRender();
297
+ },
298
+ onToolEnd: (call, output, isError) => {
299
+ toolCalls.get(call.id)?.setResult(output, isError);
300
+ log(` ${isError ? "✗" : "✓"} ${output.split("\n")[0] ?? ""}`);
301
+ refreshGit();
302
+ tui.requestRender();
303
+ },
304
+ onNotice: (text, level) => {
305
+ resetStreamTargets();
306
+ addTranscript(new NoticeComponent(text, level));
307
+ if (level === "error")
308
+ lastError = text;
309
+ tui.requestRender();
310
+ },
311
+ onRunState: (state) => {
312
+ if (state === "running") {
313
+ quitConfirm.disarm();
314
+ // Snapshot the cumulative total so the footer can show this run alone.
315
+ runBaseline = runner.usage;
316
+ startSpinner();
317
+ }
318
+ else {
319
+ stopSpinner();
320
+ }
321
+ footer.state.runState = state;
322
+ tui.requestRender();
323
+ },
324
+ onThread: (threadId) => {
325
+ sessionIndex.remember(opts.workspace, threadId, firstUserMessage ? deriveTitle(firstUserMessage) : "New session");
326
+ if (firstUserMessage)
327
+ void titleThread(threadId, firstUserMessage);
328
+ tui.requestRender();
329
+ },
330
+ onSession: (detail) => {
331
+ applySessionDetail(detail);
332
+ },
333
+ };
334
+ const runner = new AgentRunner({
335
+ client: opts.client,
336
+ registry: opts.registry,
337
+ workspace: opts.workspace,
338
+ allowOutside: opts.allowOutside,
339
+ skillDirs: opts.skillDirs,
340
+ todos,
341
+ approval,
342
+ buildSystemPrompt,
343
+ callbacks,
344
+ });
345
+ runner.mode = opts.initialMode;
346
+ runner.thinkingLevel = opts.initialThinking;
347
+ runner.setModel(opts.initialModel ?? null);
348
+ if (opts.initialModel)
349
+ editor.model = opts.initialModel;
350
+ /**
351
+ * Cached GET /v1/models list — the picker refreshes on open; this cache is
352
+ * for the startup default display (and stays empty when the server is
353
+ * unreachable or has no models configured).
354
+ */
355
+ let defaultModelId = null;
356
+ void opts.client.listModels().then((models) => {
357
+ if (models.length === 0)
358
+ return;
359
+ defaultModelId = models.find((m) => m.default)?.id ?? models[0].id;
360
+ if (opts.initialModel && !models.some((m) => m.id === opts.initialModel)) {
361
+ addTranscript(new NoticeComponent(`Model "${opts.initialModel}" is not available on this integration — the server default applies instead.`, "warn"));
362
+ }
363
+ // Only fill the display before any thread/model knowledge exists.
364
+ if (!runner.threadId && !runner.selectedModel) {
365
+ runner.model = defaultModelId;
366
+ editor.model = defaultModelId;
367
+ tui.requestRender();
368
+ }
369
+ }).catch(() => { });
370
+ function log(line) {
371
+ logLines.push(line);
372
+ }
373
+ function logRaw(text) {
374
+ runLog += text;
375
+ }
376
+ function applySessionDetail(detail, options = {}) {
377
+ if (detail.model)
378
+ runner.model = detail.model;
379
+ editor.model = detail.model ?? runner.model;
380
+ if (detail.title)
381
+ sidebar.state.title = detail.title;
382
+ const usage = usageFromSession(detail.usage);
383
+ // A loaded/switched session has no run in this process — the footer starts empty.
384
+ if (usage)
385
+ applyUsage(usage, { resetFooter: options.resetFooter });
386
+ if (detail.thinking)
387
+ thinkingLevels = detail.thinking.levels;
388
+ tui.requestRender();
389
+ }
390
+ async function titleThread(threadId, message) {
391
+ if (titledThreads.has(threadId))
392
+ return;
393
+ titledThreads.add(threadId);
394
+ const title = deriveTitle(message);
395
+ sessionIndex.setTitle(opts.workspace, threadId, title);
396
+ sidebar.state.title = title;
397
+ tui.requestRender();
398
+ await opts.client.patchSessionTitle(threadId, title).catch(() => { });
399
+ }
400
+ // -------------------------------------------------------------------------
401
+ // Spinner
402
+ // -------------------------------------------------------------------------
403
+ let spinnerTimer = null;
404
+ function startSpinner() {
405
+ if (spinnerTimer)
406
+ return;
407
+ spinnerTimer = setInterval(() => {
408
+ footer.state.spinnerFrame++;
409
+ tui.requestRender();
410
+ }, 100);
411
+ spinnerTimer.unref?.();
412
+ }
413
+ function stopSpinner() {
414
+ if (spinnerTimer)
415
+ clearInterval(spinnerTimer);
416
+ spinnerTimer = null;
417
+ }
418
+ // -------------------------------------------------------------------------
419
+ // Layout
420
+ // -------------------------------------------------------------------------
421
+ let sidebarWidthNow = sidebarWidth(process.stdout.columns ?? 80);
422
+ const applyLayout = () => {
423
+ if (isViewportTUI(tui))
424
+ tui.setLayoutRoot(buildRoot());
425
+ };
426
+ // Left column owns the transcript, prompt and status bar; the right panel
427
+ // runs the full height and is unaffected by the left content.
428
+ const buildRoot = () => new HStack([
429
+ {
430
+ component: new VStack([
431
+ { component: scroll, basis: 0, grow: 1, minSize: 3 },
432
+ // Pending steers float between the transcript and the prompt; renders
433
+ // zero lines when empty.
434
+ { component: queuedMessages, basis: "auto", shrink: 0 },
435
+ // A pending approval floats directly above the prompt (renders zero
436
+ // lines when none is open).
437
+ { component: approvalFloat, basis: "auto", shrink: 0 },
438
+ { component: promptBox, basis: "auto", shrink: 1, minSize: 3 },
439
+ { component: footer, basis: "auto", shrink: 0 },
440
+ ]),
441
+ basis: 0,
442
+ grow: 1,
443
+ minSize: 1,
444
+ },
445
+ {
446
+ component: sidebarScroll,
447
+ basis: sidebarWidthNow,
448
+ maxSize: sidebarWidthNow,
449
+ shrink: 0,
450
+ visible: (vp) => sidebarVisible && vp.width >= SIDEBAR_MIN_TERMINAL_WIDTH,
451
+ },
452
+ ]);
453
+ applyLayout();
454
+ /** One page of sidebar scroll: the viewport minus two rows of overlap. */
455
+ const pageHeight = () => Math.max(1, sidebarScroll.viewportHeight - 2);
456
+ // The panel is ~25% of the terminal, so a resize rebuilds the layout root.
457
+ const onResize = () => {
458
+ const next = sidebarWidth(process.stdout.columns ?? 80);
459
+ if (next === sidebarWidthNow)
460
+ return;
461
+ sidebarWidthNow = next;
462
+ applyLayout();
463
+ tui.requestRender();
464
+ };
465
+ process.stdout.on("resize", onResize);
466
+ // -------------------------------------------------------------------------
467
+ // Resume
468
+ // -------------------------------------------------------------------------
469
+ const commands = [
470
+ { name: "help", description: "keybindings and commands" },
471
+ { name: "new", description: "start a new session" },
472
+ { name: "session", description: "list sessions and switch" },
473
+ { name: "plan", description: "switch to plan mode (read-only)" },
474
+ { name: "coding", description: "switch to coding mode (software work)" },
475
+ { name: "cowork", description: "switch to cowork mode (general tasks)" },
476
+ { name: "mode", description: "cycle plan/coding/cowork" },
477
+ { name: "edit", description: "toggle auto/ask approval" },
478
+ { name: "thinking", description: "set the thinking level" },
479
+ { name: "model", description: "choose the model (new sessions)" },
480
+ { name: "compact", description: "compact the session history" },
481
+ { name: "stop", description: "force-stop the running turn" },
482
+ { name: "reload", description: "reload context files and skills" },
483
+ { name: "export", description: "export the transcript" },
484
+ { name: "config", description: "show connection info" },
485
+ { name: "quit", description: "exit" },
486
+ ];
487
+ editor.setAutocompleteProvider(new CombinedAutocompleteProvider(commands, opts.workspace));
488
+ editor.onSubmit = (text) => {
489
+ editor.setText("");
490
+ void handleSubmit(text);
491
+ };
492
+ editor.mode = runner.mode;
493
+ editor.borderColor = modeBorder(runner.mode);
494
+ // The bottom-edge chips (mode / model / thinking) open their pickers — the
495
+ // mouse counterpart of Tab, /model and /thinking. Guarded so a click can't
496
+ // stack a second card while one is open.
497
+ editor.onChipClick = (chip) => {
498
+ if (tui.hasOverlay())
499
+ return;
500
+ if (chip === "mode")
501
+ showModePicker();
502
+ else if (chip === "model")
503
+ void showModelPicker();
504
+ else
505
+ showThinkingPicker();
506
+ };
507
+ /** The launch logo, kept so quit() can stop its shimmer timer. */
508
+ let launchLogo = null;
509
+ if (!opts.noLogo) {
510
+ launchLogo = new LogoComponent({ animate: true });
511
+ addTranscript(launchLogo);
512
+ addSpacer();
513
+ launchLogo.start(tui);
514
+ }
515
+ if (opts.resumeThreadId) {
516
+ runner.threadId = opts.resumeThreadId;
517
+ runner.resetModeAnnouncement();
518
+ const restoredTodos = sessionIndex.get(opts.workspace, opts.resumeThreadId)?.todos;
519
+ if (restoredTodos)
520
+ todos.replace(restoredTodos);
521
+ if (!(await loadSessionView(opts.resumeThreadId))) {
522
+ addTranscript(new NoticeComponent(`Could not load session ${opts.resumeThreadId}; starting fresh.`, "warn"));
523
+ runner.threadId = null;
524
+ }
525
+ }
526
+ function renderHistory(history) {
527
+ for (const message of history) {
528
+ if (message.role === "user") {
529
+ // The mode marker is machine-facing — don't show it in the transcript.
530
+ addTranscript(new UserMessageComponent(stripModeMarker(message.text), false, { bg: () => userBg }));
531
+ addSpacer();
532
+ continue;
533
+ }
534
+ if (message.text.trim()) {
535
+ const component = new AssistantMessageComponent();
536
+ component.append(message.text);
537
+ addTranscript(component);
538
+ addSpacer();
539
+ }
540
+ for (const toolCall of message.toolCalls ?? []) {
541
+ const component = new ToolCallComponent(toolCall.name, parseToolArgs(toolCall.arguments));
542
+ const output = typeof toolCall.result === "string" ? toolCall.result : JSON.stringify(toolCall.result ?? "");
543
+ component.setResult(output, toolCall.isError);
544
+ // Replayed calls must be toggleable too — Ctrl+O is the only keyboard path.
545
+ toolCalls.set(toolCall.id, component);
546
+ addTranscript(component);
547
+ }
548
+ }
549
+ }
550
+ /** Clear all view state; the remote thread is untouched. */
551
+ function clearView() {
552
+ resetStreamTargets();
553
+ toolCalls.clear();
554
+ reasoningBlocks.length = 0;
555
+ transcript.clear();
556
+ todos.replace([]);
557
+ runBaseline = null;
558
+ applyUsage(null);
559
+ sidebar.state.title = null;
560
+ }
561
+ /** Fetch and render a remote thread. Returns false when it is unreachable. */
562
+ async function loadSessionView(threadId) {
563
+ const detail = await opts.client.getSession(threadId, { history: true, historyLimit: 50 }).catch(() => null);
564
+ if (!detail)
565
+ return false;
566
+ renderHistory(detail.history ?? []);
567
+ applySessionDetail(detail, { resetFooter: true });
568
+ sessionIndex.remember(opts.workspace, threadId, detail.title || threadId);
569
+ return true;
570
+ }
571
+ // -------------------------------------------------------------------------
572
+ // Commands
573
+ // -------------------------------------------------------------------------
574
+ async function handleSubmit(text) {
575
+ const trimmed = text.trim();
576
+ if (!trimmed)
577
+ return;
578
+ if (trimmed.startsWith("/")) {
579
+ await handleCommand(trimmed);
580
+ return;
581
+ }
582
+ if (!firstUserMessage)
583
+ firstUserMessage = trimmed;
584
+ await runner.send(trimmed);
585
+ }
586
+ /** Toggle AUTO/ASK (Shift+Tab or /edit); AUTO still asks for outside paths. */
587
+ function toggleApproval() {
588
+ footer.state.approval = approval.toggle();
589
+ tui.requestRender();
590
+ }
591
+ async function handleCommand(input) {
592
+ const [rawName, ...rest] = input.slice(1).split(/\s+/);
593
+ const name = (rawName ?? "").toLowerCase();
594
+ const argument = rest.join(" ").trim().toLowerCase();
595
+ switch (name) {
596
+ case "help":
597
+ showInfo("Help", helpLines(), { maxHeight: "90%" });
598
+ break;
599
+ case "new":
600
+ await newSession();
601
+ break;
602
+ case "session":
603
+ case "sessions":
604
+ case "resume":
605
+ await showSessions(argument === "all" ? "all" : "cwd");
606
+ break;
607
+ case "plan":
608
+ case "coding":
609
+ case "cowork":
610
+ setMode(name);
611
+ break;
612
+ case "mode":
613
+ setMode(nextMode(runner.mode));
614
+ break;
615
+ case "edit":
616
+ toggleApproval();
617
+ break;
618
+ case "thinking":
619
+ showThinkingPicker();
620
+ break;
621
+ case "model":
622
+ await showModelPicker();
623
+ break;
624
+ case "compact":
625
+ if (!runner.threadId)
626
+ addTranscript(new NoticeComponent("No active session.", "warn"));
627
+ else {
628
+ addTranscript(new NoticeComponent("Compacting session…", "info"));
629
+ try {
630
+ await runner.compact();
631
+ addTranscript(new NoticeComponent("Compaction complete.", "info"));
632
+ }
633
+ catch (err) {
634
+ addTranscript(new NoticeComponent(err instanceof Error ? err.message : String(err), "error"));
635
+ }
636
+ }
637
+ break;
638
+ case "stop":
639
+ await runner.stop();
640
+ break;
641
+ case "reload":
642
+ addTranscript(new NoticeComponent("Context files and skills are re-read on every turn.", "info"));
643
+ break;
644
+ case "export":
645
+ await exportTranscript();
646
+ break;
647
+ case "config":
648
+ showInfo("Connection", [
649
+ `server: ${opts.config.baseUrl}`,
650
+ `integration: ${opts.config.integrationId}`,
651
+ `api key: ${opts.config.apiKey ? `${opts.config.apiKey.slice(0, 8)}…` : "(none)"}`,
652
+ `workspace: ${opts.workspace}`,
653
+ `approval: ${approval.current}`,
654
+ ]);
655
+ break;
656
+ case "quit":
657
+ case "exit":
658
+ quit();
659
+ break;
660
+ default:
661
+ addTranscript(new NoticeComponent(`Unknown command: /${name} (try /help)`, "warn"));
662
+ }
663
+ }
664
+ function setMode(mode) {
665
+ runner.setMode(mode);
666
+ editor.mode = mode;
667
+ editor.borderColor = modeBorder(mode);
668
+ tui.requestRender();
669
+ }
670
+ async function newSession() {
671
+ await runner.stop().catch(() => { });
672
+ runner.threadId = null;
673
+ runner.resetModeAnnouncement();
674
+ runner.setThinkingLevel(opts.initialThinking);
675
+ // Back to the pending client-choice selection (or the server default).
676
+ runner.model = runner.selectedModel ?? defaultModelId;
677
+ editor.model = runner.model;
678
+ firstUserMessage = null;
679
+ clearView();
680
+ addTranscript(new NoticeComponent("New session.", "info"));
681
+ if (!opts.noLogo) {
682
+ const logo = new LogoComponent({ animate: false });
683
+ addTranscript(logo);
684
+ addSpacer();
685
+ }
686
+ tui.requestRender();
687
+ }
688
+ /**
689
+ * Session picker. `cwd` (default) shows only sessions this machine recorded
690
+ * for the current workspace; `all` shows every session on the integration
691
+ * with its source folder annotated. The remote API has no cwd concept — the
692
+ * local session index is the only folder association, so this filtering is
693
+ * client-side by design.
694
+ */
695
+ async function showSessions(scope = "cwd") {
696
+ let sessions = [];
697
+ try {
698
+ sessions = await opts.client.listSessions();
699
+ }
700
+ catch (err) {
701
+ addTranscript(new NoticeComponent(`Failed to list sessions: ${err instanceof Error ? err.message : String(err)}`, "error"));
702
+ return;
703
+ }
704
+ if (sessions.length === 0) {
705
+ addTranscript(new NoticeComponent("No remote sessions yet.", "info"));
706
+ return;
707
+ }
708
+ const current = runner.threadId;
709
+ const known = sessionIndex.knownThreadIds(opts.workspace);
710
+ const visible = scope === "cwd"
711
+ ? sessions.filter((s) => known.has(s.thread_id) || s.thread_id === current)
712
+ : sessions;
713
+ if (visible.length === 0) {
714
+ addTranscript(new NoticeComponent(`No sessions for this folder yet. ${sessions.length} session(s) belong to other folders — use /session all.`, "info"));
715
+ return;
716
+ }
717
+ const hiddenCount = sessions.length - visible.length;
718
+ const shown = visible.slice(0, 50);
719
+ const items = shown.map((s) => {
720
+ const folder = scope === "all" ? sessionIndex.findCwd(s.thread_id) : undefined;
721
+ const folderTag = folder && resolve(folder) !== resolve(opts.workspace)
722
+ ? `[${basename(folder)}] `
723
+ : "";
724
+ return {
725
+ value: s.thread_id,
726
+ label: `${s.thread_id === current ? "● " : " "}${folderTag}${s.title || s.thread_id}`,
727
+ description: `${s.status} · ${s.message_count} msgs · ${new Date(s.updated_at).toLocaleString()}${s.thread_id === current ? " · current" : ""}`,
728
+ };
729
+ });
730
+ if (scope === "cwd" && hiddenCount > 0) {
731
+ items.push({
732
+ value: "__all__",
733
+ label: ` Show all folders…`,
734
+ description: `${hiddenCount} more session(s) from other folders`,
735
+ });
736
+ }
737
+ const currentIndex = scope === "cwd" ? shown.findIndex((s) => s.thread_id === current) : -1;
738
+ showSelectOverlay(scope === "cwd" ? "Switch session (this folder)" : "Switch session (all folders)", items, (value) => {
739
+ if (value === "__all__") {
740
+ void showSessions("all");
741
+ return;
742
+ }
743
+ if (value === runner.threadId) {
744
+ addTranscript(new NoticeComponent("Already on this session.", "info"));
745
+ tui.requestRender();
746
+ return;
747
+ }
748
+ const folder = sessionIndex.findCwd(value);
749
+ if (folder && resolve(folder) !== resolve(opts.workspace)) {
750
+ addTranscript(new NoticeComponent(`Note: this session was created in ${folder}; tools will still run in the current workspace (${opts.workspace}).`, "warn"));
751
+ }
752
+ void resumeSession(value);
753
+ }, { selectedIndex: currentIndex });
754
+ }
755
+ async function resumeSession(threadId) {
756
+ await runner.stop().catch(() => { });
757
+ clearView();
758
+ runner.threadId = threadId;
759
+ runner.resetModeAnnouncement();
760
+ const restored = sessionIndex.get(opts.workspace, threadId)?.todos;
761
+ if (restored)
762
+ todos.replace(restored);
763
+ if (!(await loadSessionView(threadId))) {
764
+ addTranscript(new NoticeComponent(`Failed to resume ${threadId}.`, "error"));
765
+ }
766
+ tui.requestRender();
767
+ }
768
+ /** Mode picker — the bottom-edge chip lands here (Tab still cycles). */
769
+ function showModePicker() {
770
+ const modes = Object.keys(MODES);
771
+ showSelectOverlay("Mode", modes.map((mode) => ({
772
+ value: mode,
773
+ label: MODES[mode].label,
774
+ description: `${MODES[mode].description}${mode === runner.mode ? " · current" : ""}`,
775
+ })), (value) => setMode(value), { selectedIndex: modes.indexOf(runner.mode) });
776
+ }
777
+ function showThinkingPicker() {
778
+ const levels = [...new Set(["default", "off", ...thinkingLevels.filter((l) => l !== "off")])];
779
+ showSelectOverlay("Thinking level (this request)", levels.map((level) => ({
780
+ value: level,
781
+ label: level === "default" ? "Default (provider decides)" : level,
782
+ description: level === (runner.thinkingLevel ?? "default") ? "current" : "",
783
+ })), (value) => {
784
+ runner.setThinkingLevel(value === "default" ? undefined : value);
785
+ editor.thinking = value === "default" ? undefined : value;
786
+ addTranscript(new NoticeComponent(`Thinking level: ${value}`, "info"));
787
+ tui.requestRender();
788
+ }, { maxVisible: 10 });
789
+ }
790
+ /**
791
+ * Client-choice model picker. The list always comes fresh from
792
+ * GET /v1/models (the integration's policy may have changed since startup);
793
+ * a forced-model integration returns a single entry.
794
+ *
795
+ * The picked model applies to the next new session: the server pins a
796
+ * thread's model on its first run, so a running thread cannot switch under
797
+ * itself.
798
+ */
799
+ async function showModelPicker() {
800
+ let models = [];
801
+ try {
802
+ models = await opts.client.listModels();
803
+ }
804
+ catch (err) {
805
+ addTranscript(new NoticeComponent(`Failed to list models: ${err instanceof Error ? err.message : String(err)}`, "error"));
806
+ tui.requestRender();
807
+ return;
808
+ }
809
+ if (models.length === 0) {
810
+ addTranscript(new NoticeComponent("No models are configured on this integration.", "warn"));
811
+ tui.requestRender();
812
+ return;
813
+ }
814
+ const current = currentModelId(models, runner.model, runner.selectedModel);
815
+ showSelectOverlay("Model (applies to a new session)", buildModelItems(models, current), (value) => {
816
+ runner.setModel(value);
817
+ if (runner.threadId) {
818
+ addTranscript(new NoticeComponent(`Model for new sessions: ${value}. This thread keeps ${runner.model ?? "its pinned model"} — run /new to switch.`, "info"));
819
+ }
820
+ else {
821
+ runner.model = value;
822
+ editor.model = value;
823
+ addTranscript(new NoticeComponent(`Model: ${value}`, "info"));
824
+ }
825
+ tui.requestRender();
826
+ }, {
827
+ selectedIndex: models.findIndex((m) => m.id === current),
828
+ });
829
+ }
830
+ function showInfo(title, body, options = {}) {
831
+ const contentWidth = body.reduce((widest, line) => Math.max(widest, visibleWidth(line)), visibleWidth(title));
832
+ const overlay = new InfoOverlay(title, body, () => handle.hide(), { bg: () => overlayBg });
833
+ const handle = tui.showOverlay(overlay, {
834
+ width: Math.min(88, tui.terminal.columns - 4, Math.max(40, contentWidth + 6)),
835
+ maxHeight: options.maxHeight ?? "70%",
836
+ });
837
+ }
838
+ /** Show a titled select overlay and report the chosen value. */
839
+ function showSelectOverlay(title, items, onSelect, options = {}) {
840
+ const rows = selectVisibleRows(tui.terminal.rows, options.maxVisible ?? 12);
841
+ // Tight label column so the card tracks the content instead of a fixed 32.
842
+ const column = selectLabelColumn(items);
843
+ const list = new SelectList(items, rows, selectListTheme, {
844
+ minPrimaryColumnWidth: column,
845
+ maxPrimaryColumnWidth: column,
846
+ });
847
+ if (options.selectedIndex !== undefined && options.selectedIndex >= 0) {
848
+ list.setSelectedIndex(options.selectedIndex);
849
+ }
850
+ const hasDescriptions = items.some((item) => Boolean(item.description));
851
+ const width = selectCardWidth(title, list, {
852
+ termWidth: tui.terminal.columns,
853
+ minWidth: hasDescriptions ? selectDescriptionMinWidth(column) : undefined,
854
+ });
855
+ const overlay = new SelectOverlay(title, list, { bg: () => overlayBg });
856
+ const handle = tui.showOverlay(overlay, { width, maxHeight: rows + 5 });
857
+ list.onSelect = (item) => {
858
+ handle.hide();
859
+ onSelect(item.value);
860
+ };
861
+ list.onCancel = () => handle.hide();
862
+ }
863
+ async function exportTranscript() {
864
+ const path = join(opts.workspace, `myagent-session-${runner.threadId?.slice(0, 8) ?? "new"}.md`);
865
+ const body = [
866
+ `# MyAgent session ${runner.threadId ?? "(new)"}`,
867
+ "",
868
+ `- workspace: ${opts.workspace}`,
869
+ `- exported: ${new Date().toISOString()}`,
870
+ "",
871
+ "```",
872
+ logLines.join("\n"),
873
+ "```",
874
+ "",
875
+ runLog,
876
+ ].join("\n");
877
+ try {
878
+ await writeFile(path, body);
879
+ addTranscript(new NoticeComponent(`Exported to ${path}`, "info"));
880
+ }
881
+ catch (err) {
882
+ addTranscript(new NoticeComponent(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error"));
883
+ }
884
+ }
885
+ // -------------------------------------------------------------------------
886
+ // Keybindings
887
+ // -------------------------------------------------------------------------
888
+ tui.addInputListener((data) => {
889
+ if (tui.hasOverlay())
890
+ return undefined;
891
+ // A pending approval owns ctrl+y / ctrl+n before anything else (the
892
+ // editor keeps focus, so plain keys keep typing into the prompt).
893
+ if (approvalResolve) {
894
+ if (matchesKey(data, "ctrl+y")) {
895
+ answerApproval("approved");
896
+ return { consume: true };
897
+ }
898
+ if (matchesKey(data, "ctrl+n")) {
899
+ answerApproval("denied");
900
+ return { consume: true };
901
+ }
902
+ }
903
+ // Repaint + swallow the key: every handled binding owns its own render.
904
+ const handled = () => { tui.requestRender(); return { consume: true }; };
905
+ if (matchesKey(data, "tab")) {
906
+ setMode(nextMode(runner.mode));
907
+ return handled();
908
+ }
909
+ if (matchesKey(data, "shift+tab")) {
910
+ toggleApproval();
911
+ return handled();
912
+ }
913
+ // Ctrl+P ("panel"), not Ctrl+B: Ctrl+B is tmux's default prefix — tmux
914
+ // users press it constantly, and send-prefix (Ctrl+B Ctrl+B) would toggle
915
+ // the sidebar on every literal prefix. Ctrl+P is bound nowhere else (not
916
+ // tmux, not pi-tui's editor — its history bindings are disabled).
917
+ if (matchesKey(data, "ctrl+p")) {
918
+ sidebarVisible = !sidebarVisible;
919
+ return handled();
920
+ }
921
+ if (matchesKey(data, "ctrl+e")) {
922
+ sidebar.toggleChanges();
923
+ return handled();
924
+ }
925
+ if (matchesKey(data, "alt+up")) {
926
+ sidebarScroll.scrollBy(-1);
927
+ return handled();
928
+ }
929
+ if (matchesKey(data, "alt+down")) {
930
+ sidebarScroll.scrollBy(1);
931
+ return handled();
932
+ }
933
+ if (matchesKey(data, "alt+pageUp")) {
934
+ sidebarScroll.scrollBy(-pageHeight());
935
+ return handled();
936
+ }
937
+ if (matchesKey(data, "alt+pageDown")) {
938
+ sidebarScroll.scrollBy(pageHeight());
939
+ return handled();
940
+ }
941
+ if (matchesKey(data, "ctrl+o")) {
942
+ for (const component of toolCalls.values())
943
+ component.toggle();
944
+ return handled();
945
+ }
946
+ if (matchesKey(data, "ctrl+t")) {
947
+ for (const component of reasoningBlocks)
948
+ component.toggle();
949
+ return handled();
950
+ }
951
+ if (matchesKey(data, "escape") && runner.busy) {
952
+ void runner.stop();
953
+ return { consume: true };
954
+ }
955
+ if (matchesKey(data, "ctrl+c")) {
956
+ if (runner.busy)
957
+ void runner.stop();
958
+ else if (quitConfirm.armed) {
959
+ quitConfirm.disarm();
960
+ quit();
961
+ }
962
+ else {
963
+ quitConfirm.arm();
964
+ }
965
+ return { consume: true };
966
+ }
967
+ return undefined;
968
+ });
969
+ // -------------------------------------------------------------------------
970
+ // Lifecycle
971
+ // -------------------------------------------------------------------------
972
+ let finished = false;
973
+ let resolveQuit;
974
+ const done = new Promise((resolve) => { resolveQuit = resolve; });
975
+ const quit = () => {
976
+ if (finished)
977
+ return;
978
+ finished = true;
979
+ quitConfirm.disarm();
980
+ stopSpinner();
981
+ // The shimmer stops itself only after its last frame — quitting mid-
982
+ // animation would otherwise keep requesting renders after tui.stop()
983
+ // handed the terminal back.
984
+ launchLogo?.stop();
985
+ process.stdout.off("resize", onResize);
986
+ if (runner.busy)
987
+ void runner.stop().catch(() => { });
988
+ setTimeout(() => {
989
+ // Exit the alternate screen WITHOUT reprinting the final document into
990
+ // the normal buffer — quitting must leave the terminal as it was.
991
+ tui.stop({ preserveScreen: true });
992
+ resolveQuit(lastError ? 1 : 0);
993
+ }, 30);
994
+ };
995
+ tui.start();
996
+ tui.setFocus(editor);
997
+ tui.requestRender();
998
+ refreshGit();
999
+ if (opts.initialPrompt)
1000
+ void handleSubmit(opts.initialPrompt);
1001
+ if (isColorEnabled()) {
1002
+ void tui.queryTerminalBackgroundColor({ timeoutMs: 250 }).then((terminalBg) => {
1003
+ panelBg = panelBackgroundRgb(terminalBg);
1004
+ overlayBg = overlayBackgroundRgb(terminalBg);
1005
+ userBg = userMessageBackgroundRgb(terminalBg);
1006
+ tui.requestRender();
1007
+ });
1008
+ }
1009
+ return done;
1010
+ }
1011
+ // ---------------------------------------------------------------------------
1012
+ // Small helpers
1013
+ // ---------------------------------------------------------------------------
1014
+ /**
1015
+ * Footer usage: `current` minus the pre-run `baseline`, so the status bar
1016
+ * reports the current/last run instead of the session total.
1017
+ */
1018
+ export function runUsageDelta(current, baseline) {
1019
+ if (!current)
1020
+ return null;
1021
+ if (!baseline)
1022
+ return current;
1023
+ const delta = (value, base) => Math.max(0, value - base);
1024
+ const cost = current.cost && baseline.cost
1025
+ ? {
1026
+ input: delta(current.cost.input, baseline.cost.input),
1027
+ output: delta(current.cost.output, baseline.cost.output),
1028
+ cacheRead: delta(current.cost.cacheRead ?? 0, baseline.cost.cacheRead ?? 0),
1029
+ cacheWrite: delta(current.cost.cacheWrite ?? 0, baseline.cost.cacheWrite ?? 0),
1030
+ total: delta(current.cost.total, baseline.cost.total),
1031
+ }
1032
+ : current.cost;
1033
+ return {
1034
+ input: delta(current.input, baseline.input),
1035
+ output: delta(current.output, baseline.output),
1036
+ cacheRead: delta(current.cacheRead, baseline.cacheRead),
1037
+ cacheWrite: delta(current.cacheWrite, baseline.cacheWrite),
1038
+ totalTokens: delta(current.totalTokens, baseline.totalTokens),
1039
+ turns: delta(current.turns, baseline.turns),
1040
+ contextTokens: current.contextTokens,
1041
+ contextWindow: current.contextWindow,
1042
+ ...(cost ? { cost } : {}),
1043
+ };
1044
+ }
1045
+ function emptySidebar() {
1046
+ return {
1047
+ title: null,
1048
+ usage: null,
1049
+ git: { ...EMPTY_GIT_STATUS, files: [] },
1050
+ };
1051
+ }
1052
+ function emptyFooter(opts) {
1053
+ return {
1054
+ approval: opts.initialApproval,
1055
+ workspace: opts.workspace,
1056
+ branch: null,
1057
+ usage: null,
1058
+ runState: "idle",
1059
+ spinnerFrame: 0,
1060
+ };
1061
+ }