edge-pi-cli 0.1.1
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/dist/auth/anthropic-oauth.d.ts +10 -0
- package/dist/auth/anthropic-oauth.d.ts.map +1 -0
- package/dist/auth/anthropic-oauth.js +97 -0
- package/dist/auth/anthropic-oauth.js.map +1 -0
- package/dist/auth/auth-storage.d.ts +46 -0
- package/dist/auth/auth-storage.d.ts.map +1 -0
- package/dist/auth/auth-storage.js +213 -0
- package/dist/auth/auth-storage.js.map +1 -0
- package/dist/auth/github-copilot-oauth.d.ts +8 -0
- package/dist/auth/github-copilot-oauth.d.ts.map +1 -0
- package/dist/auth/github-copilot-oauth.js +131 -0
- package/dist/auth/github-copilot-oauth.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +5 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/openai-codex-oauth.d.ts +8 -0
- package/dist/auth/openai-codex-oauth.d.ts.map +1 -0
- package/dist/auth/openai-codex-oauth.js +131 -0
- package/dist/auth/openai-codex-oauth.js.map +1 -0
- package/dist/auth/types.d.ts +41 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +5 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +191 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -0
- package/dist/context.d.ts +16 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +38 -0
- package/dist/context.js.map +1 -0
- package/dist/main.d.ts +8 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +313 -0
- package/dist/main.js.map +1 -0
- package/dist/model-factory.d.ts +45 -0
- package/dist/model-factory.d.ts.map +1 -0
- package/dist/model-factory.js +175 -0
- package/dist/model-factory.js.map +1 -0
- package/dist/modes/interactive/bash-helpers.d.ts +31 -0
- package/dist/modes/interactive/bash-helpers.d.ts.map +1 -0
- package/dist/modes/interactive/bash-helpers.js +68 -0
- package/dist/modes/interactive/bash-helpers.js.map +1 -0
- package/dist/modes/interactive/components/assistant-message.d.ts +19 -0
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/assistant-message.js +54 -0
- package/dist/modes/interactive/components/assistant-message.js.map +1 -0
- package/dist/modes/interactive/components/bash-execution.d.ts +18 -0
- package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -0
- package/dist/modes/interactive/components/bash-execution.js +77 -0
- package/dist/modes/interactive/components/bash-execution.js.map +1 -0
- package/dist/modes/interactive/components/compaction-summary.d.ts +18 -0
- package/dist/modes/interactive/components/compaction-summary.d.ts.map +1 -0
- package/dist/modes/interactive/components/compaction-summary.js +45 -0
- package/dist/modes/interactive/components/compaction-summary.js.map +1 -0
- package/dist/modes/interactive/components/footer.d.ts +20 -0
- package/dist/modes/interactive/components/footer.d.ts.map +1 -0
- package/dist/modes/interactive/components/footer.js +82 -0
- package/dist/modes/interactive/components/footer.js.map +1 -0
- package/dist/modes/interactive/components/tool-execution.d.ts +30 -0
- package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -0
- package/dist/modes/interactive/components/tool-execution.js +133 -0
- package/dist/modes/interactive/components/tool-execution.js.map +1 -0
- package/dist/modes/interactive/components/user-message.d.ts +9 -0
- package/dist/modes/interactive/components/user-message.d.ts.map +1 -0
- package/dist/modes/interactive/components/user-message.js +17 -0
- package/dist/modes/interactive/components/user-message.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts +49 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -0
- package/dist/modes/interactive/interactive-mode.js +1397 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -0
- package/dist/modes/interactive/theme.d.ts +26 -0
- package/dist/modes/interactive/theme.d.ts.map +1 -0
- package/dist/modes/interactive/theme.js +64 -0
- package/dist/modes/interactive/theme.js.map +1 -0
- package/dist/modes/interactive-mode.d.ts +5 -0
- package/dist/modes/interactive-mode.d.ts.map +1 -0
- package/dist/modes/interactive-mode.js +5 -0
- package/dist/modes/interactive-mode.js.map +1 -0
- package/dist/modes/print-mode.d.ts +20 -0
- package/dist/modes/print-mode.d.ts.map +1 -0
- package/dist/modes/print-mode.js +56 -0
- package/dist/modes/print-mode.js.map +1 -0
- package/dist/prompts.d.ts +53 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +132 -0
- package/dist/prompts.js.map +1 -0
- package/dist/settings.d.ts +34 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +73 -0
- package/dist/settings.js.map +1 -0
- package/dist/skills.d.ts +51 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +304 -0
- package/dist/skills.js.map +1 -0
- package/dist/utils/bash-executor.d.ts +32 -0
- package/dist/utils/bash-executor.d.ts.map +1 -0
- package/dist/utils/bash-executor.js +166 -0
- package/dist/utils/bash-executor.js.map +1 -0
- package/dist/utils/clipboard-image.d.ts +24 -0
- package/dist/utils/clipboard-image.d.ts.map +1 -0
- package/dist/utils/clipboard-image.js +211 -0
- package/dist/utils/clipboard-image.js.map +1 -0
- package/dist/utils/find-fd.d.ts +12 -0
- package/dist/utils/find-fd.d.ts.map +1 -0
- package/dist/utils/find-fd.js +33 -0
- package/dist/utils/find-fd.js.map +1 -0
- package/dist/utils/frontmatter.d.ts +7 -0
- package/dist/utils/frontmatter.d.ts.map +1 -0
- package/dist/utils/frontmatter.js +25 -0
- package/dist/utils/frontmatter.js.map +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive mode using @mariozechner/pi-tui.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old readline-based REPL with a proper TUI that matches
|
|
5
|
+
* the UX patterns from @mariozechner/pi-coding-agent:
|
|
6
|
+
* - Editor component for input with submit/escape handling
|
|
7
|
+
* - Markdown rendering for assistant responses
|
|
8
|
+
* - Tool execution components with collapsible output
|
|
9
|
+
* - Footer with model/provider info and token stats
|
|
10
|
+
* - Container-based layout (header → chat → pending → editor → footer)
|
|
11
|
+
* - Context compaction (manual /compact + auto mode)
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { CombinedAutocompleteProvider, Container, Editor, Key, Loader, matchesKey, ProcessTerminal, SelectList, Spacer, Text, TUI, } from "@mariozechner/pi-tui";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import { compact, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, prepareCompaction, SessionManager as SessionManagerClass, shouldCompact, } from "edge-pi";
|
|
18
|
+
import { getLatestModels } from "../../model-factory.js";
|
|
19
|
+
import { expandPromptTemplate } from "../../prompts.js";
|
|
20
|
+
import { executeBashCommand } from "../../utils/bash-executor.js";
|
|
21
|
+
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
|
|
22
|
+
import { formatPendingMessages, parseBashInput } from "./bash-helpers.js";
|
|
23
|
+
import { AssistantMessageComponent } from "./components/assistant-message.js";
|
|
24
|
+
import { BashExecutionComponent } from "./components/bash-execution.js";
|
|
25
|
+
import { CompactionSummaryComponent } from "./components/compaction-summary.js";
|
|
26
|
+
import { FooterComponent } from "./components/footer.js";
|
|
27
|
+
import { ToolExecutionComponent } from "./components/tool-execution.js";
|
|
28
|
+
import { UserMessageComponent } from "./components/user-message.js";
|
|
29
|
+
import { getEditorTheme, getMarkdownTheme, getSelectListTheme } from "./theme.js";
|
|
30
|
+
/** Default context window size (used when model doesn't report one). */
|
|
31
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
32
|
+
/** Extract display-friendly output from a tool result. Handles both plain strings and structured objects with text/image fields. */
|
|
33
|
+
function extractToolOutput(output) {
|
|
34
|
+
if (typeof output === "string") {
|
|
35
|
+
return { text: output };
|
|
36
|
+
}
|
|
37
|
+
if (typeof output === "object" && output !== null && "text" in output && typeof output.text === "string") {
|
|
38
|
+
const obj = output;
|
|
39
|
+
return {
|
|
40
|
+
text: obj.text,
|
|
41
|
+
...(obj.image && { image: obj.image }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { text: JSON.stringify(output) };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Run the interactive TUI mode with streaming output.
|
|
48
|
+
*/
|
|
49
|
+
export async function runInteractiveMode(agent, options) {
|
|
50
|
+
const mode = new InteractiveMode(agent, options);
|
|
51
|
+
await mode.run();
|
|
52
|
+
}
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// InteractiveMode class
|
|
55
|
+
// ============================================================================
|
|
56
|
+
class InteractiveMode {
|
|
57
|
+
agent;
|
|
58
|
+
options;
|
|
59
|
+
currentProvider;
|
|
60
|
+
currentModelId;
|
|
61
|
+
ui;
|
|
62
|
+
headerContainer;
|
|
63
|
+
chatContainer;
|
|
64
|
+
pendingContainer;
|
|
65
|
+
pendingMessagesContainer;
|
|
66
|
+
editorContainer;
|
|
67
|
+
editor;
|
|
68
|
+
editorTheme;
|
|
69
|
+
// Message queues
|
|
70
|
+
steeringMessages = [];
|
|
71
|
+
followUpMessages = [];
|
|
72
|
+
isStreaming = false;
|
|
73
|
+
// Inline bash state
|
|
74
|
+
isBashMode = false;
|
|
75
|
+
isBashRunning = false;
|
|
76
|
+
bashAbortController = null;
|
|
77
|
+
bashComponent = undefined;
|
|
78
|
+
footer;
|
|
79
|
+
// Loading animation during agent processing
|
|
80
|
+
loadingAnimation = undefined;
|
|
81
|
+
// Streaming state
|
|
82
|
+
streamingComponent = undefined;
|
|
83
|
+
streamingText = "";
|
|
84
|
+
hadToolResults = false;
|
|
85
|
+
// Tool execution tracking: toolCallId → component
|
|
86
|
+
pendingTools = new Map();
|
|
87
|
+
// Tool output expansion state
|
|
88
|
+
toolOutputExpanded = false;
|
|
89
|
+
// Callback for resolving user input promise
|
|
90
|
+
onInputCallback;
|
|
91
|
+
// Pending clipboard images to attach to the next message
|
|
92
|
+
pendingImages = [];
|
|
93
|
+
// Compaction state
|
|
94
|
+
contextWindow;
|
|
95
|
+
compactionSettings;
|
|
96
|
+
autoCompaction = true;
|
|
97
|
+
isCompacting = false;
|
|
98
|
+
compactionAbortController = null;
|
|
99
|
+
constructor(agent, options) {
|
|
100
|
+
this.agent = agent;
|
|
101
|
+
this.options = options;
|
|
102
|
+
this.currentProvider = options.provider;
|
|
103
|
+
this.currentModelId = options.modelId;
|
|
104
|
+
this.contextWindow = options.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
105
|
+
// Initialize compaction settings from persisted settings if available
|
|
106
|
+
const savedCompaction = options.settingsManager?.getCompaction();
|
|
107
|
+
this.compactionSettings = {
|
|
108
|
+
...DEFAULT_COMPACTION_SETTINGS,
|
|
109
|
+
...(savedCompaction?.reserveTokens !== undefined && { reserveTokens: savedCompaction.reserveTokens }),
|
|
110
|
+
...(savedCompaction?.keepRecentTokens !== undefined && { keepRecentTokens: savedCompaction.keepRecentTokens }),
|
|
111
|
+
};
|
|
112
|
+
this.autoCompaction = options.settingsManager?.getCompactionEnabled() ?? true;
|
|
113
|
+
}
|
|
114
|
+
async run() {
|
|
115
|
+
this.initUI();
|
|
116
|
+
this.updateFooterTokens();
|
|
117
|
+
// Show session picker immediately if --resume was passed
|
|
118
|
+
if (this.options.resumeOnStart) {
|
|
119
|
+
await this.handleResume();
|
|
120
|
+
}
|
|
121
|
+
// Process initial messages
|
|
122
|
+
const { initialMessage, initialMessages = [] } = this.options;
|
|
123
|
+
const allInitial = [];
|
|
124
|
+
if (initialMessage)
|
|
125
|
+
allInitial.push(initialMessage);
|
|
126
|
+
allInitial.push(...initialMessages);
|
|
127
|
+
for (const msg of allInitial) {
|
|
128
|
+
this.chatContainer.addChild(new UserMessageComponent(msg, getMarkdownTheme()));
|
|
129
|
+
this.ui.requestRender();
|
|
130
|
+
await this.streamPrompt(msg);
|
|
131
|
+
}
|
|
132
|
+
// Main interactive loop
|
|
133
|
+
while (true) {
|
|
134
|
+
const userInput = await this.getUserInput();
|
|
135
|
+
await this.handleUserInput(userInput);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ========================================================================
|
|
139
|
+
// UI Setup
|
|
140
|
+
// ========================================================================
|
|
141
|
+
initUI() {
|
|
142
|
+
const { provider, modelId, skills = [], contextFiles = [], prompts = [], verbose, sessionManager } = this.options;
|
|
143
|
+
this.ui = new TUI(new ProcessTerminal());
|
|
144
|
+
// Header
|
|
145
|
+
this.headerContainer = new Container();
|
|
146
|
+
const logo = chalk.bold("epi") + chalk.dim(` - ${provider}/${modelId}`);
|
|
147
|
+
const hints = [
|
|
148
|
+
`${chalk.dim("Escape")} to abort`,
|
|
149
|
+
`${chalk.dim("!")} inline bash`,
|
|
150
|
+
`${chalk.dim("Alt+Enter")} follow-up while streaming`,
|
|
151
|
+
`${chalk.dim("Ctrl+C")} to exit`,
|
|
152
|
+
`${chalk.dim("Ctrl+E")} to expand tools`,
|
|
153
|
+
`${chalk.dim("Ctrl+L")} to switch model`,
|
|
154
|
+
`${chalk.dim("Ctrl+V")} to paste image`,
|
|
155
|
+
`${chalk.dim("↑/↓")} to browse history`,
|
|
156
|
+
`${chalk.dim("@")} for file references`,
|
|
157
|
+
`${chalk.dim("/")} for commands`,
|
|
158
|
+
].join("\n");
|
|
159
|
+
this.headerContainer.addChild(new Spacer(1));
|
|
160
|
+
this.headerContainer.addChild(new Text(`${logo}\n${hints}`, 1, 0));
|
|
161
|
+
this.headerContainer.addChild(new Spacer(1));
|
|
162
|
+
if (verbose && sessionManager?.getSessionFile()) {
|
|
163
|
+
this.headerContainer.addChild(new Text(chalk.dim(`Session: ${sessionManager.getSessionFile()}`), 1, 0));
|
|
164
|
+
}
|
|
165
|
+
// Show loaded context, skills, and prompts at startup
|
|
166
|
+
this.showLoadedResources(contextFiles, skills, prompts);
|
|
167
|
+
// Chat area
|
|
168
|
+
this.chatContainer = new Container();
|
|
169
|
+
// Pending messages (loading animations, status)
|
|
170
|
+
this.pendingContainer = new Container();
|
|
171
|
+
// Pending steering/follow-up messages
|
|
172
|
+
this.pendingMessagesContainer = new Container();
|
|
173
|
+
// Editor with slash command autocomplete
|
|
174
|
+
this.editorTheme = getEditorTheme();
|
|
175
|
+
this.editor = new Editor(this.ui, this.editorTheme);
|
|
176
|
+
this.editor.setAutocompleteProvider(this.buildAutocompleteProvider());
|
|
177
|
+
this.editorContainer = new Container();
|
|
178
|
+
this.editorContainer.addChild(this.editor);
|
|
179
|
+
// Footer
|
|
180
|
+
this.footer = new FooterComponent(this.currentProvider, this.currentModelId);
|
|
181
|
+
this.footer.setAutoCompaction(this.autoCompaction);
|
|
182
|
+
this.footer.setSubscription(this.isSubscriptionProvider());
|
|
183
|
+
// Assemble layout
|
|
184
|
+
this.ui.addChild(this.headerContainer);
|
|
185
|
+
this.ui.addChild(this.chatContainer);
|
|
186
|
+
this.ui.addChild(this.pendingMessagesContainer);
|
|
187
|
+
this.ui.addChild(this.pendingContainer);
|
|
188
|
+
this.ui.addChild(this.editorContainer);
|
|
189
|
+
this.ui.addChild(this.footer);
|
|
190
|
+
this.ui.setFocus(this.editor);
|
|
191
|
+
this.setupKeyHandlers();
|
|
192
|
+
this.ui.start();
|
|
193
|
+
}
|
|
194
|
+
// ========================================================================
|
|
195
|
+
// Key Handlers
|
|
196
|
+
// ========================================================================
|
|
197
|
+
setupKeyHandlers() {
|
|
198
|
+
this.editor.onChange = (text) => {
|
|
199
|
+
const wasBashMode = this.isBashMode;
|
|
200
|
+
this.isBashMode = text.trimStart().startsWith("!");
|
|
201
|
+
if (wasBashMode !== this.isBashMode) {
|
|
202
|
+
this.updateEditorBorderColor();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
this.editor.onSubmit = (text) => {
|
|
206
|
+
text = text.trim();
|
|
207
|
+
if (!text)
|
|
208
|
+
return;
|
|
209
|
+
// If agent is streaming, Enter becomes a steering message
|
|
210
|
+
if (this.isStreaming) {
|
|
211
|
+
this.agent.steer({ role: "user", content: [{ type: "text", text }] });
|
|
212
|
+
this.steeringMessages.push(text);
|
|
213
|
+
this.editor.setText("");
|
|
214
|
+
this.updatePendingMessagesDisplay();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this.editor.addToHistory(text);
|
|
218
|
+
this.editor.setText("");
|
|
219
|
+
if (this.onInputCallback) {
|
|
220
|
+
this.onInputCallback(text);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const origHandleInput = this.editor.handleInput.bind(this.editor);
|
|
224
|
+
this.editor.handleInput = (data) => {
|
|
225
|
+
// Escape: abort if agent is running or compacting
|
|
226
|
+
if (matchesKey(data, Key.escape)) {
|
|
227
|
+
if (this.isBashRunning && this.bashAbortController) {
|
|
228
|
+
this.bashAbortController.abort();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (this.isCompacting && this.compactionAbortController) {
|
|
232
|
+
this.compactionAbortController.abort();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (this.loadingAnimation) {
|
|
236
|
+
this.agent.abort();
|
|
237
|
+
this.stopLoading();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Ctrl+C: exit
|
|
242
|
+
if (matchesKey(data, Key.ctrl("c"))) {
|
|
243
|
+
this.shutdown();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Ctrl+D: exit if editor is empty
|
|
247
|
+
if (matchesKey(data, Key.ctrl("d"))) {
|
|
248
|
+
if (this.editor.getText().length === 0) {
|
|
249
|
+
this.shutdown();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Ctrl+E: toggle tool output expansion
|
|
254
|
+
if (matchesKey(data, Key.ctrl("e"))) {
|
|
255
|
+
this.toggleToolExpansion();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Ctrl+L: select model
|
|
259
|
+
if (matchesKey(data, Key.ctrl("l"))) {
|
|
260
|
+
this.handleModelSelect();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// Ctrl+V: paste image from clipboard
|
|
264
|
+
if (matchesKey(data, Key.ctrl("v"))) {
|
|
265
|
+
this.handleClipboardImagePaste();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Alt+Enter (Option+Enter on Mac): follow-up while streaming (or submit normally when idle)
|
|
269
|
+
if (matchesKey(data, Key.alt("enter"))) {
|
|
270
|
+
const text = this.editor.getText().trim();
|
|
271
|
+
if (!text)
|
|
272
|
+
return;
|
|
273
|
+
if (this.isStreaming) {
|
|
274
|
+
this.agent.followUp({ role: "user", content: [{ type: "text", text }] });
|
|
275
|
+
this.followUpMessages.push(text);
|
|
276
|
+
this.editor.setText("");
|
|
277
|
+
this.updatePendingMessagesDisplay();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// Not streaming: treat like regular submit
|
|
281
|
+
this.editor.onSubmit?.(text);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Alt+Up (Option+Up on Mac): dequeue all queued messages back into the editor
|
|
285
|
+
if (matchesKey(data, Key.alt("up"))) {
|
|
286
|
+
const restored = this.clearAllQueues();
|
|
287
|
+
if (restored.length > 0) {
|
|
288
|
+
this.editor.setText(restored.join("\n\n"));
|
|
289
|
+
this.updatePendingMessagesDisplay();
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
origHandleInput(data);
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
// ========================================================================
|
|
297
|
+
// User Input
|
|
298
|
+
// ========================================================================
|
|
299
|
+
getUserInput() {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
this.onInputCallback = (text) => {
|
|
302
|
+
this.onInputCallback = undefined;
|
|
303
|
+
resolve(text);
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
async handleUserInput(input) {
|
|
308
|
+
// Handle commands
|
|
309
|
+
if (input === "/help") {
|
|
310
|
+
this.showHelp();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (input === "/skills") {
|
|
314
|
+
this.showSkills();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (input === "/quit" || input === "/exit") {
|
|
318
|
+
this.shutdown();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (input === "/model") {
|
|
322
|
+
await this.handleModelSelect();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (input === "/login") {
|
|
326
|
+
await this.handleLogin();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (input === "/logout") {
|
|
330
|
+
await this.handleLogout();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (input === "/compact" || input.startsWith("/compact ")) {
|
|
334
|
+
const customInstructions = input.startsWith("/compact ") ? input.slice(9).trim() : undefined;
|
|
335
|
+
await this.handleCompactCommand(customInstructions);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (input === "/auto-compact") {
|
|
339
|
+
this.toggleAutoCompaction();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (input === "/resume") {
|
|
343
|
+
await this.handleResume();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (input.startsWith("/skill:")) {
|
|
347
|
+
const skillName = input.slice("/skill:".length).trim();
|
|
348
|
+
await this.handleSkillInvocation(skillName);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Handle bash commands (! for normal, !! for excluded from context)
|
|
352
|
+
const bashParsed = parseBashInput(input);
|
|
353
|
+
if (bashParsed) {
|
|
354
|
+
if (this.isBashRunning) {
|
|
355
|
+
this.showStatus(chalk.yellow("A bash command is already running. Press Escape to cancel it first."));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
await this.handleBashCommand(bashParsed.command, bashParsed.excludeFromContext);
|
|
359
|
+
this.isBashMode = false;
|
|
360
|
+
this.updateEditorBorderColor();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// Try expanding prompt templates
|
|
364
|
+
const { prompts = [] } = this.options;
|
|
365
|
+
const expanded = expandPromptTemplate(input, prompts);
|
|
366
|
+
// Capture and clear pending images
|
|
367
|
+
const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined;
|
|
368
|
+
this.pendingImages = [];
|
|
369
|
+
// Regular message (use expanded text if a prompt template was matched)
|
|
370
|
+
const imageLabel = images ? chalk.dim(` (${images.length} image${images.length > 1 ? "s" : ""})`) : "";
|
|
371
|
+
this.chatContainer.addChild(new UserMessageComponent(`${expanded}${imageLabel}`, getMarkdownTheme()));
|
|
372
|
+
this.ui.requestRender();
|
|
373
|
+
await this.streamPrompt(expanded, images);
|
|
374
|
+
}
|
|
375
|
+
async handleBashCommand(command, excludeFromContext) {
|
|
376
|
+
const { sessionManager } = this.options;
|
|
377
|
+
this.bashAbortController = new AbortController();
|
|
378
|
+
this.isBashRunning = true;
|
|
379
|
+
this.bashComponent = new BashExecutionComponent(command, this.ui, excludeFromContext);
|
|
380
|
+
if (this.toolOutputExpanded) {
|
|
381
|
+
this.bashComponent.setExpanded(true);
|
|
382
|
+
}
|
|
383
|
+
this.chatContainer.addChild(this.bashComponent);
|
|
384
|
+
this.ui.requestRender();
|
|
385
|
+
try {
|
|
386
|
+
const result = await executeBashCommand(command, {
|
|
387
|
+
signal: this.bashAbortController.signal,
|
|
388
|
+
onChunk: (chunk) => {
|
|
389
|
+
this.bashComponent?.appendOutput(chunk);
|
|
390
|
+
this.ui.requestRender();
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
this.bashComponent.setComplete(result.exitCode, result.cancelled, result.truncated, result.fullOutputPath);
|
|
394
|
+
this.ui.requestRender();
|
|
395
|
+
if (!excludeFromContext) {
|
|
396
|
+
const msgText = `Ran \`${command}\`\n\n\`\`\`\n${result.output.trimEnd()}\n\`\`\``;
|
|
397
|
+
const userMsg = { role: "user", content: [{ type: "text", text: msgText }] };
|
|
398
|
+
this.agent.setMessages([...this.agent.messages, userMsg]);
|
|
399
|
+
sessionManager?.appendMessage(userMsg);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
this.isBashRunning = false;
|
|
404
|
+
this.bashAbortController = null;
|
|
405
|
+
this.bashComponent = undefined;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
// ========================================================================
|
|
409
|
+
// Autocomplete
|
|
410
|
+
// ========================================================================
|
|
411
|
+
buildAutocompleteProvider() {
|
|
412
|
+
const { skills = [], prompts = [], fdPath } = this.options;
|
|
413
|
+
const commands = [
|
|
414
|
+
{ name: "help", description: "Show available commands" },
|
|
415
|
+
{ name: "resume", description: "Resume a previous session" },
|
|
416
|
+
{ name: "compact", description: "Manually compact the session context" },
|
|
417
|
+
{ name: "auto-compact", description: "Toggle automatic context compaction" },
|
|
418
|
+
{ name: "login", description: "Login to an OAuth provider" },
|
|
419
|
+
{ name: "logout", description: "Logout from an OAuth provider" },
|
|
420
|
+
{ name: "skills", description: "List loaded skills" },
|
|
421
|
+
{ name: "model", description: "Switch model (Ctrl+L)" },
|
|
422
|
+
{ name: "quit", description: "Exit the CLI" },
|
|
423
|
+
{ name: "exit", description: "Exit the CLI" },
|
|
424
|
+
];
|
|
425
|
+
for (const skill of skills) {
|
|
426
|
+
commands.push({
|
|
427
|
+
name: `skill:${skill.name}`,
|
|
428
|
+
description: skill.description,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
// Add prompt templates as slash commands
|
|
432
|
+
for (const prompt of prompts) {
|
|
433
|
+
commands.push({
|
|
434
|
+
name: prompt.name,
|
|
435
|
+
description: prompt.description,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return new CombinedAutocompleteProvider(commands, process.cwd(), fdPath ?? null);
|
|
439
|
+
}
|
|
440
|
+
// ========================================================================
|
|
441
|
+
// Model Selection
|
|
442
|
+
// ========================================================================
|
|
443
|
+
async handleModelSelect() {
|
|
444
|
+
const latestModels = getLatestModels();
|
|
445
|
+
const modelOptions = [];
|
|
446
|
+
for (const [provider, models] of Object.entries(latestModels)) {
|
|
447
|
+
for (const modelId of models) {
|
|
448
|
+
modelOptions.push({ provider, modelId, label: `${provider}/${modelId}` });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const items = modelOptions.map((m) => {
|
|
452
|
+
const current = m.provider === this.currentProvider && m.modelId === this.currentModelId;
|
|
453
|
+
return {
|
|
454
|
+
value: `${m.provider}/${m.modelId}`,
|
|
455
|
+
label: current ? `${m.label} (current)` : m.label,
|
|
456
|
+
};
|
|
457
|
+
});
|
|
458
|
+
const selected = await this.showSelectList("Switch model", items);
|
|
459
|
+
if (!selected)
|
|
460
|
+
return;
|
|
461
|
+
const [newProvider, ...modelParts] = selected.split("/");
|
|
462
|
+
const newModelId = modelParts.join("/");
|
|
463
|
+
if (newProvider === this.currentProvider && newModelId === this.currentModelId) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
this.showStatus(chalk.dim(`Switching to ${newProvider}/${newModelId}...`));
|
|
467
|
+
if (!this.options.onModelChange) {
|
|
468
|
+
this.showStatus(chalk.yellow("Model switching is not available."));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const newAgent = await this.options.onModelChange(newProvider, newModelId);
|
|
473
|
+
// Preserve conversation history
|
|
474
|
+
newAgent.setMessages([...this.agent.messages]);
|
|
475
|
+
this.agent = newAgent;
|
|
476
|
+
this.currentProvider = newProvider;
|
|
477
|
+
this.currentModelId = newModelId;
|
|
478
|
+
this.updateFooter();
|
|
479
|
+
// Persist the choice for next startup
|
|
480
|
+
this.options.settingsManager?.setDefaults(newProvider, newModelId);
|
|
481
|
+
this.showStatus(chalk.green(`Switched to ${newProvider}/${newModelId}`));
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
485
|
+
this.showStatus(chalk.red(`Failed to switch model: ${msg}`));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// ========================================================================
|
|
489
|
+
// Streaming
|
|
490
|
+
// ========================================================================
|
|
491
|
+
async streamPrompt(prompt, images) {
|
|
492
|
+
this.isStreaming = true;
|
|
493
|
+
this.updatePendingMessagesDisplay();
|
|
494
|
+
const { sessionManager } = this.options;
|
|
495
|
+
const messagesBefore = this.agent.messages.length;
|
|
496
|
+
// Build image parts from clipboard images
|
|
497
|
+
const imageParts = (images ?? []).map((img) => ({
|
|
498
|
+
type: "image",
|
|
499
|
+
image: Buffer.from(img.bytes).toString("base64"),
|
|
500
|
+
mediaType: img.mimeType,
|
|
501
|
+
}));
|
|
502
|
+
// Start loading animation
|
|
503
|
+
this.startLoading();
|
|
504
|
+
this.streamingComponent = undefined;
|
|
505
|
+
this.streamingText = "";
|
|
506
|
+
this.hadToolResults = false;
|
|
507
|
+
let errorDisplayed = false;
|
|
508
|
+
let streamFailed = false;
|
|
509
|
+
try {
|
|
510
|
+
const result = imageParts.length > 0
|
|
511
|
+
? await this.agent.stream({
|
|
512
|
+
messages: [
|
|
513
|
+
{
|
|
514
|
+
role: "user",
|
|
515
|
+
content: [{ type: "text", text: prompt }, ...imageParts],
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
})
|
|
519
|
+
: await this.agent.stream({ prompt });
|
|
520
|
+
for await (const part of result.fullStream) {
|
|
521
|
+
switch (part.type) {
|
|
522
|
+
case "text-delta":
|
|
523
|
+
// After tool results, or for the very first text part, start a new assistant message component
|
|
524
|
+
// so each agent step gets its own message bubble
|
|
525
|
+
if (this.hadToolResults || !this.streamingComponent) {
|
|
526
|
+
this.streamingComponent = new AssistantMessageComponent(getMarkdownTheme());
|
|
527
|
+
this.streamingText = "";
|
|
528
|
+
this.hadToolResults = false;
|
|
529
|
+
this.chatContainer.addChild(this.streamingComponent);
|
|
530
|
+
}
|
|
531
|
+
this.streamingText += part.text;
|
|
532
|
+
this.streamingComponent.updateText(this.streamingText);
|
|
533
|
+
this.ui.requestRender();
|
|
534
|
+
break;
|
|
535
|
+
case "tool-call": {
|
|
536
|
+
const args = typeof part.input === "object" && part.input !== null
|
|
537
|
+
? part.input
|
|
538
|
+
: {};
|
|
539
|
+
const toolComponent = new ToolExecutionComponent(part.toolName, args);
|
|
540
|
+
if (this.toolOutputExpanded) {
|
|
541
|
+
toolComponent.setExpanded(true);
|
|
542
|
+
}
|
|
543
|
+
this.pendingTools.set(part.toolCallId, toolComponent);
|
|
544
|
+
this.chatContainer.addChild(toolComponent);
|
|
545
|
+
this.ui.requestRender();
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case "tool-result": {
|
|
549
|
+
const toolComponent = this.pendingTools.get(part.toolCallId);
|
|
550
|
+
if (toolComponent) {
|
|
551
|
+
const toolOutput = extractToolOutput(part.output);
|
|
552
|
+
toolComponent.updateResult(toolOutput, /* isError */ false, /* isPartial */ false);
|
|
553
|
+
this.pendingTools.delete(part.toolCallId);
|
|
554
|
+
this.hadToolResults = true;
|
|
555
|
+
this.ui.requestRender();
|
|
556
|
+
}
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case "error": {
|
|
560
|
+
const errorMessage = part.error?.message ??
|
|
561
|
+
(typeof part.error === "object" && part.error !== null
|
|
562
|
+
? JSON.stringify(part.error)
|
|
563
|
+
: String(part.error));
|
|
564
|
+
if (this.streamingComponent) {
|
|
565
|
+
this.streamingComponent.setError(errorMessage);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
this.showStatus(chalk.red(`Error: ${errorMessage}`));
|
|
569
|
+
}
|
|
570
|
+
errorDisplayed = true;
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (errorDisplayed)
|
|
576
|
+
return;
|
|
577
|
+
// Get final response and update messages
|
|
578
|
+
const response = await result.response;
|
|
579
|
+
const responseMessages = response.messages;
|
|
580
|
+
this.agent.setMessages([
|
|
581
|
+
...this.agent.messages.slice(0, messagesBefore),
|
|
582
|
+
...buildUserMessage(prompt, imageParts),
|
|
583
|
+
...responseMessages,
|
|
584
|
+
]);
|
|
585
|
+
// Save to session
|
|
586
|
+
if (sessionManager) {
|
|
587
|
+
const userMsg = {
|
|
588
|
+
role: "user",
|
|
589
|
+
content: [{ type: "text", text: prompt }, ...imageParts],
|
|
590
|
+
};
|
|
591
|
+
sessionManager.appendMessage(userMsg);
|
|
592
|
+
for (const msg of responseMessages) {
|
|
593
|
+
sessionManager.appendMessage(msg);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Update footer token stats
|
|
597
|
+
this.updateFooterTokens();
|
|
598
|
+
// Check for auto-compaction after successful response
|
|
599
|
+
await this.checkAutoCompaction();
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
if (errorDisplayed) {
|
|
603
|
+
streamFailed = true;
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
streamFailed = true;
|
|
607
|
+
if (error.name === "AbortError") {
|
|
608
|
+
if (this.streamingComponent) {
|
|
609
|
+
this.streamingComponent.setAborted();
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
this.showStatus(chalk.dim("[aborted]"));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
const msg = error instanceof Error
|
|
617
|
+
? error.message
|
|
618
|
+
: typeof error === "object" && error !== null
|
|
619
|
+
? JSON.stringify(error)
|
|
620
|
+
: String(error);
|
|
621
|
+
if (this.streamingComponent) {
|
|
622
|
+
this.streamingComponent.setError(msg);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
this.showStatus(chalk.red(`Error: ${msg}`));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
this.stopLoading();
|
|
631
|
+
this.streamingComponent = undefined;
|
|
632
|
+
this.streamingText = "";
|
|
633
|
+
this.hadToolResults = false;
|
|
634
|
+
this.pendingTools.clear();
|
|
635
|
+
this.isStreaming = false;
|
|
636
|
+
this.steeringMessages = [];
|
|
637
|
+
if (streamFailed) {
|
|
638
|
+
this.followUpMessages = [];
|
|
639
|
+
}
|
|
640
|
+
this.updatePendingMessagesDisplay();
|
|
641
|
+
this.ui.requestRender();
|
|
642
|
+
}
|
|
643
|
+
// Process queued follow-ups (skipped if stream failed/aborted)
|
|
644
|
+
while (this.followUpMessages.length > 0) {
|
|
645
|
+
const next = this.followUpMessages.shift();
|
|
646
|
+
if (!next)
|
|
647
|
+
break;
|
|
648
|
+
this.updatePendingMessagesDisplay();
|
|
649
|
+
this.chatContainer.addChild(new UserMessageComponent(next, getMarkdownTheme()));
|
|
650
|
+
this.ui.requestRender();
|
|
651
|
+
await this.streamPrompt(next);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
updatePendingMessagesDisplay() {
|
|
655
|
+
this.pendingMessagesContainer.clear();
|
|
656
|
+
// If no agent is running, clear pending messages (they've been consumed)
|
|
657
|
+
if (!this.isStreaming && this.followUpMessages.length === 0 && this.steeringMessages.length === 0) {
|
|
658
|
+
this.ui.requestRender();
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
const lines = formatPendingMessages(this.steeringMessages, this.followUpMessages);
|
|
662
|
+
if (lines.length === 0) {
|
|
663
|
+
this.ui.requestRender();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
this.pendingMessagesContainer.addChild(new Spacer(1));
|
|
667
|
+
this.pendingMessagesContainer.addChild(new Text(lines.map((l) => chalk.dim(l)).join("\n"), 1, 0));
|
|
668
|
+
this.pendingMessagesContainer.addChild(new Text(chalk.dim("↳ Alt+Up to edit queued messages"), 1, 0));
|
|
669
|
+
this.pendingMessagesContainer.addChild(new Spacer(1));
|
|
670
|
+
this.ui.requestRender();
|
|
671
|
+
}
|
|
672
|
+
clearAllQueues() {
|
|
673
|
+
const restored = [...this.steeringMessages, ...this.followUpMessages];
|
|
674
|
+
this.steeringMessages = [];
|
|
675
|
+
this.followUpMessages = [];
|
|
676
|
+
// Force agent to drop its internal queue too if possible, but edge-pi agent doesn't expose that yet.
|
|
677
|
+
// However, steering messages are consumed immediately by the agent loop so they might already be gone.
|
|
678
|
+
// Follow-ups are managed here in the loop, so clearing this array stops them.
|
|
679
|
+
return restored;
|
|
680
|
+
}
|
|
681
|
+
// ========================================================================
|
|
682
|
+
// Loading Animation
|
|
683
|
+
// ========================================================================
|
|
684
|
+
startLoading() {
|
|
685
|
+
this.stopLoading();
|
|
686
|
+
this.loadingAnimation = new Loader(this.ui, (s) => chalk.cyan(s), (s) => chalk.dim(s), "Working...");
|
|
687
|
+
this.loadingAnimation.start();
|
|
688
|
+
this.pendingContainer.addChild(new Spacer(1));
|
|
689
|
+
this.pendingContainer.addChild(this.loadingAnimation);
|
|
690
|
+
this.ui.requestRender();
|
|
691
|
+
}
|
|
692
|
+
stopLoading() {
|
|
693
|
+
if (this.loadingAnimation) {
|
|
694
|
+
this.loadingAnimation.stop();
|
|
695
|
+
this.pendingContainer.clear();
|
|
696
|
+
this.loadingAnimation = undefined;
|
|
697
|
+
this.ui.requestRender();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// ========================================================================
|
|
701
|
+
// Tool Expansion
|
|
702
|
+
// ========================================================================
|
|
703
|
+
toggleToolExpansion() {
|
|
704
|
+
this.toolOutputExpanded = !this.toolOutputExpanded;
|
|
705
|
+
// Update all tool components and compaction components in the chat
|
|
706
|
+
for (const child of this.chatContainer.children) {
|
|
707
|
+
if (child instanceof ToolExecutionComponent) {
|
|
708
|
+
child.setExpanded(this.toolOutputExpanded);
|
|
709
|
+
}
|
|
710
|
+
else if (child instanceof CompactionSummaryComponent) {
|
|
711
|
+
child.setExpanded(this.toolOutputExpanded);
|
|
712
|
+
}
|
|
713
|
+
else if (child instanceof BashExecutionComponent) {
|
|
714
|
+
child.setExpanded(this.toolOutputExpanded);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
this.ui.requestRender();
|
|
718
|
+
}
|
|
719
|
+
// ========================================================================
|
|
720
|
+
// Clipboard Image Paste
|
|
721
|
+
// ========================================================================
|
|
722
|
+
handleClipboardImagePaste() {
|
|
723
|
+
try {
|
|
724
|
+
const image = readClipboardImage();
|
|
725
|
+
if (!image)
|
|
726
|
+
return;
|
|
727
|
+
this.pendingImages.push(image);
|
|
728
|
+
const ext = extensionForImageMimeType(image.mimeType) ?? "image";
|
|
729
|
+
const label = `[image ${this.pendingImages.length}: ${ext}]`;
|
|
730
|
+
this.editor.insertTextAtCursor(label);
|
|
731
|
+
this.ui.requestRender();
|
|
732
|
+
}
|
|
733
|
+
catch {
|
|
734
|
+
// Silently ignore clipboard errors (may not have permission, etc.)
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// ========================================================================
|
|
738
|
+
// Compaction
|
|
739
|
+
// ========================================================================
|
|
740
|
+
/**
|
|
741
|
+
* Handle the /compact command.
|
|
742
|
+
*/
|
|
743
|
+
async handleCompactCommand(_customInstructions) {
|
|
744
|
+
const messages = this.agent.messages;
|
|
745
|
+
if (messages.length < 2) {
|
|
746
|
+
this.showStatus(chalk.yellow("Nothing to compact (not enough messages)."));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
await this.executeCompaction(false);
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Toggle auto-compaction on/off.
|
|
753
|
+
*/
|
|
754
|
+
toggleAutoCompaction() {
|
|
755
|
+
this.autoCompaction = !this.autoCompaction;
|
|
756
|
+
this.footer.setAutoCompaction(this.autoCompaction);
|
|
757
|
+
this.options.settingsManager?.setCompactionEnabled(this.autoCompaction);
|
|
758
|
+
this.showStatus(this.autoCompaction ? chalk.green("Auto-compaction enabled") : chalk.dim("Auto-compaction disabled"));
|
|
759
|
+
this.ui.requestRender();
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Check if auto-compaction should trigger after an agent response.
|
|
763
|
+
*/
|
|
764
|
+
async checkAutoCompaction() {
|
|
765
|
+
if (!this.autoCompaction)
|
|
766
|
+
return;
|
|
767
|
+
const contextTokens = estimateContextTokens([...this.agent.messages]);
|
|
768
|
+
if (!shouldCompact(contextTokens, this.contextWindow, this.compactionSettings))
|
|
769
|
+
return;
|
|
770
|
+
await this.executeCompaction(true);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Execute compaction (used by both manual /compact and auto mode).
|
|
774
|
+
*/
|
|
775
|
+
async executeCompaction(isAuto) {
|
|
776
|
+
if (this.isCompacting)
|
|
777
|
+
return undefined;
|
|
778
|
+
const { sessionManager } = this.options;
|
|
779
|
+
// Build path entries from session if available, otherwise from agent messages
|
|
780
|
+
const pathEntries = sessionManager ? sessionManager.getBranch() : this.buildSessionEntriesFromMessages();
|
|
781
|
+
if (pathEntries.length < 2) {
|
|
782
|
+
if (!isAuto) {
|
|
783
|
+
this.showStatus(chalk.yellow("Nothing to compact (not enough messages)."));
|
|
784
|
+
}
|
|
785
|
+
return undefined;
|
|
786
|
+
}
|
|
787
|
+
// Prepare compaction
|
|
788
|
+
const preparation = prepareCompaction(pathEntries, this.compactionSettings);
|
|
789
|
+
if (!preparation) {
|
|
790
|
+
if (!isAuto) {
|
|
791
|
+
this.showStatus(chalk.yellow("Nothing to compact (already compacted or insufficient history)."));
|
|
792
|
+
}
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
795
|
+
if (preparation.messagesToSummarize.length === 0) {
|
|
796
|
+
if (!isAuto) {
|
|
797
|
+
this.showStatus(chalk.yellow("Nothing to compact (no messages to summarize)."));
|
|
798
|
+
}
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
this.isCompacting = true;
|
|
802
|
+
this.compactionAbortController = new AbortController();
|
|
803
|
+
// Show compaction indicator
|
|
804
|
+
const label = isAuto
|
|
805
|
+
? "Auto-compacting context... (Escape to cancel)"
|
|
806
|
+
: "Compacting context... (Escape to cancel)";
|
|
807
|
+
const compactingLoader = new Loader(this.ui, (s) => chalk.cyan(s), (s) => chalk.dim(s), label);
|
|
808
|
+
compactingLoader.start();
|
|
809
|
+
this.pendingContainer.clear();
|
|
810
|
+
this.pendingContainer.addChild(new Spacer(1));
|
|
811
|
+
this.pendingContainer.addChild(compactingLoader);
|
|
812
|
+
this.ui.requestRender();
|
|
813
|
+
let result;
|
|
814
|
+
try {
|
|
815
|
+
// We need a LanguageModel for summarization. Use the agent's model
|
|
816
|
+
// by extracting it from the config. The model is accessible through
|
|
817
|
+
// the onModelChange callback pattern, but for simplicity we create
|
|
818
|
+
// a model via the same factory used at startup.
|
|
819
|
+
const { model } = await this.getCompactionModel();
|
|
820
|
+
result = await compact(preparation, model, this.compactionAbortController.signal);
|
|
821
|
+
// Record compaction in session
|
|
822
|
+
if (sessionManager) {
|
|
823
|
+
sessionManager.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, result.details);
|
|
824
|
+
}
|
|
825
|
+
// Rebuild agent messages from the session context
|
|
826
|
+
if (sessionManager) {
|
|
827
|
+
const context = sessionManager.buildSessionContext();
|
|
828
|
+
this.agent.setMessages(context.messages);
|
|
829
|
+
}
|
|
830
|
+
// Rebuild the chat UI
|
|
831
|
+
this.rebuildChatFromSession();
|
|
832
|
+
// Add compaction summary component so user sees it
|
|
833
|
+
const summaryComponent = new CompactionSummaryComponent(result.tokensBefore, result.summary);
|
|
834
|
+
if (this.toolOutputExpanded) {
|
|
835
|
+
summaryComponent.setExpanded(true);
|
|
836
|
+
}
|
|
837
|
+
this.chatContainer.addChild(summaryComponent);
|
|
838
|
+
// Update footer tokens
|
|
839
|
+
this.updateFooterTokens();
|
|
840
|
+
if (this.options.verbose) {
|
|
841
|
+
const tokensAfter = estimateContextTokens([...this.agent.messages]);
|
|
842
|
+
this.showStatus(chalk.dim(`Compacted: ${result.tokensBefore.toLocaleString()} -> ${tokensAfter.toLocaleString()} tokens`));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
847
|
+
if (this.compactionAbortController.signal.aborted ||
|
|
848
|
+
message === "Compaction cancelled" ||
|
|
849
|
+
(error instanceof Error && error.name === "AbortError")) {
|
|
850
|
+
this.showStatus(chalk.dim("Compaction cancelled."));
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
this.showStatus(chalk.red(`Compaction failed: ${message}`));
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
finally {
|
|
857
|
+
compactingLoader.stop();
|
|
858
|
+
this.pendingContainer.clear();
|
|
859
|
+
this.isCompacting = false;
|
|
860
|
+
this.compactionAbortController = null;
|
|
861
|
+
this.ui.requestRender();
|
|
862
|
+
}
|
|
863
|
+
return result;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Get the language model for compaction summarization.
|
|
867
|
+
* Uses the same model creation path as the main agent.
|
|
868
|
+
*/
|
|
869
|
+
async getCompactionModel() {
|
|
870
|
+
const { createModel } = await import("../../model-factory.js");
|
|
871
|
+
return createModel({
|
|
872
|
+
provider: this.currentProvider,
|
|
873
|
+
model: this.currentModelId,
|
|
874
|
+
authStorage: this.options.authStorage,
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Build session entries from agent messages (when no session manager).
|
|
879
|
+
* Creates synthetic SessionEntry objects for the compaction algorithm.
|
|
880
|
+
*/
|
|
881
|
+
buildSessionEntriesFromMessages() {
|
|
882
|
+
const messages = this.agent.messages;
|
|
883
|
+
const entries = [];
|
|
884
|
+
let parentId = null;
|
|
885
|
+
for (let i = 0; i < messages.length; i++) {
|
|
886
|
+
const id = `msg-${i}`;
|
|
887
|
+
entries.push({
|
|
888
|
+
type: "message",
|
|
889
|
+
id,
|
|
890
|
+
parentId,
|
|
891
|
+
timestamp: new Date().toISOString(),
|
|
892
|
+
message: messages[i],
|
|
893
|
+
});
|
|
894
|
+
parentId = id;
|
|
895
|
+
}
|
|
896
|
+
return entries;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Rebuild the chat UI from session context after compaction.
|
|
900
|
+
*/
|
|
901
|
+
rebuildChatFromSession() {
|
|
902
|
+
this.chatContainer.clear();
|
|
903
|
+
const messages = this.agent.messages;
|
|
904
|
+
for (const msg of messages) {
|
|
905
|
+
if (msg.role === "user") {
|
|
906
|
+
// Check if this is a compaction summary
|
|
907
|
+
const content = msg.content;
|
|
908
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
909
|
+
const textBlock = content[0];
|
|
910
|
+
if (textBlock.type === "text" && textBlock.text?.startsWith('<summary type="compaction"')) {
|
|
911
|
+
// Skip compaction summaries in rebuild (they are injected by buildSessionContext)
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (textBlock.type === "text" && textBlock.text?.startsWith('<summary type="branch"')) {
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
const text = extractTextFromMessage(msg);
|
|
919
|
+
if (text) {
|
|
920
|
+
this.chatContainer.addChild(new UserMessageComponent(text, getMarkdownTheme()));
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
else if (msg.role === "assistant") {
|
|
924
|
+
const assistantMsg = msg;
|
|
925
|
+
const textParts = [];
|
|
926
|
+
for (const block of assistantMsg.content) {
|
|
927
|
+
const b = block;
|
|
928
|
+
if (b.type === "text" && b.text) {
|
|
929
|
+
textParts.push(b.text);
|
|
930
|
+
}
|
|
931
|
+
else if (b.type === "tool-call" && b.toolName) {
|
|
932
|
+
const args = typeof b.input === "object" && b.input !== null ? b.input : {};
|
|
933
|
+
const toolComp = new ToolExecutionComponent(b.toolName, args);
|
|
934
|
+
if (this.toolOutputExpanded) {
|
|
935
|
+
toolComp.setExpanded(true);
|
|
936
|
+
}
|
|
937
|
+
// Mark as completed (we don't have the result here, just show collapsed)
|
|
938
|
+
toolComp.updateResult({ text: "(from history)" }, false, false);
|
|
939
|
+
this.chatContainer.addChild(toolComp);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (textParts.length > 0) {
|
|
943
|
+
const comp = new AssistantMessageComponent(getMarkdownTheme());
|
|
944
|
+
comp.updateText(textParts.join(""));
|
|
945
|
+
this.chatContainer.addChild(comp);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// Skip tool messages in UI rebuild - they are consumed by tool-call components
|
|
949
|
+
}
|
|
950
|
+
this.ui.requestRender();
|
|
951
|
+
}
|
|
952
|
+
// ========================================================================
|
|
953
|
+
// Footer Token Tracking
|
|
954
|
+
// ========================================================================
|
|
955
|
+
/**
|
|
956
|
+
* Update the footer with current token count information.
|
|
957
|
+
*/
|
|
958
|
+
updateFooterTokens() {
|
|
959
|
+
const contextTokens = estimateContextTokens([...this.agent.messages]);
|
|
960
|
+
this.footer.setTokenInfo(contextTokens, this.contextWindow);
|
|
961
|
+
this.footer.setAutoCompaction(this.autoCompaction);
|
|
962
|
+
this.ui?.requestRender();
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Check if the current provider is using an OAuth subscription credential.
|
|
966
|
+
*/
|
|
967
|
+
isSubscriptionProvider() {
|
|
968
|
+
const { authStorage } = this.options;
|
|
969
|
+
if (!authStorage)
|
|
970
|
+
return false;
|
|
971
|
+
const cred = authStorage.get(this.currentProvider);
|
|
972
|
+
return cred?.type === "oauth";
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Replace the footer component and update token info.
|
|
976
|
+
*/
|
|
977
|
+
updateFooter() {
|
|
978
|
+
this.footer = new FooterComponent(this.currentProvider, this.currentModelId);
|
|
979
|
+
this.footer.setSubscription(this.isSubscriptionProvider());
|
|
980
|
+
this.updateFooterTokens();
|
|
981
|
+
// Replace footer in UI
|
|
982
|
+
const children = this.ui.children;
|
|
983
|
+
children[children.length - 1] = this.footer;
|
|
984
|
+
this.ui.requestRender();
|
|
985
|
+
}
|
|
986
|
+
// ========================================================================
|
|
987
|
+
// Startup Resource Display
|
|
988
|
+
// ========================================================================
|
|
989
|
+
formatDisplayPath(p) {
|
|
990
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
991
|
+
if (home && p.startsWith(home)) {
|
|
992
|
+
return `~${p.slice(home.length)}`;
|
|
993
|
+
}
|
|
994
|
+
return p;
|
|
995
|
+
}
|
|
996
|
+
showLoadedResources(contextFiles, skills, prompts) {
|
|
997
|
+
const sectionHeader = (name) => chalk.cyan(`[${name}]`);
|
|
998
|
+
if (contextFiles.length > 0) {
|
|
999
|
+
const contextList = contextFiles.map((f) => chalk.dim(` ${this.formatDisplayPath(f.path)}`)).join("\n");
|
|
1000
|
+
this.headerContainer.addChild(new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0));
|
|
1001
|
+
this.headerContainer.addChild(new Spacer(1));
|
|
1002
|
+
}
|
|
1003
|
+
if (skills.length > 0) {
|
|
1004
|
+
const skillList = skills.map((s) => chalk.dim(` ${this.formatDisplayPath(s.filePath)}`)).join("\n");
|
|
1005
|
+
this.headerContainer.addChild(new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0));
|
|
1006
|
+
this.headerContainer.addChild(new Spacer(1));
|
|
1007
|
+
}
|
|
1008
|
+
if (prompts.length > 0) {
|
|
1009
|
+
const promptList = prompts
|
|
1010
|
+
.map((p) => {
|
|
1011
|
+
const sourceLabel = chalk.cyan(p.source);
|
|
1012
|
+
return chalk.dim(` ${sourceLabel} /${p.name}`);
|
|
1013
|
+
})
|
|
1014
|
+
.join("\n");
|
|
1015
|
+
this.headerContainer.addChild(new Text(`${sectionHeader("Prompts")}\n${promptList}`, 0, 0));
|
|
1016
|
+
this.headerContainer.addChild(new Spacer(1));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// ========================================================================
|
|
1020
|
+
// Commands
|
|
1021
|
+
// ========================================================================
|
|
1022
|
+
showHelp() {
|
|
1023
|
+
const helpText = [
|
|
1024
|
+
chalk.bold("Commands:"),
|
|
1025
|
+
" !<command> Run inline bash and include output in context",
|
|
1026
|
+
" !!<command> Run inline bash but exclude output from context",
|
|
1027
|
+
" /resume Resume a previous session",
|
|
1028
|
+
" /compact [text] Compact the session context (optional instructions)",
|
|
1029
|
+
" /auto-compact Toggle automatic context compaction",
|
|
1030
|
+
" /model Switch model (Ctrl+L)",
|
|
1031
|
+
" /login Login to an OAuth provider",
|
|
1032
|
+
" /logout Logout from an OAuth provider",
|
|
1033
|
+
" /skills List loaded skills",
|
|
1034
|
+
" /skill:<name> Invoke a skill by name",
|
|
1035
|
+
" /quit, /exit Exit the CLI",
|
|
1036
|
+
].join("\n");
|
|
1037
|
+
this.chatContainer.addChild(new Spacer(1));
|
|
1038
|
+
this.chatContainer.addChild(new Text(helpText, 1, 0));
|
|
1039
|
+
this.ui.requestRender();
|
|
1040
|
+
}
|
|
1041
|
+
showSkills() {
|
|
1042
|
+
const { skills = [] } = this.options;
|
|
1043
|
+
if (skills.length === 0) {
|
|
1044
|
+
this.showStatus(chalk.dim("No skills loaded."));
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const lines = [];
|
|
1048
|
+
for (const skill of skills) {
|
|
1049
|
+
const hidden = skill.disableModelInvocation ? chalk.dim(" (hidden from model)") : "";
|
|
1050
|
+
lines.push(` ${chalk.bold(skill.name)}${hidden}`);
|
|
1051
|
+
lines.push(chalk.dim(` ${skill.description}`));
|
|
1052
|
+
lines.push(chalk.dim(` ${skill.filePath}`));
|
|
1053
|
+
}
|
|
1054
|
+
this.chatContainer.addChild(new Spacer(1));
|
|
1055
|
+
this.chatContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
1056
|
+
this.ui.requestRender();
|
|
1057
|
+
}
|
|
1058
|
+
async handleSkillInvocation(skillName) {
|
|
1059
|
+
const { skills = [] } = this.options;
|
|
1060
|
+
const skill = skills.find((s) => s.name === skillName);
|
|
1061
|
+
if (!skill) {
|
|
1062
|
+
this.showStatus(chalk.red(`Skill "${skillName}" not found.`));
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const skillPrompt = `Please read and follow the instructions in the skill file: ${skill.filePath}`;
|
|
1066
|
+
this.chatContainer.addChild(new UserMessageComponent(skillPrompt, getMarkdownTheme()));
|
|
1067
|
+
this.ui.requestRender();
|
|
1068
|
+
await this.streamPrompt(skillPrompt);
|
|
1069
|
+
}
|
|
1070
|
+
// ========================================================================
|
|
1071
|
+
// OAuth Login/Logout
|
|
1072
|
+
// ========================================================================
|
|
1073
|
+
async handleLogin() {
|
|
1074
|
+
const { authStorage } = this.options;
|
|
1075
|
+
if (!authStorage) {
|
|
1076
|
+
this.showStatus(chalk.red("Auth storage not available."));
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const providers = authStorage.getProviders();
|
|
1080
|
+
if (providers.length === 0) {
|
|
1081
|
+
this.showStatus(chalk.dim("No OAuth providers registered."));
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
// Use SelectList overlay for provider selection
|
|
1085
|
+
const items = providers.map((p) => {
|
|
1086
|
+
const loggedIn = authStorage.get(p.id)?.type === "oauth" ? " (logged in)" : "";
|
|
1087
|
+
return { value: p.id, label: `${p.name}${loggedIn}` };
|
|
1088
|
+
});
|
|
1089
|
+
const selected = await this.showSelectList("Login to OAuth provider", items);
|
|
1090
|
+
if (!selected)
|
|
1091
|
+
return;
|
|
1092
|
+
const provider = providers.find((p) => p.id === selected);
|
|
1093
|
+
if (!provider)
|
|
1094
|
+
return;
|
|
1095
|
+
this.showStatus(chalk.dim(`Logging in to ${provider.name}...`));
|
|
1096
|
+
try {
|
|
1097
|
+
await authStorage.login(provider.id, {
|
|
1098
|
+
onAuth: (info) => {
|
|
1099
|
+
const lines = [chalk.bold("Open this URL in your browser:"), chalk.cyan(info.url)];
|
|
1100
|
+
if (info.instructions) {
|
|
1101
|
+
lines.push(chalk.dim(info.instructions));
|
|
1102
|
+
}
|
|
1103
|
+
this.chatContainer.addChild(new Spacer(1));
|
|
1104
|
+
this.chatContainer.addChild(new Text(lines.join("\n"), 1, 0));
|
|
1105
|
+
this.ui.requestRender();
|
|
1106
|
+
// Try to open browser
|
|
1107
|
+
try {
|
|
1108
|
+
const { execSync } = require("node:child_process");
|
|
1109
|
+
const platform = process.platform;
|
|
1110
|
+
if (platform === "darwin") {
|
|
1111
|
+
execSync(`open "${info.url}"`, { stdio: "ignore" });
|
|
1112
|
+
}
|
|
1113
|
+
else if (platform === "linux") {
|
|
1114
|
+
execSync(`xdg-open "${info.url}" 2>/dev/null || sensible-browser "${info.url}" 2>/dev/null`, {
|
|
1115
|
+
stdio: "ignore",
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
else if (platform === "win32") {
|
|
1119
|
+
execSync(`start "" "${info.url}"`, { stdio: "ignore" });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
catch {
|
|
1123
|
+
// Silently fail - user can open manually
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
onPrompt: async (promptInfo) => {
|
|
1127
|
+
// Show prompt message and wait for user input
|
|
1128
|
+
this.showStatus(chalk.dim(promptInfo.message));
|
|
1129
|
+
const answer = await this.getUserInput();
|
|
1130
|
+
return answer.trim();
|
|
1131
|
+
},
|
|
1132
|
+
onProgress: (message) => {
|
|
1133
|
+
this.showStatus(chalk.dim(message));
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
this.footer.setSubscription(this.isSubscriptionProvider());
|
|
1137
|
+
this.ui.requestRender();
|
|
1138
|
+
this.showStatus(chalk.green(`Logged in to ${provider.name}. Credentials saved.`));
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1142
|
+
if (msg !== "Login cancelled") {
|
|
1143
|
+
this.showStatus(chalk.red(`Login failed: ${msg}`));
|
|
1144
|
+
}
|
|
1145
|
+
else {
|
|
1146
|
+
this.showStatus(chalk.dim("Login cancelled."));
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async handleLogout() {
|
|
1151
|
+
const { authStorage } = this.options;
|
|
1152
|
+
if (!authStorage) {
|
|
1153
|
+
this.showStatus(chalk.red("Auth storage not available."));
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const loggedIn = authStorage
|
|
1157
|
+
.list()
|
|
1158
|
+
.filter((id) => authStorage.get(id)?.type === "oauth")
|
|
1159
|
+
.map((id) => {
|
|
1160
|
+
const provider = authStorage.getProvider(id);
|
|
1161
|
+
return { id, name: provider?.name ?? id };
|
|
1162
|
+
});
|
|
1163
|
+
if (loggedIn.length === 0) {
|
|
1164
|
+
this.showStatus(chalk.dim("No OAuth providers logged in. Use /login first."));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const items = loggedIn.map((p) => ({
|
|
1168
|
+
value: p.id,
|
|
1169
|
+
label: p.name,
|
|
1170
|
+
}));
|
|
1171
|
+
const selected = await this.showSelectList("Logout from OAuth provider", items);
|
|
1172
|
+
if (!selected)
|
|
1173
|
+
return;
|
|
1174
|
+
const entry = loggedIn.find((p) => p.id === selected);
|
|
1175
|
+
if (!entry)
|
|
1176
|
+
return;
|
|
1177
|
+
authStorage.logout(entry.id);
|
|
1178
|
+
this.showStatus(chalk.green(`Logged out of ${entry.name}.`));
|
|
1179
|
+
}
|
|
1180
|
+
// ========================================================================
|
|
1181
|
+
// Resume Session
|
|
1182
|
+
// ========================================================================
|
|
1183
|
+
/**
|
|
1184
|
+
* List session files from the session directory, sorted by modification time (newest first).
|
|
1185
|
+
* Returns metadata for each session including the first user message as a preview.
|
|
1186
|
+
*/
|
|
1187
|
+
listAvailableSessions() {
|
|
1188
|
+
const { sessionDir } = this.options;
|
|
1189
|
+
if (!sessionDir || !existsSync(sessionDir))
|
|
1190
|
+
return [];
|
|
1191
|
+
try {
|
|
1192
|
+
const files = readdirSync(sessionDir)
|
|
1193
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
1194
|
+
.map((f) => {
|
|
1195
|
+
const filePath = join(sessionDir, f);
|
|
1196
|
+
const mtime = statSync(filePath).mtime.getTime();
|
|
1197
|
+
return { name: f, path: filePath, mtime };
|
|
1198
|
+
})
|
|
1199
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
1200
|
+
const sessions = [];
|
|
1201
|
+
for (const file of files) {
|
|
1202
|
+
// Skip the current session file
|
|
1203
|
+
if (this.options.sessionManager?.getSessionFile() === file.path)
|
|
1204
|
+
continue;
|
|
1205
|
+
const preview = this.getSessionPreview(file.path);
|
|
1206
|
+
const timestamp = new Date(file.mtime).toLocaleString();
|
|
1207
|
+
sessions.push({ path: file.path, mtime: file.mtime, preview, timestamp });
|
|
1208
|
+
}
|
|
1209
|
+
return sessions;
|
|
1210
|
+
}
|
|
1211
|
+
catch {
|
|
1212
|
+
return [];
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Extract the first user message from a session file for preview.
|
|
1217
|
+
*/
|
|
1218
|
+
getSessionPreview(filePath) {
|
|
1219
|
+
try {
|
|
1220
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1221
|
+
const lines = content.trim().split("\n");
|
|
1222
|
+
for (const line of lines) {
|
|
1223
|
+
if (!line.trim())
|
|
1224
|
+
continue;
|
|
1225
|
+
try {
|
|
1226
|
+
const entry = JSON.parse(line);
|
|
1227
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
1228
|
+
const msg = entry.message;
|
|
1229
|
+
let text = "";
|
|
1230
|
+
if (typeof msg.content === "string") {
|
|
1231
|
+
text = msg.content;
|
|
1232
|
+
}
|
|
1233
|
+
else if (Array.isArray(msg.content)) {
|
|
1234
|
+
for (const block of msg.content) {
|
|
1235
|
+
if (block.type === "text" && block.text) {
|
|
1236
|
+
text = block.text;
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
// Truncate and clean up for display
|
|
1242
|
+
text = text.replace(/\n/g, " ").trim();
|
|
1243
|
+
if (text.length > 80) {
|
|
1244
|
+
text = `${text.slice(0, 77)}...`;
|
|
1245
|
+
}
|
|
1246
|
+
return text || "(empty message)";
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
// Skip malformed lines
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return "(no messages)";
|
|
1254
|
+
}
|
|
1255
|
+
catch {
|
|
1256
|
+
return "(unreadable)";
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Format a relative time string (e.g. "2 hours ago", "3 days ago").
|
|
1261
|
+
*/
|
|
1262
|
+
formatRelativeTime(mtime) {
|
|
1263
|
+
const now = Date.now();
|
|
1264
|
+
const diffMs = now - mtime;
|
|
1265
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
1266
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
1267
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
1268
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
1269
|
+
if (diffMin < 1)
|
|
1270
|
+
return "just now";
|
|
1271
|
+
if (diffMin < 60)
|
|
1272
|
+
return `${diffMin}m ago`;
|
|
1273
|
+
if (diffHour < 24)
|
|
1274
|
+
return `${diffHour}h ago`;
|
|
1275
|
+
if (diffDay < 30)
|
|
1276
|
+
return `${diffDay}d ago`;
|
|
1277
|
+
return new Date(mtime).toLocaleDateString();
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Handle the /resume command: show a list of previous sessions and load the selected one.
|
|
1281
|
+
*/
|
|
1282
|
+
async handleResume() {
|
|
1283
|
+
const sessions = this.listAvailableSessions();
|
|
1284
|
+
if (sessions.length === 0) {
|
|
1285
|
+
this.showStatus(chalk.yellow("No previous sessions found."));
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const items = sessions.map((s) => ({
|
|
1289
|
+
value: s.path,
|
|
1290
|
+
label: `${chalk.dim(this.formatRelativeTime(s.mtime))} ${s.preview}`,
|
|
1291
|
+
}));
|
|
1292
|
+
const selected = await this.showSelectList("Resume session", items);
|
|
1293
|
+
if (!selected)
|
|
1294
|
+
return;
|
|
1295
|
+
const session = sessions.find((s) => s.path === selected);
|
|
1296
|
+
if (!session)
|
|
1297
|
+
return;
|
|
1298
|
+
try {
|
|
1299
|
+
// Open the selected session
|
|
1300
|
+
const sessionDir = this.options.sessionDir;
|
|
1301
|
+
const newSessionManager = SessionManagerClass.open(selected, sessionDir);
|
|
1302
|
+
// Rebuild agent messages from session context
|
|
1303
|
+
const context = newSessionManager.buildSessionContext();
|
|
1304
|
+
this.agent.setMessages(context.messages);
|
|
1305
|
+
// Update session manager reference
|
|
1306
|
+
this.options.sessionManager = newSessionManager;
|
|
1307
|
+
// Rebuild the chat UI
|
|
1308
|
+
this.chatContainer.clear();
|
|
1309
|
+
this.rebuildChatFromSession();
|
|
1310
|
+
// Update footer tokens
|
|
1311
|
+
this.updateFooterTokens();
|
|
1312
|
+
const msgCount = context.messages.length;
|
|
1313
|
+
this.showStatus(chalk.green(`Resumed session (${msgCount} messages)`));
|
|
1314
|
+
}
|
|
1315
|
+
catch (error) {
|
|
1316
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1317
|
+
this.showStatus(chalk.red(`Failed to resume session: ${msg}`));
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
// ========================================================================
|
|
1321
|
+
// Select List (overlay pattern from pi-coding-agent)
|
|
1322
|
+
// ========================================================================
|
|
1323
|
+
showSelectList(title, items) {
|
|
1324
|
+
return new Promise((resolve) => {
|
|
1325
|
+
const container = new Container();
|
|
1326
|
+
container.addChild(new Spacer(1));
|
|
1327
|
+
container.addChild(new Text(chalk.bold.cyan(title), 1, 0));
|
|
1328
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), getSelectListTheme());
|
|
1329
|
+
selectList.onSelect = (item) => {
|
|
1330
|
+
// Restore normal UI
|
|
1331
|
+
this.pendingContainer.clear();
|
|
1332
|
+
this.editorContainer.addChild(this.editor);
|
|
1333
|
+
this.ui.setFocus(this.editor);
|
|
1334
|
+
this.ui.requestRender();
|
|
1335
|
+
resolve(item.value);
|
|
1336
|
+
};
|
|
1337
|
+
selectList.onCancel = () => {
|
|
1338
|
+
this.pendingContainer.clear();
|
|
1339
|
+
this.editorContainer.addChild(this.editor);
|
|
1340
|
+
this.ui.setFocus(this.editor);
|
|
1341
|
+
this.ui.requestRender();
|
|
1342
|
+
resolve(null);
|
|
1343
|
+
};
|
|
1344
|
+
container.addChild(selectList);
|
|
1345
|
+
container.addChild(new Text(chalk.dim("↑↓ navigate • enter select • esc cancel"), 1, 0));
|
|
1346
|
+
container.addChild(new Spacer(1));
|
|
1347
|
+
// Replace editor area with select list
|
|
1348
|
+
this.editorContainer.clear();
|
|
1349
|
+
this.pendingContainer.clear();
|
|
1350
|
+
this.pendingContainer.addChild(container);
|
|
1351
|
+
this.ui.setFocus(selectList);
|
|
1352
|
+
this.ui.requestRender();
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
// ========================================================================
|
|
1356
|
+
// Status & Utilities
|
|
1357
|
+
// ========================================================================
|
|
1358
|
+
showStatus(text) {
|
|
1359
|
+
this.chatContainer.addChild(new Spacer(1));
|
|
1360
|
+
this.chatContainer.addChild(new Text(text, 1, 0));
|
|
1361
|
+
this.ui.requestRender();
|
|
1362
|
+
}
|
|
1363
|
+
updateEditorBorderColor() {
|
|
1364
|
+
this.editorTheme.borderColor = this.isBashMode ? (s) => chalk.yellow(s) : (s) => chalk.gray(s);
|
|
1365
|
+
this.ui.requestRender();
|
|
1366
|
+
}
|
|
1367
|
+
shutdown() {
|
|
1368
|
+
this.ui.stop();
|
|
1369
|
+
console.log(chalk.dim("\nGoodbye."));
|
|
1370
|
+
process.exit(0);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// ============================================================================
|
|
1374
|
+
// Helpers
|
|
1375
|
+
// ============================================================================
|
|
1376
|
+
function buildUserMessage(text, imageParts) {
|
|
1377
|
+
const content = [{ type: "text", text }];
|
|
1378
|
+
if (imageParts && imageParts.length > 0) {
|
|
1379
|
+
content.push(...imageParts);
|
|
1380
|
+
}
|
|
1381
|
+
return [{ role: "user", content }];
|
|
1382
|
+
}
|
|
1383
|
+
function extractTextFromMessage(msg) {
|
|
1384
|
+
if (msg.role === "user") {
|
|
1385
|
+
const content = msg.content;
|
|
1386
|
+
if (typeof content === "string")
|
|
1387
|
+
return content;
|
|
1388
|
+
if (Array.isArray(content)) {
|
|
1389
|
+
return content
|
|
1390
|
+
.filter((c) => c.type === "text")
|
|
1391
|
+
.map((c) => c.text)
|
|
1392
|
+
.join("");
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
return "";
|
|
1396
|
+
}
|
|
1397
|
+
//# sourceMappingURL=interactive-mode.js.map
|