pzero-operator 0.1.6 → 0.1.8

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.
@@ -6,7 +6,7 @@ import * as crypto from "node:crypto";
6
6
  import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
- import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth, } from "@operator/tui";
9
+ import { CombinedAutocompleteProvider, Container, fuzzyFilter, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, truncateToWidth, visibleWidth, } from "@operator/tui";
10
10
  import { spawn, spawnSync } from "child_process";
11
11
  import { APP_NAME, getAgentDir, getDebugLogPath, getShareViewerUrl, getUpdateInstruction, VERSION, } from "../../config.js";
12
12
  import { parseSkillBlock } from "../../core/agent-session.js";
@@ -24,6 +24,7 @@ import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/cha
24
24
  import { copyToClipboard } from "../../utils/clipboard.js";
25
25
  import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
26
26
  import { parseGitUrl } from "../../utils/git.js";
27
+ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
27
28
  import { killTrackedDetachedChildren } from "../../utils/shell.js";
28
29
  import { ensureTool } from "../../utils/tools-manager.js";
29
30
  import { ArminComponent } from "./components/armin.js";
@@ -89,6 +90,133 @@ class StartupHeroText extends Text {
89
90
  clearInterval(this.interval);
90
91
  }
91
92
  }
93
+ function fitLine(line, width) {
94
+ const fitted = truncateToWidth(line ?? "", Math.max(0, width), theme.fg("dim", "..."));
95
+ const padding = Math.max(0, width - visibleWidth(fitted));
96
+ return fitted + " ".repeat(padding);
97
+ }
98
+ function framedLines(lines, width, title = " OPERATOR ") {
99
+ if (width < 8)
100
+ return lines;
101
+ const innerWidth = Math.max(1, width - 4);
102
+ const titleText = truncateToWidth(title, Math.max(0, innerWidth - 2), "");
103
+ const topFill = Math.max(0, width - 3 - visibleWidth(titleText));
104
+ const top = theme.fg("borderAccent", "┌") + theme.bold(theme.fg("accent", titleText)) + theme.fg("borderAccent", "─".repeat(topFill) + "┐");
105
+ const bottom = theme.fg("borderAccent", "└" + "─".repeat(width - 2) + "┘");
106
+ return [
107
+ top,
108
+ ...lines.map((line) => theme.fg("borderAccent", "│ ") + fitLine(line, innerWidth) + theme.fg("borderAccent", " │")),
109
+ bottom,
110
+ ];
111
+ }
112
+ function formatTokensCompact(count) {
113
+ if (!count)
114
+ return "0";
115
+ if (count < 1000)
116
+ return String(count);
117
+ if (count < 10000)
118
+ return `${(count / 1000).toFixed(1)}k`;
119
+ if (count < 1000000)
120
+ return `${Math.round(count / 1000)}k`;
121
+ return `${(count / 1000000).toFixed(1)}M`;
122
+ }
123
+ function renderGauge(value, width) {
124
+ const pct = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
125
+ const slots = Math.max(6, Math.min(18, width - 10));
126
+ const filled = Math.round((slots * pct) / 100);
127
+ return `[${theme.fg("borderAccent", "█".repeat(filled))}${theme.fg("dim", "░".repeat(slots - filled))}] ${pct.toFixed(1)}%`;
128
+ }
129
+ function getSpinnerFrame(frame) {
130
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
131
+ return frames[frame % frames.length] || "•";
132
+ }
133
+ function formatReasoningLevel(level) {
134
+ const normalized = level === "low" || level === "medium" || level === "high" ? level : "off";
135
+ if (normalized === "low")
136
+ return `${theme.fg("success", "[LOW]")} ${theme.fg("success", "•")} ${theme.fg("dim", "light analysis")}`;
137
+ if (normalized === "medium")
138
+ return `${theme.fg("warning", "[MEDIUM]")} ${theme.fg("warning", "••")} ${theme.fg("dim", "balanced depth")}`;
139
+ if (normalized === "high")
140
+ return `${theme.fg("error", "[HIGH]")} ${theme.fg("error", "•••")} ${theme.fg("dim", "deep reasoning")}`;
141
+ return `${theme.fg("accent", "[FAST]")} ${theme.fg("accent", "⚡")} ${theme.fg("dim", "direct output")}`;
142
+ }
143
+ function formatReasoningLegend() {
144
+ return [
145
+ `${theme.fg("accent", "FAST")} ${theme.fg("dim", "⚡")}`,
146
+ `${theme.fg("success", "LOW")} ${theme.fg("success", "•")}`,
147
+ `${theme.fg("warning", "MED")} ${theme.fg("warning", "••")}`,
148
+ `${theme.fg("error", "HIGH")} ${theme.fg("error", "•••")}`,
149
+ ].join(theme.fg("dim", " "));
150
+ }
151
+ function displayThinkingLevel(level) {
152
+ return level === "off" ? "fast" : level;
153
+ }
154
+ class OperatorWorkspaceLayout {
155
+ mode;
156
+ frame = 0;
157
+ interval;
158
+ constructor(mode) {
159
+ this.mode = mode;
160
+ this.interval = setInterval(() => {
161
+ this.frame = (this.frame + 1) % 24;
162
+ this.mode.ui.requestRender();
163
+ }, 380);
164
+ this.interval.unref?.();
165
+ }
166
+ dispose() {
167
+ clearInterval(this.interval);
168
+ }
169
+ render(width) {
170
+ const terminalHeight = this.mode.ui.terminal.rows || 30;
171
+ const headerHeight = this.mode.headerContainer.render(width).length;
172
+ const outerFrame = width >= 72;
173
+ const contentWidth = outerFrame ? Math.max(1, width - 4) : width;
174
+ const maxRows = Math.max(8, terminalHeight - headerHeight - (outerFrame ? 2 : 0));
175
+ const renderBody = (bodyLines) => outerFrame ? framedLines(bodyLines, width, this.mode.fullHistoryView ? " OPERATOR / FULL CHAT " : " OPERATOR / WORKSPACE ") : bodyLines;
176
+ if (contentWidth < 96) {
177
+ const leftLines = this.clampLeftLines(this.renderLeft(contentWidth), maxRows);
178
+ return renderBody([
179
+ ...leftLines,
180
+ theme.fg("borderMuted", "─".repeat(Math.max(0, contentWidth))),
181
+ ...this.mode.renderOperatorSidebar(contentWidth).slice(0, Math.max(0, maxRows - leftLines.length - 1)),
182
+ ]);
183
+ }
184
+ const gutter = 3;
185
+ const leftWidth = Math.max(64, Math.floor(contentWidth * 0.65) - 1);
186
+ const rightWidth = Math.max(28, contentWidth - leftWidth - gutter);
187
+ const separator = theme.fg("borderMuted", " │ ");
188
+ const leftLines = this.clampLeftLines(this.renderLeft(leftWidth), maxRows);
189
+ const rightLines = this.mode.renderOperatorSidebar(rightWidth).slice(0, maxRows);
190
+ const rowCount = this.mode.fullHistoryView ? Math.max(maxRows, leftLines.length) : maxRows;
191
+ const rightStart = Math.max(0, rowCount - maxRows);
192
+ const lines = [];
193
+ for (let i = 0; i < rowCount; i++) {
194
+ const rightIndex = this.mode.fullHistoryView ? i - rightStart : i;
195
+ lines.push(fitLine(leftLines[i] ?? "", leftWidth) + separator + fitLine(rightLines[rightIndex] ?? "", rightWidth));
196
+ }
197
+ return renderBody(lines);
198
+ }
199
+ renderLeft(width) {
200
+ const components = [
201
+ this.mode.chatContainer,
202
+ this.mode.pendingMessagesContainer,
203
+ this.mode.statusContainer,
204
+ this.mode.widgetContainerAbove,
205
+ this.mode.editorDeckContainer,
206
+ this.mode.editorContainer,
207
+ this.mode.widgetContainerBelow,
208
+ ];
209
+ return components.flatMap((component) => component.render(width));
210
+ }
211
+ clampLeftLines(lines, maxRows) {
212
+ if (this.mode.fullHistoryView)
213
+ return lines;
214
+ if (lines.length <= maxRows)
215
+ return lines;
216
+ const marker = theme.fg("dim", `... ${lines.length - maxRows + 1} earlier lines hidden in current view ...`);
217
+ return [marker, ...lines.slice(-(maxRows - 1))];
218
+ }
219
+ }
92
220
  function isUnknownModel(model) {
93
221
  return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
94
222
  }
@@ -126,6 +254,7 @@ export class InteractiveMode {
126
254
  fdPath;
127
255
  editorDeckContainer;
128
256
  editorContainer;
257
+ workspaceLayout;
129
258
  footer;
130
259
  footerDataProvider;
131
260
  // Stored so the same manager can be injected into custom editors, selectors, and extension UI.
@@ -155,6 +284,7 @@ export class InteractiveMode {
155
284
  isFirstUserMessage = true;
156
285
  // Tool output expansion state
157
286
  toolOutputExpanded = false;
287
+ fullHistoryView = false;
158
288
  // Thinking block visibility state
159
289
  hideThinkingBlock = false;
160
290
  // Skill commands: command name -> skill file path
@@ -177,6 +307,7 @@ export class InteractiveMode {
177
307
  retryEscapeHandler;
178
308
  // Messages queued while compaction is running
179
309
  compactionQueuedMessages = [];
310
+ pendingImageAttachments = [];
180
311
  // Shutdown state
181
312
  shutdownRequested = false;
182
313
  // Extension UI state
@@ -238,6 +369,7 @@ export class InteractiveMode {
238
369
  this.editorDeckContainer = new Container();
239
370
  this.editorContainer = new Container();
240
371
  this.editorContainer.addChild(this.editor);
372
+ this.workspaceLayout = new OperatorWorkspaceLayout(this);
241
373
  this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
242
374
  this.footer = new FooterComponent(this.session, this.footerDataProvider);
243
375
  this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
@@ -472,15 +604,8 @@ export class InteractiveMode {
472
604
  this.builtInHeader = new Text("", 0, 0);
473
605
  this.headerContainer.addChild(this.builtInHeader);
474
606
  }
475
- this.ui.addChild(this.chatContainer);
476
- this.ui.addChild(this.pendingMessagesContainer);
477
- this.ui.addChild(this.statusContainer);
478
607
  this.renderWidgets(); // Initialize with default spacer
479
- this.ui.addChild(this.widgetContainerAbove);
480
- this.ui.addChild(this.editorDeckContainer);
481
- this.ui.addChild(this.editorContainer);
482
- this.ui.addChild(this.widgetContainerBelow);
483
- this.ui.addChild(this.footer);
608
+ this.ui.addChild(this.workspaceLayout);
484
609
  this.ui.setFocus(this.editor);
485
610
  this.setupKeyHandlers();
486
611
  this.setupEditorSubmitHandler();
@@ -591,7 +716,9 @@ export class InteractiveMode {
591
716
  continue;
592
717
  }
593
718
  this.dismissStartupHero();
594
- await this.session.prompt(userInput);
719
+ const text = typeof userInput === "string" ? userInput : userInput.text;
720
+ const images = typeof userInput === "string" ? undefined : userInput.images;
721
+ await this.session.prompt(text, { images });
595
722
  }
596
723
  catch (error) {
597
724
  const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
@@ -1512,22 +1639,12 @@ export class InteractiveMode {
1512
1639
  if (this.customFooter?.dispose) {
1513
1640
  this.customFooter.dispose();
1514
1641
  }
1515
- // Remove current footer from UI
1516
- if (this.customFooter) {
1517
- this.ui.removeChild(this.customFooter);
1518
- }
1519
- else {
1520
- this.ui.removeChild(this.footer);
1521
- }
1522
1642
  if (factory) {
1523
- // Create and add custom footer, passing the data provider
1643
+ // Custom footer is rendered inside the right sidebar.
1524
1644
  this.customFooter = factory(this.ui, theme, this.footerDataProvider);
1525
- this.ui.addChild(this.customFooter);
1526
1645
  }
1527
1646
  else {
1528
- // Restore built-in footer
1529
1647
  this.customFooter = undefined;
1530
- this.ui.addChild(this.footer);
1531
1648
  }
1532
1649
  this.ui.requestRender();
1533
1650
  }
@@ -1981,6 +2098,7 @@ export class InteractiveMode {
1981
2098
  this.ui.onDebug = () => this.handleDebugCommand();
1982
2099
  this.defaultEditor.onAction("app.model.select", () => this.showModelSelector());
1983
2100
  this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
2101
+ this.defaultEditor.onAction("app.history.full", () => this.toggleFullHistoryView());
1984
2102
  this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
1985
2103
  this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
1986
2104
  this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
@@ -2007,14 +2125,15 @@ export class InteractiveMode {
2007
2125
  if (!image) {
2008
2126
  return;
2009
2127
  }
2010
- // Write to temp file
2011
- const tmpDir = os.tmpdir();
2012
2128
  const ext = extensionForImageMimeType(image.mimeType) ?? "png";
2013
- const fileName = `operator-clipboard-${crypto.randomUUID()}.${ext}`;
2014
- const filePath = path.join(tmpDir, fileName);
2015
- fs.writeFileSync(filePath, Buffer.from(image.bytes));
2016
- // Insert file path directly
2017
- this.editor.insertTextAtCursor?.(filePath);
2129
+ this.pendingImageAttachments.push({
2130
+ type: "image",
2131
+ data: Buffer.from(image.bytes).toString("base64"),
2132
+ mimeType: image.mimeType,
2133
+ filename: `clipboard-${crypto.randomUUID()}.${ext}`,
2134
+ });
2135
+ this.showStatus(`Image attached (${this.pendingImageAttachments.length})`);
2136
+ this.updateEditorDeck();
2018
2137
  this.ui.requestRender();
2019
2138
  }
2020
2139
  catch {
@@ -2024,7 +2143,9 @@ export class InteractiveMode {
2024
2143
  setupEditorSubmitHandler() {
2025
2144
  this.defaultEditor.onSubmit = async (text) => {
2026
2145
  text = text.trim();
2027
- if (!text)
2146
+ const { text: normalizedText, images } = await this.preparePromptPayload(text);
2147
+ text = normalizedText;
2148
+ if (!text && !images?.length)
2028
2149
  return;
2029
2150
  if (DISABLED_SLASH_COMMANDS.has(text) || Array.from(DISABLED_SLASH_COMMANDS).some((command) => text.startsWith(`${command} `))) {
2030
2151
  this.editor.setText("");
@@ -2059,28 +2180,30 @@ export class InteractiveMode {
2059
2180
  }
2060
2181
  const value = text.startsWith("/thinking ") ? text.slice(10).trim().toLowerCase() : "";
2061
2182
  if (!value) {
2062
- const selected = await this.showExtensionSelector("Select analysis depth", ["low", "medium", "high"]);
2183
+ const selected = await this.showExtensionSelector("Select analysis depth", ["fast", "low", "medium", "high"]);
2063
2184
  if (!selected) {
2064
2185
  this.showStatus("Analysis depth selection cancelled");
2065
2186
  return;
2066
2187
  }
2067
- this.session.setThinkingLevel(selected);
2188
+ this.session.setThinkingLevel(selected === "fast" ? "off" : selected);
2068
2189
  const after = this.session.thinkingLevel || "off";
2069
2190
  this.footer.invalidate();
2070
2191
  this.updateEditorBorderColor();
2071
- this.showStatus(`Analysis depth: ${after}`);
2192
+ this.showStatus(`Analysis depth: ${displayThinkingLevel(after)}`);
2072
2193
  return;
2073
2194
  }
2074
- if (!["low", "medium", "high"].includes(value)) {
2075
- this.showStatus("Use /thinking low, /thinking medium, or /thinking high");
2195
+ if (!["fast", "low", "medium", "high"].includes(value)) {
2196
+ this.showStatus("Use /thinking fast, /thinking low, /thinking medium, or /thinking high");
2076
2197
  return;
2077
2198
  }
2078
2199
  const before = this.session.thinkingLevel || "off";
2079
- this.session.setThinkingLevel(value);
2200
+ this.session.setThinkingLevel(value === "fast" ? "off" : value);
2080
2201
  const after = this.session.thinkingLevel || "off";
2081
2202
  this.footer.invalidate();
2082
2203
  this.updateEditorBorderColor();
2083
- this.showStatus(before === after ? `Analysis depth unchanged: ${after}` : `Analysis depth: ${after}`);
2204
+ this.showStatus(before === after
2205
+ ? `Analysis depth unchanged: ${displayThinkingLevel(after)}`
2206
+ : `Analysis depth: ${displayThinkingLevel(after)}`);
2084
2207
  return;
2085
2208
  }
2086
2209
  if (text === "/copy") {
@@ -2145,7 +2268,7 @@ export class InteractiveMode {
2145
2268
  const command = isExcluded ? text.slice(2).trim() : text.slice(1).trim();
2146
2269
  if (command) {
2147
2270
  if (this.session.isBashRunning) {
2148
- this.showWarning("A bash command is already running. Press Ctrl+C to cancel it first.");
2271
+ this.showWarning("A shell command is already running. Press Ctrl+C to cancel it first.");
2149
2272
  this.editor.setText(text);
2150
2273
  return;
2151
2274
  }
@@ -2167,10 +2290,10 @@ export class InteractiveMode {
2167
2290
  if (this.isExtensionCommand(text)) {
2168
2291
  this.editor.addToHistory?.(text);
2169
2292
  this.editor.setText("");
2170
- await this.session.prompt(text);
2293
+ await this.session.prompt(text, { images });
2171
2294
  }
2172
2295
  else {
2173
- this.queueCompactionMessage(text, "steer");
2296
+ this.queueCompactionMessage(text, "steer", images);
2174
2297
  }
2175
2298
  return;
2176
2299
  }
@@ -2179,7 +2302,7 @@ export class InteractiveMode {
2179
2302
  if (this.session.isStreaming) {
2180
2303
  this.editor.addToHistory?.(text);
2181
2304
  this.editor.setText("");
2182
- await this.session.prompt(text, { streamingBehavior: "steer" });
2305
+ await this.session.prompt(text, { streamingBehavior: "steer", images });
2183
2306
  this.updatePendingMessagesDisplay();
2184
2307
  this.ui.requestRender();
2185
2308
  return;
@@ -2188,9 +2311,11 @@ export class InteractiveMode {
2188
2311
  // First, move any pending bash components to chat
2189
2312
  this.flushPendingBashComponents();
2190
2313
  if (this.onInputCallback) {
2191
- this.onInputCallback(text);
2314
+ this.onInputCallback({ text, images });
2192
2315
  }
2193
2316
  this.editor.addToHistory?.(text);
2317
+ this.pendingImageAttachments = [];
2318
+ this.updateEditorDeck();
2194
2319
  };
2195
2320
  }
2196
2321
  subscribeToAgent() {
@@ -2206,6 +2331,7 @@ export class InteractiveMode {
2206
2331
  switch (event.type) {
2207
2332
  case "agent_start":
2208
2333
  this.ui.terminal.setProgress(true);
2334
+ this.fullHistoryView = false;
2209
2335
  // Restore main escape handler if retry handler is still active
2210
2336
  // (retry success event fires later, but we need main handler now)
2211
2337
  if (this.retryEscapeHandler) {
@@ -2483,7 +2609,11 @@ export class InteractiveMode {
2483
2609
  const textBlocks = typeof message.content === "string"
2484
2610
  ? [{ type: "text", text: message.content }]
2485
2611
  : message.content.filter((c) => c.type === "text");
2486
- return textBlocks.map((c) => c.text).join("");
2612
+ const imageCount = typeof message.content === "string"
2613
+ ? 0
2614
+ : message.content.filter((c) => c.type === "image").length;
2615
+ const text = textBlocks.map((c) => c.text).join("");
2616
+ return imageCount > 0 ? `${text}\n\n[Attached images: ${imageCount}]` : text;
2487
2617
  }
2488
2618
  /**
2489
2619
  * Show a status message in the chat.
@@ -2670,6 +2800,50 @@ export class InteractiveMode {
2670
2800
  };
2671
2801
  });
2672
2802
  }
2803
+ async buildImageAttachmentsFromPaths(text) {
2804
+ const attachments = [];
2805
+ const keptLines = [];
2806
+ const lines = text.split("\n");
2807
+ for (const line of lines) {
2808
+ const candidate = line.trim();
2809
+ if (!candidate) {
2810
+ keptLines.push(line);
2811
+ continue;
2812
+ }
2813
+ const resolvedPath = path.isAbsolute(candidate)
2814
+ ? candidate
2815
+ : path.resolve(this.sessionManager.getCwd(), candidate);
2816
+ if (!fs.existsSync(resolvedPath)) {
2817
+ keptLines.push(line);
2818
+ continue;
2819
+ }
2820
+ const mimeType = await detectSupportedImageMimeTypeFromFile(resolvedPath).catch(() => null);
2821
+ if (!mimeType) {
2822
+ keptLines.push(line);
2823
+ continue;
2824
+ }
2825
+ attachments.push({
2826
+ type: "image",
2827
+ data: fs.readFileSync(resolvedPath).toString("base64"),
2828
+ mimeType,
2829
+ filename: path.basename(resolvedPath),
2830
+ });
2831
+ }
2832
+ return {
2833
+ text: keptLines.join("\n").trim(),
2834
+ images: attachments,
2835
+ };
2836
+ }
2837
+ async preparePromptPayload(text) {
2838
+ const pathAttachments = await this.buildImageAttachmentsFromPaths(text);
2839
+ const images = [...this.pendingImageAttachments, ...pathAttachments.images];
2840
+ this.pendingImageAttachments = [];
2841
+ this.updateEditorDeck();
2842
+ return {
2843
+ text: pathAttachments.text || text,
2844
+ images: images.length > 0 ? images : undefined,
2845
+ };
2846
+ }
2673
2847
  rebuildChatFromMessages() {
2674
2848
  this.chatContainer.clear();
2675
2849
  const context = this.sessionManager.buildSessionContext();
@@ -2779,33 +2953,36 @@ export class InteractiveMode {
2779
2953
  }
2780
2954
  async handleFollowUp() {
2781
2955
  const text = (this.editor.getExpandedText?.() ?? this.editor.getText()).trim();
2782
- if (!text)
2956
+ if (!text && this.pendingImageAttachments.length === 0)
2957
+ return;
2958
+ if (!this.session.isStreaming && !this.session.isCompacting) {
2959
+ if (this.editor.onSubmit) {
2960
+ this.editor.onSubmit(text);
2961
+ }
2783
2962
  return;
2963
+ }
2964
+ const payload = await this.preparePromptPayload(text);
2784
2965
  // Queue input during compaction (extension commands execute immediately)
2785
2966
  if (this.session.isCompacting) {
2786
- if (this.isExtensionCommand(text)) {
2787
- this.editor.addToHistory?.(text);
2967
+ if (this.isExtensionCommand(payload.text)) {
2968
+ this.editor.addToHistory?.(payload.text);
2788
2969
  this.editor.setText("");
2789
- await this.session.prompt(text);
2970
+ await this.session.prompt(payload.text, { images: payload.images });
2790
2971
  }
2791
2972
  else {
2792
- this.queueCompactionMessage(text, "followUp");
2973
+ this.queueCompactionMessage(payload.text, "followUp", payload.images);
2793
2974
  }
2794
2975
  return;
2795
2976
  }
2796
2977
  // Alt+Enter queues a follow-up message (waits until agent finishes)
2797
2978
  // This handles extension commands (execute immediately), prompt template expansion, and queueing
2798
2979
  if (this.session.isStreaming) {
2799
- this.editor.addToHistory?.(text);
2980
+ this.editor.addToHistory?.(payload.text);
2800
2981
  this.editor.setText("");
2801
- await this.session.prompt(text, { streamingBehavior: "followUp" });
2982
+ await this.session.prompt(payload.text, { streamingBehavior: "followUp", images: payload.images });
2802
2983
  this.updatePendingMessagesDisplay();
2803
2984
  this.ui.requestRender();
2804
2985
  }
2805
- // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
2806
- else if (this.editor.onSubmit) {
2807
- this.editor.onSubmit(text);
2808
- }
2809
2986
  }
2810
2987
  handleDequeue() {
2811
2988
  const restored = this.restoreQueuedMessagesToEditor();
@@ -2829,17 +3006,105 @@ export class InteractiveMode {
2829
3006
  }
2830
3007
  updateEditorDeck() {
2831
3008
  this.editorDeckContainer.clear();
2832
- const modeLabel = this.isBashMode ? "shell lane" : "prompt lane";
2833
- const stateLabel = this.isInterruptibleWorkActive()
2834
- ? "live processing • ctrl+c interrupt"
2835
- : `ready • ${keyText("tui.input.submit")} send`;
3009
+ const modeLabel = this.isBashMode ? "shell" : "prompt";
3010
+ const stateLabel = this.isInterruptibleWorkActive() ? "active" : "ready";
3011
+ const imageLabel = this.pendingImageAttachments.length > 0
3012
+ ? theme.fg("warning", `${this.pendingImageAttachments.length} image${this.pendingImageAttachments.length > 1 ? "s" : ""}`)
3013
+ : "";
2836
3014
  const hintLabel = this.isBashMode
2837
- ? `! shell${keyText("app.clear")} interrupt • ${keyText("app.model.select")} engines`
2838
- : `/ commands • ${keyText("app.thinking.cycle")} analysis • ${keyText("app.model.select")} engines • ${keyText("app.editor.external")} external editor`;
2839
- this.editorDeckContainer.addChild(new Text(theme.bold(theme.fg("accent", `INPUT DECK // ${modeLabel}`)), 0, 0));
2840
- this.editorDeckContainer.addChild(new Text(theme.fg("dim", `${stateLabel} • ${hintLabel}`), 0, 0));
3015
+ ? theme.fg("dim", `! shell | ${keyText("app.clear")} stop`)
3016
+ : theme.fg("dim", `/ cmd | ${keyText("app.history.full")} full | ${keyText("app.thinking.cycle")} `) +
3017
+ formatReasoningLegend() +
3018
+ theme.fg("dim", ` | ${keyText("app.model.select")} model`);
3019
+ this.editorDeckContainer.addChild(new TruncatedText(theme.bold(theme.fg(this.isBashMode ? "warning" : "accent", `INPUT ${modeLabel}`)) +
3020
+ theme.fg("dim", " | ") +
3021
+ theme.fg(this.isInterruptibleWorkActive() ? "warning" : "success", stateLabel) +
3022
+ (imageLabel ? theme.fg("dim", " | ") + imageLabel : "") +
3023
+ theme.fg("dim", " | ") +
3024
+ hintLabel, 0, 0));
2841
3025
  this.editorDeckContainer.addChild(new Spacer(1));
2842
3026
  }
3027
+ renderOperatorSidebar(width) {
3028
+ const state = this.session.state;
3029
+ const contextUsage = this.session.getContextUsage();
3030
+ const contextWindow = contextUsage?.contextWindow ?? state.model?.contextWindow ?? 0;
3031
+ const contextPercent = contextUsage?.percent ?? 0;
3032
+ let totalInput = 0;
3033
+ let totalOutput = 0;
3034
+ let totalCacheRead = 0;
3035
+ for (const entry of this.session.sessionManager.getEntries()) {
3036
+ if (entry.type === "message" && entry.message.role === "assistant") {
3037
+ totalInput += entry.message.usage.input;
3038
+ totalOutput += entry.message.usage.output;
3039
+ totalCacheRead += entry.message.usage.cacheRead;
3040
+ }
3041
+ }
3042
+ const cwd = this.session.sessionManager.getCwd().replace(process.env.HOME || "", "~");
3043
+ const sessionName = this.session.sessionManager.getSessionName() || "unnamed";
3044
+ const modelName = state.model?.id || "no-model";
3045
+ const providerName = state.model?.provider || "none";
3046
+ const thinking = state.model?.reasoning ? this.session.thinkingLevel || "off" : "off";
3047
+ const reasoningText = formatReasoningLevel(thinking);
3048
+ const active = this.isInterruptibleWorkActive();
3049
+ const activeTools = this.pendingTools.size;
3050
+ const queued = this.session.pendingMessageCount || 0;
3051
+ const activeToolNames = this.session.getActiveToolNames?.() || [];
3052
+ const toolText = activeToolNames.length ? activeToolNames.slice(0, 6).join(", ") : "read, bash, edit, write";
3053
+ const pulse = active ? ["●", "●", "●", "○"][Math.floor((this.workspaceLayout.frame % 24) / 6)] : "●";
3054
+ const loadingMessage = this.loadingAnimation?.message || this.defaultWorkingMessage;
3055
+ const spinner = getSpinnerFrame(this.workspaceLayout.frame);
3056
+ const rule = theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
3057
+ const section = (name) => fitLine(theme.bold(theme.fg("accent", `▌ ${name}`)) + theme.fg("borderMuted", " " + "─".repeat(Math.max(0, width - visibleWidth(name) - 4))), width);
3058
+ const label = (name, value, color = "text") => fitLine(theme.fg("dim", `${name.padEnd(10)} `) + theme.fg(color, value), width);
3059
+ const rawLabel = (name, value) => fitLine(theme.fg("dim", `${name.padEnd(10)} `) + value, width);
3060
+ const lines = [
3061
+ theme.bold(fitLine(theme.fg("accent", "OPERATOR CONTROL"), width)),
3062
+ fitLine(theme.fg("dim", "agent loop dashboard"), width),
3063
+ rule,
3064
+ section("RUN"),
3065
+ label("state", `${pulse} ${active ? "RUNNING" : "READY"}`, active ? "warning" : "success"),
3066
+ label("phase", active ? "working / verify" : "waiting input", active ? "accent" : "muted"),
3067
+ label("queue", `${queued} pending`, queued ? "warning" : "text"),
3068
+ ...(active
3069
+ ? [
3070
+ rawLabel("activity", `${theme.fg("accent", spinner)} ${theme.fg("text", loadingMessage)}`),
3071
+ "",
3072
+ ]
3073
+ : []),
3074
+ ...(!active ? [""] : []),
3075
+ section("CONTEXT"),
3076
+ fitLine(renderGauge(contextPercent, width), width),
3077
+ label("window", `${formatTokensCompact(contextWindow)} tokens`),
3078
+ label("usage", `↑${formatTokensCompact(totalInput)} ↓${formatTokensCompact(totalOutput)} R${formatTokensCompact(totalCacheRead)}`),
3079
+ label("history", this.fullHistoryView ? "full view" : "compact", this.fullHistoryView ? "warning" : "text"),
3080
+ "",
3081
+ section("ENGINE"),
3082
+ label("model", modelName),
3083
+ rawLabel("reason", reasoningText),
3084
+ label("provider", providerName),
3085
+ "",
3086
+ section("WORKSPACE"),
3087
+ label("path", cwd),
3088
+ label("thread", sessionName),
3089
+ "",
3090
+ section("TOOLS"),
3091
+ label("active", `${activeTools} running`, activeTools ? "warning" : "text"),
3092
+ fitLine(theme.fg("text", toolText), width),
3093
+ "",
3094
+ rule,
3095
+ fitLine(theme.fg("dim", `${keyText("app.clear")} stop ${keyText("app.history.full")} full chat`), width),
3096
+ fitLine(theme.fg("dim", `${keyText("app.thinking.cycle")} reason: `) + formatReasoningLegend(), width),
3097
+ fitLine(theme.fg("dim", `${keyText("app.model.select")} model ${keyText("app.editor.external")} editor`), width),
3098
+ ];
3099
+ const extensionStatuses = this.footerDataProvider.getExtensionStatuses();
3100
+ if (extensionStatuses.size > 0) {
3101
+ lines.splice(lines.length - 3, 0, "", fitLine(theme.fg("accent", "EXTENSIONS"), width), ...Array.from(extensionStatuses.values()).slice(0, 4).map((value) => fitLine(theme.fg("text", String(value).replace(/[\r\n\t]/g, " ")), width)));
3102
+ }
3103
+ if (this.customFooter) {
3104
+ lines.splice(lines.length - 3, 0, "", ...this.customFooter.render(width).map((line) => fitLine(line, width)));
3105
+ }
3106
+ return lines;
3107
+ }
2843
3108
  cycleThinkingLevel() {
2844
3109
  if (this.isBusyForModelOrThinkingChange()) {
2845
3110
  this.showBusyModelOrThinkingStatus();
@@ -2852,7 +3117,7 @@ export class InteractiveMode {
2852
3117
  else {
2853
3118
  this.footer.invalidate();
2854
3119
  this.updateEditorBorderColor();
2855
- this.showStatus(`Analysis depth: ${newLevel}`);
3120
+ this.showStatus(`Analysis depth: ${displayThinkingLevel(newLevel)}`);
2856
3121
  }
2857
3122
  }
2858
3123
  async cycleModel(direction) {
@@ -2880,6 +3145,19 @@ export class InteractiveMode {
2880
3145
  toggleToolOutputExpansion() {
2881
3146
  this.setToolsExpanded(!this.toolOutputExpanded);
2882
3147
  }
3148
+ toggleFullHistoryView() {
3149
+ if (this.isInterruptibleWorkActive()) {
3150
+ this.fullHistoryView = false;
3151
+ this.showStatus("Full conversation view is available after the current run finishes");
3152
+ this.ui.requestRender();
3153
+ return;
3154
+ }
3155
+ this.fullHistoryView = !this.fullHistoryView;
3156
+ this.showStatus(this.fullHistoryView
3157
+ ? "Full conversation view enabled. Press Ctrl+R again to return to compact view."
3158
+ : "Compact conversation view enabled.");
3159
+ this.ui.requestRender();
3160
+ }
2883
3161
  setToolsExpanded(expanded) {
2884
3162
  this.toolOutputExpanded = expanded;
2885
3163
  const activeHeader = this.customHeader ?? this.builtInHeader;
@@ -3044,8 +3322,8 @@ export class InteractiveMode {
3044
3322
  }
3045
3323
  return allQueued.length;
3046
3324
  }
3047
- queueCompactionMessage(text, mode) {
3048
- this.compactionQueuedMessages.push({ text, mode });
3325
+ queueCompactionMessage(text, mode, images) {
3326
+ this.compactionQueuedMessages.push({ text, mode, images });
3049
3327
  this.editor.addToHistory?.(text);
3050
3328
  this.editor.setText("");
3051
3329
  this.updatePendingMessagesDisplay();
@@ -3077,13 +3355,13 @@ export class InteractiveMode {
3077
3355
  // When retry is pending, queue messages for the retry turn
3078
3356
  for (const message of queuedMessages) {
3079
3357
  if (this.isExtensionCommand(message.text)) {
3080
- await this.session.prompt(message.text);
3358
+ await this.session.prompt(message.text, { images: message.images });
3081
3359
  }
3082
3360
  else if (message.mode === "followUp") {
3083
- await this.session.followUp(message.text);
3361
+ await this.session.followUp(message.text, message.images);
3084
3362
  }
3085
3363
  else {
3086
- await this.session.steer(message.text);
3364
+ await this.session.steer(message.text, message.images);
3087
3365
  }
3088
3366
  }
3089
3367
  this.updatePendingMessagesDisplay();
@@ -3094,7 +3372,7 @@ export class InteractiveMode {
3094
3372
  if (firstPromptIndex === -1) {
3095
3373
  // All extension commands - execute them all
3096
3374
  for (const message of queuedMessages) {
3097
- await this.session.prompt(message.text);
3375
+ await this.session.prompt(message.text, { images: message.images });
3098
3376
  }
3099
3377
  return;
3100
3378
  }
@@ -3103,22 +3381,22 @@ export class InteractiveMode {
3103
3381
  const firstPrompt = queuedMessages[firstPromptIndex];
3104
3382
  const rest = queuedMessages.slice(firstPromptIndex + 1);
3105
3383
  for (const message of preCommands) {
3106
- await this.session.prompt(message.text);
3384
+ await this.session.prompt(message.text, { images: message.images });
3107
3385
  }
3108
3386
  // Send first prompt (starts streaming)
3109
- const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => {
3387
+ const promptPromise = this.session.prompt(firstPrompt.text, { images: firstPrompt.images }).catch((error) => {
3110
3388
  restoreQueue(error);
3111
3389
  });
3112
3390
  // Queue remaining messages
3113
3391
  for (const message of rest) {
3114
3392
  if (this.isExtensionCommand(message.text)) {
3115
- await this.session.prompt(message.text);
3393
+ await this.session.prompt(message.text, { images: message.images });
3116
3394
  }
3117
3395
  else if (message.mode === "followUp") {
3118
- await this.session.followUp(message.text);
3396
+ await this.session.followUp(message.text, message.images);
3119
3397
  }
3120
3398
  else {
3121
- await this.session.steer(message.text);
3399
+ await this.session.steer(message.text, message.images);
3122
3400
  }
3123
3401
  }
3124
3402
  this.updatePendingMessagesDisplay();
@@ -4320,7 +4598,7 @@ export class InteractiveMode {
4320
4598
  if (this.bashComponent) {
4321
4599
  this.bashComponent.setComplete(undefined, false);
4322
4600
  }
4323
- this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
4601
+ this.showError(`Shell command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
4324
4602
  }
4325
4603
  this.bashComponent = undefined;
4326
4604
  this.ui.requestRender();
@@ -4351,6 +4629,7 @@ export class InteractiveMode {
4351
4629
  this.loadingAnimation.stop();
4352
4630
  this.loadingAnimation = undefined;
4353
4631
  }
4632
+ this.workspaceLayout.dispose();
4354
4633
  this.clearExtensionTerminalInputListeners();
4355
4634
  this.footer.dispose();
4356
4635
  this.footerDataProvider.dispose();