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.
Files changed (117) hide show
  1. package/dist/auth/anthropic-oauth.d.ts +10 -0
  2. package/dist/auth/anthropic-oauth.d.ts.map +1 -0
  3. package/dist/auth/anthropic-oauth.js +97 -0
  4. package/dist/auth/anthropic-oauth.js.map +1 -0
  5. package/dist/auth/auth-storage.d.ts +46 -0
  6. package/dist/auth/auth-storage.d.ts.map +1 -0
  7. package/dist/auth/auth-storage.js +213 -0
  8. package/dist/auth/auth-storage.js.map +1 -0
  9. package/dist/auth/github-copilot-oauth.d.ts +8 -0
  10. package/dist/auth/github-copilot-oauth.d.ts.map +1 -0
  11. package/dist/auth/github-copilot-oauth.js +131 -0
  12. package/dist/auth/github-copilot-oauth.js.map +1 -0
  13. package/dist/auth/index.d.ts +6 -0
  14. package/dist/auth/index.d.ts.map +1 -0
  15. package/dist/auth/index.js +5 -0
  16. package/dist/auth/index.js.map +1 -0
  17. package/dist/auth/openai-codex-oauth.d.ts +8 -0
  18. package/dist/auth/openai-codex-oauth.d.ts.map +1 -0
  19. package/dist/auth/openai-codex-oauth.js +131 -0
  20. package/dist/auth/openai-codex-oauth.js.map +1 -0
  21. package/dist/auth/types.d.ts +41 -0
  22. package/dist/auth/types.d.ts.map +1 -0
  23. package/dist/auth/types.js +5 -0
  24. package/dist/auth/types.js.map +1 -0
  25. package/dist/cli/args.d.ts +35 -0
  26. package/dist/cli/args.d.ts.map +1 -0
  27. package/dist/cli/args.js +191 -0
  28. package/dist/cli/args.js.map +1 -0
  29. package/dist/cli.d.ts +3 -0
  30. package/dist/cli.d.ts.map +1 -0
  31. package/dist/cli.js +8 -0
  32. package/dist/cli.js.map +1 -0
  33. package/dist/context.d.ts +16 -0
  34. package/dist/context.d.ts.map +1 -0
  35. package/dist/context.js +38 -0
  36. package/dist/context.js.map +1 -0
  37. package/dist/main.d.ts +8 -0
  38. package/dist/main.d.ts.map +1 -0
  39. package/dist/main.js +313 -0
  40. package/dist/main.js.map +1 -0
  41. package/dist/model-factory.d.ts +45 -0
  42. package/dist/model-factory.d.ts.map +1 -0
  43. package/dist/model-factory.js +175 -0
  44. package/dist/model-factory.js.map +1 -0
  45. package/dist/modes/interactive/bash-helpers.d.ts +31 -0
  46. package/dist/modes/interactive/bash-helpers.d.ts.map +1 -0
  47. package/dist/modes/interactive/bash-helpers.js +68 -0
  48. package/dist/modes/interactive/bash-helpers.js.map +1 -0
  49. package/dist/modes/interactive/components/assistant-message.d.ts +19 -0
  50. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -0
  51. package/dist/modes/interactive/components/assistant-message.js +54 -0
  52. package/dist/modes/interactive/components/assistant-message.js.map +1 -0
  53. package/dist/modes/interactive/components/bash-execution.d.ts +18 -0
  54. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -0
  55. package/dist/modes/interactive/components/bash-execution.js +77 -0
  56. package/dist/modes/interactive/components/bash-execution.js.map +1 -0
  57. package/dist/modes/interactive/components/compaction-summary.d.ts +18 -0
  58. package/dist/modes/interactive/components/compaction-summary.d.ts.map +1 -0
  59. package/dist/modes/interactive/components/compaction-summary.js +45 -0
  60. package/dist/modes/interactive/components/compaction-summary.js.map +1 -0
  61. package/dist/modes/interactive/components/footer.d.ts +20 -0
  62. package/dist/modes/interactive/components/footer.d.ts.map +1 -0
  63. package/dist/modes/interactive/components/footer.js +82 -0
  64. package/dist/modes/interactive/components/footer.js.map +1 -0
  65. package/dist/modes/interactive/components/tool-execution.d.ts +30 -0
  66. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -0
  67. package/dist/modes/interactive/components/tool-execution.js +133 -0
  68. package/dist/modes/interactive/components/tool-execution.js.map +1 -0
  69. package/dist/modes/interactive/components/user-message.d.ts +9 -0
  70. package/dist/modes/interactive/components/user-message.d.ts.map +1 -0
  71. package/dist/modes/interactive/components/user-message.js +17 -0
  72. package/dist/modes/interactive/components/user-message.js.map +1 -0
  73. package/dist/modes/interactive/interactive-mode.d.ts +49 -0
  74. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -0
  75. package/dist/modes/interactive/interactive-mode.js +1397 -0
  76. package/dist/modes/interactive/interactive-mode.js.map +1 -0
  77. package/dist/modes/interactive/theme.d.ts +26 -0
  78. package/dist/modes/interactive/theme.d.ts.map +1 -0
  79. package/dist/modes/interactive/theme.js +64 -0
  80. package/dist/modes/interactive/theme.js.map +1 -0
  81. package/dist/modes/interactive-mode.d.ts +5 -0
  82. package/dist/modes/interactive-mode.d.ts.map +1 -0
  83. package/dist/modes/interactive-mode.js +5 -0
  84. package/dist/modes/interactive-mode.js.map +1 -0
  85. package/dist/modes/print-mode.d.ts +20 -0
  86. package/dist/modes/print-mode.d.ts.map +1 -0
  87. package/dist/modes/print-mode.js +56 -0
  88. package/dist/modes/print-mode.js.map +1 -0
  89. package/dist/prompts.d.ts +53 -0
  90. package/dist/prompts.d.ts.map +1 -0
  91. package/dist/prompts.js +132 -0
  92. package/dist/prompts.js.map +1 -0
  93. package/dist/settings.d.ts +34 -0
  94. package/dist/settings.d.ts.map +1 -0
  95. package/dist/settings.js +73 -0
  96. package/dist/settings.js.map +1 -0
  97. package/dist/skills.d.ts +51 -0
  98. package/dist/skills.d.ts.map +1 -0
  99. package/dist/skills.js +304 -0
  100. package/dist/skills.js.map +1 -0
  101. package/dist/utils/bash-executor.d.ts +32 -0
  102. package/dist/utils/bash-executor.d.ts.map +1 -0
  103. package/dist/utils/bash-executor.js +166 -0
  104. package/dist/utils/bash-executor.js.map +1 -0
  105. package/dist/utils/clipboard-image.d.ts +24 -0
  106. package/dist/utils/clipboard-image.d.ts.map +1 -0
  107. package/dist/utils/clipboard-image.js +211 -0
  108. package/dist/utils/clipboard-image.js.map +1 -0
  109. package/dist/utils/find-fd.d.ts +12 -0
  110. package/dist/utils/find-fd.d.ts.map +1 -0
  111. package/dist/utils/find-fd.js +33 -0
  112. package/dist/utils/find-fd.js.map +1 -0
  113. package/dist/utils/frontmatter.d.ts +7 -0
  114. package/dist/utils/frontmatter.d.ts.map +1 -0
  115. package/dist/utils/frontmatter.js +25 -0
  116. package/dist/utils/frontmatter.js.map +1 -0
  117. 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