@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.
- package/README.md +357 -0
- package/dist/agent/context.d.ts +33 -0
- package/dist/agent/context.js +169 -0
- package/dist/agent/modes.d.ts +21 -0
- package/dist/agent/modes.js +84 -0
- package/dist/agent/prompt-builder.d.ts +9 -0
- package/dist/agent/prompt-builder.js +31 -0
- package/dist/agent/sessions.d.ts +38 -0
- package/dist/agent/sessions.js +130 -0
- package/dist/agent/todo.d.ts +18 -0
- package/dist/agent/todo.js +61 -0
- package/dist/agent/turn.d.ts +309 -0
- package/dist/agent/turn.js +1253 -0
- package/dist/approval/policy.d.ts +81 -0
- package/dist/approval/policy.js +157 -0
- package/dist/config.d.ts +49 -0
- package/dist/config.js +156 -0
- package/dist/git/status.d.ts +89 -0
- package/dist/git/status.js +226 -0
- package/dist/headless.d.ts +72 -0
- package/dist/headless.js +330 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +511 -0
- package/dist/protocol/client.d.ts +123 -0
- package/dist/protocol/client.js +250 -0
- package/dist/protocol/sse-frames.d.ts +6 -0
- package/dist/protocol/sse-frames.js +75 -0
- package/dist/protocol/types.d.ts +200 -0
- package/dist/protocol/types.js +8 -0
- package/dist/runtime.d.ts +38 -0
- package/dist/runtime.js +166 -0
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +21 -0
- package/dist/skills/discovery.d.ts +24 -0
- package/dist/skills/discovery.js +109 -0
- package/dist/tools/binary.d.ts +2 -0
- package/dist/tools/binary.js +22 -0
- package/dist/tools/diff.d.ts +1 -0
- package/dist/tools/diff.js +49 -0
- package/dist/tools/find.d.ts +2 -0
- package/dist/tools/find.js +61 -0
- package/dist/tools/fs.d.ts +2 -0
- package/dist/tools/fs.js +276 -0
- package/dist/tools/glob.d.ts +6 -0
- package/dist/tools/glob.js +131 -0
- package/dist/tools/grep.d.ts +3 -0
- package/dist/tools/grep.js +228 -0
- package/dist/tools/paths.d.ts +27 -0
- package/dist/tools/paths.js +124 -0
- package/dist/tools/registry.d.ts +13 -0
- package/dist/tools/registry.js +38 -0
- package/dist/tools/shell.d.ts +2 -0
- package/dist/tools/shell.js +136 -0
- package/dist/tools/skills.d.ts +2 -0
- package/dist/tools/skills.js +36 -0
- package/dist/tools/todo.d.ts +2 -0
- package/dist/tools/todo.js +43 -0
- package/dist/tools/transfer.d.ts +2 -0
- package/dist/tools/transfer.js +145 -0
- package/dist/tools/truncate.d.ts +12 -0
- package/dist/tools/truncate.js +46 -0
- package/dist/tools/types.d.ts +85 -0
- package/dist/tools/types.js +63 -0
- package/dist/ui/app.d.ts +39 -0
- package/dist/ui/app.js +1061 -0
- package/dist/ui/colors.d.ts +100 -0
- package/dist/ui/colors.js +169 -0
- package/dist/ui/components.d.ts +267 -0
- package/dist/ui/components.js +811 -0
- package/dist/ui/diff.d.ts +37 -0
- package/dist/ui/diff.js +143 -0
- package/dist/ui/format.d.ts +28 -0
- package/dist/ui/format.js +76 -0
- package/dist/ui/help.d.ts +6 -0
- package/dist/ui/help.js +45 -0
- package/dist/ui/highlight.d.ts +20 -0
- package/dist/ui/highlight.js +210 -0
- package/dist/ui/logo.d.ts +24 -0
- package/dist/ui/logo.js +106 -0
- package/dist/ui/model-list.d.ts +10 -0
- package/dist/ui/model-list.js +33 -0
- package/dist/ui/quit-confirm.d.ts +8 -0
- package/dist/ui/quit-confirm.js +40 -0
- package/dist/ui/select-popup.d.ts +30 -0
- package/dist/ui/select-popup.js +54 -0
- package/dist/ui/theme.d.ts +4 -0
- package/dist/ui/theme.js +41 -0
- package/dist/ui/tool-view.d.ts +20 -0
- package/dist/ui/tool-view.js +326 -0
- package/package.json +44 -0
- 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
|
+
}
|