pzero-operator 0.1.7 → 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.
@@ -52,9 +52,9 @@ export function parseSkillBlock(text) {
52
52
  // Constants
53
53
  // ============================================================================
54
54
  /** Standard thinking levels */
55
- const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
55
+ const THINKING_LEVELS = ["off", "low", "medium", "high"];
56
56
  /** Thinking levels including xhigh (for supported models) */
57
- const THINKING_LEVELS_WITH_XHIGH = ["off", "minimal", "low", "medium", "high", "xhigh"];
57
+ const THINKING_LEVELS_WITH_XHIGH = ["off", "low", "medium", "high", "xhigh"];
58
58
  // ============================================================================
59
59
  // AgentSession Class
60
60
  // ============================================================================
@@ -1887,7 +1887,7 @@ export class AgentSession {
1887
1887
  this._applyExtensionBindings(this._extensionRunner);
1888
1888
  const defaultActiveToolNames = this._baseToolsOverride
1889
1889
  ? Object.keys(this._baseToolsOverride)
1890
- : ["read", "bash", "edit", "write"];
1890
+ : ["read", "bash", "edit", "write", "web_search"];
1891
1891
  const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
1892
1892
  this._refreshToolRegistry({
1893
1893
  activeToolNames: baseActiveToolNames,
@@ -25,6 +25,7 @@ export const KEYBINDINGS = {
25
25
  },
26
26
  "app.model.select": { defaultKeys: "ctrl+l", description: "Open model selector" },
27
27
  "app.tools.expand": { defaultKeys: "ctrl+o", description: "Toggle tool output" },
28
+ "app.history.full": { defaultKeys: "ctrl+r", description: "Toggle full conversation view" },
28
29
  "app.thinking.toggle": {
29
30
  defaultKeys: "ctrl+t",
30
31
  description: "Toggle thinking blocks",
package/dist/core/sdk.js CHANGED
@@ -13,12 +13,12 @@ import { getDefaultSessionDir, SessionManager } from "./session-manager.js";
13
13
  import { SettingsManager } from "./settings-manager.js";
14
14
  import { isInstallTelemetryEnabled } from "./telemetry.js";
15
15
  import { time } from "./timings.js";
16
- import { createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWriteTool, withFileMutationQueue, } from "./tools/index.js";
16
+ import { createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWebSearchTool, createWriteTool, withFileMutationQueue, } from "./tools/index.js";
17
17
  // Re-exports
18
18
  export * from "./agent-session-runtime.js";
19
19
  export { withFileMutationQueue,
20
20
  // Tool factories (for custom cwd)
21
- createCodingTools, createReadOnlyTools, createReadTool, createBashTool, createEditTool, createWriteTool, createGrepTool, createFindTool, createLsTool, };
21
+ createCodingTools, createReadOnlyTools, createReadTool, createBashTool, createEditTool, createWriteTool, createGrepTool, createFindTool, createLsTool, createWebSearchTool, };
22
22
  // Helper Functions
23
23
  function getDefaultAgentDir() {
24
24
  return getAgentDir();
@@ -135,7 +135,7 @@ export async function createAgentSession(options = {}) {
135
135
  if (!model || !model.reasoning) {
136
136
  thinkingLevel = "off";
137
137
  }
138
- const defaultActiveToolNames = ["read", "bash", "edit", "write"];
138
+ const defaultActiveToolNames = ["read", "bash", "edit", "write", "web_search"];
139
139
  const initialActiveToolNames = options.tools ? [...options.tools] : defaultActiveToolNames;
140
140
  let agent;
141
141
  // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
@@ -45,7 +45,7 @@ export function buildSystemPrompt(options) {
45
45
  const examplesPath = getExamplesPath();
46
46
  // Build tools list based on selected tools.
47
47
  // A tool appears in Available tools only when the caller provides a one-line snippet.
48
- const tools = selectedTools || ["read", "bash", "edit", "write"];
48
+ const tools = selectedTools || ["read", "bash", "edit", "write", "web_search"];
49
49
  const visibleTools = tools.filter((name) => !!toolSnippets?.[name]);
50
50
  const toolsList = visibleTools.length > 0 ? visibleTools.map((name) => `- ${name}: ${toolSnippets[name]}`).join("\n") : "(none)";
51
51
  // Build guidelines based on which tools are actually available
@@ -63,6 +63,7 @@ export function buildSystemPrompt(options) {
63
63
  const hasFind = tools.includes("find");
64
64
  const hasLs = tools.includes("ls");
65
65
  const hasRead = tools.includes("read");
66
+ const hasWebSearch = tools.includes("web_search");
66
67
  // File exploration guidelines
67
68
  if (hasBash && !hasGrep && !hasFind && !hasLs) {
68
69
  addGuideline("Use bash for file operations like ls, rg, find");
@@ -70,6 +71,9 @@ export function buildSystemPrompt(options) {
70
71
  else if (hasBash && (hasGrep || hasFind || hasLs)) {
71
72
  addGuideline("Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)");
72
73
  }
74
+ if (hasWebSearch) {
75
+ addGuideline("Use web_search for current external information, public docs, and recent web content.");
76
+ }
73
77
  for (const guideline of promptGuidelines ?? []) {
74
78
  const normalized = guideline.trim();
75
79
  if (normalized.length > 0) {
@@ -6,6 +6,7 @@ export { createGrepTool, createGrepToolDefinition, } from "./grep.js";
6
6
  export { createLsTool, createLsToolDefinition, } from "./ls.js";
7
7
  export { createReadTool, createReadToolDefinition, } from "./read.js";
8
8
  export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead, truncateLine, truncateTail, } from "./truncate.js";
9
+ export { createWebSearchTool, createWebSearchToolDefinition, } from "./web-search.js";
9
10
  export { createWriteTool, createWriteToolDefinition, } from "./write.js";
10
11
  import { createBashTool, createBashToolDefinition } from "./bash.js";
11
12
  import { createEditTool, createEditToolDefinition } from "./edit.js";
@@ -13,8 +14,9 @@ import { createFindTool, createFindToolDefinition } from "./find.js";
13
14
  import { createGrepTool, createGrepToolDefinition } from "./grep.js";
14
15
  import { createLsTool, createLsToolDefinition } from "./ls.js";
15
16
  import { createReadTool, createReadToolDefinition } from "./read.js";
17
+ import { createWebSearchTool, createWebSearchToolDefinition } from "./web-search.js";
16
18
  import { createWriteTool, createWriteToolDefinition } from "./write.js";
17
- export const allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
19
+ export const allToolNames = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "web_search"]);
18
20
  export function createToolDefinition(toolName, cwd, options) {
19
21
  switch (toolName) {
20
22
  case "read":
@@ -31,6 +33,8 @@ export function createToolDefinition(toolName, cwd, options) {
31
33
  return createFindToolDefinition(cwd, options?.find);
32
34
  case "ls":
33
35
  return createLsToolDefinition(cwd, options?.ls);
36
+ case "web_search":
37
+ return createWebSearchToolDefinition(cwd, options?.web_search);
34
38
  default:
35
39
  throw new Error(`Unknown tool name: ${toolName}`);
36
40
  }
@@ -51,6 +55,8 @@ export function createTool(toolName, cwd, options) {
51
55
  return createFindTool(cwd, options?.find);
52
56
  case "ls":
53
57
  return createLsTool(cwd, options?.ls);
58
+ case "web_search":
59
+ return createWebSearchTool(cwd, options?.web_search);
54
60
  default:
55
61
  throw new Error(`Unknown tool name: ${toolName}`);
56
62
  }
@@ -61,6 +67,7 @@ export function createCodingToolDefinitions(cwd, options) {
61
67
  createBashToolDefinition(cwd, options?.bash),
62
68
  createEditToolDefinition(cwd, options?.edit),
63
69
  createWriteToolDefinition(cwd, options?.write),
70
+ createWebSearchToolDefinition(cwd, options?.web_search),
64
71
  ];
65
72
  }
66
73
  export function createReadOnlyToolDefinitions(cwd, options) {
@@ -80,6 +87,7 @@ export function createAllToolDefinitions(cwd, options) {
80
87
  grep: createGrepToolDefinition(cwd, options?.grep),
81
88
  find: createFindToolDefinition(cwd, options?.find),
82
89
  ls: createLsToolDefinition(cwd, options?.ls),
90
+ web_search: createWebSearchToolDefinition(cwd, options?.web_search),
83
91
  };
84
92
  }
85
93
  export function createCodingTools(cwd, options) {
@@ -88,6 +96,7 @@ export function createCodingTools(cwd, options) {
88
96
  createBashTool(cwd, options?.bash),
89
97
  createEditTool(cwd, options?.edit),
90
98
  createWriteTool(cwd, options?.write),
99
+ createWebSearchTool(cwd, options?.web_search),
91
100
  ];
92
101
  }
93
102
  export function createReadOnlyTools(cwd, options) {
@@ -107,6 +116,7 @@ export function createAllTools(cwd, options) {
107
116
  grep: createGrepTool(cwd, options?.grep),
108
117
  find: createFindTool(cwd, options?.find),
109
118
  ls: createLsTool(cwd, options?.ls),
119
+ web_search: createWebSearchTool(cwd, options?.web_search),
110
120
  };
111
121
  }
112
- //# sourceMappingURL=index.js.map
122
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,162 @@
1
+ import { Text } from "@operator/tui";
2
+ import { spawn } from "child_process";
3
+ import { existsSync } from "fs";
4
+ import { Type } from "typebox";
5
+ import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
6
+ import { getTextOutput, invalidArgText, str } from "./render-utils.js";
7
+ import { wrapToolDefinition } from "./tool-definition-wrapper.js";
8
+ import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js";
9
+ const WEB_SEARCH_HELPER = `${process.env.HOME || ""}/.tools/web_search/search.cjs`;
10
+ const webSearchSchema = Type.Object({
11
+ query: Type.Optional(Type.String({ description: "Search query for full, ddg, instant, or wiki modes" })),
12
+ url: Type.Optional(Type.String({ description: "Page URL to extract when mode is 'page'" })),
13
+ mode: Type.Optional(Type.Union([
14
+ Type.Literal("full"),
15
+ Type.Literal("ddg"),
16
+ Type.Literal("instant"),
17
+ Type.Literal("wiki"),
18
+ Type.Literal("page"),
19
+ ], { description: "Search mode (default: full)" })),
20
+ limit: Type.Optional(Type.Number({ description: "Maximum number of results for ddg/wiki/full (default: 5)" })),
21
+ });
22
+ function formatWebSearchCall(args, theme) {
23
+ const mode = str(args?.mode) || "full";
24
+ const query = str(args?.query);
25
+ const url = str(args?.url);
26
+ const target = mode === "page" ? url : query;
27
+ const invalidArg = invalidArgText(theme);
28
+ return `${theme.fg("toolTitle", theme.bold("web.search"))} ${theme.fg("toolOutput", `[${mode}] `)}${target === null ? invalidArg : theme.fg("accent", target || "...")}`;
29
+ }
30
+ function formatWebSearchResult(result, options, theme, showImages) {
31
+ const output = getTextOutput(result, showImages).trim();
32
+ if (!output) {
33
+ return "";
34
+ }
35
+ const lines = output.split("\n");
36
+ const maxLines = options.expanded ? lines.length : 20;
37
+ const displayLines = lines.slice(0, maxLines);
38
+ const remaining = lines.length - maxLines;
39
+ let text = `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
40
+ if (remaining > 0) {
41
+ text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
42
+ }
43
+ const truncation = result.details?.truncation;
44
+ if (truncation?.truncated) {
45
+ text += `\n${theme.fg("warning", `[Truncated: ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit]`)}`;
46
+ }
47
+ return text;
48
+ }
49
+ export function createWebSearchToolDefinition(_cwd, _options) {
50
+ return {
51
+ name: "web_search",
52
+ label: "web.search",
53
+ description: `Search the web using DuckDuckGo and Wikipedia, or extract readable text from a web page. Best for current external information, documentation, and quick validation. Output is truncated to ${DEFAULT_MAX_BYTES / 1024}KB if needed.`,
54
+ promptSnippet: "Search the web for current external information or extract text from a URL",
55
+ promptGuidelines: [
56
+ "Use web_search when the user asks for current external information, recent updates, or public web content.",
57
+ ],
58
+ parameters: webSearchSchema,
59
+ async execute(_toolCallId, { query, url, mode, limit }, signal, _onUpdate, _ctx) {
60
+ return new Promise((resolve, reject) => {
61
+ if (signal?.aborted) {
62
+ reject(new Error("Operation aborted"));
63
+ return;
64
+ }
65
+ if (!existsSync(WEB_SEARCH_HELPER)) {
66
+ reject(new Error(`Web search helper not found: ${WEB_SEARCH_HELPER}`));
67
+ return;
68
+ }
69
+ const effectiveMode = mode || "full";
70
+ const target = effectiveMode === "page" ? url : query;
71
+ if (!target) {
72
+ reject(new Error(effectiveMode === "page" ? "url is required for page mode" : "query is required for search mode"));
73
+ return;
74
+ }
75
+ const args = [WEB_SEARCH_HELPER, "--json"];
76
+ if (effectiveMode === "page") {
77
+ args.push("--page", target);
78
+ }
79
+ else if (effectiveMode === "ddg") {
80
+ args.push("--ddg", target);
81
+ }
82
+ else if (effectiveMode === "instant") {
83
+ args.push("--instant", target);
84
+ }
85
+ else if (effectiveMode === "wiki") {
86
+ args.push("--wiki", target);
87
+ }
88
+ else {
89
+ args.push(target);
90
+ }
91
+ if (limit !== undefined && effectiveMode !== "page" && effectiveMode !== "instant") {
92
+ args.push("--limit", String(limit));
93
+ }
94
+ const child = spawn(process.execPath, args, {
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ });
97
+ let stdout = "";
98
+ let stderr = "";
99
+ let settled = false;
100
+ const settle = (fn) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ signal?.removeEventListener("abort", onAbort);
105
+ fn();
106
+ };
107
+ const onAbort = () => {
108
+ if (!child.killed) {
109
+ child.kill();
110
+ }
111
+ settle(() => reject(new Error("Operation aborted")));
112
+ };
113
+ signal?.addEventListener("abort", onAbort, { once: true });
114
+ child.stdout?.on("data", (chunk) => {
115
+ stdout += chunk.toString();
116
+ });
117
+ child.stderr?.on("data", (chunk) => {
118
+ stderr += chunk.toString();
119
+ });
120
+ child.on("error", (error) => {
121
+ settle(() => reject(new Error(`Failed to run web search helper: ${error.message}`)));
122
+ });
123
+ child.on("close", (code) => {
124
+ if (signal?.aborted) {
125
+ settle(() => reject(new Error("Operation aborted")));
126
+ return;
127
+ }
128
+ if (code !== 0) {
129
+ settle(() => reject(new Error(stderr.trim() || `web_search exited with code ${code}`)));
130
+ return;
131
+ }
132
+ const output = stdout.trim();
133
+ if (!output) {
134
+ settle(() => resolve({
135
+ content: [{ type: "text", text: "No web search results returned" }],
136
+ details: undefined,
137
+ }));
138
+ return;
139
+ }
140
+ const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
141
+ settle(() => resolve({
142
+ content: [{ type: "text", text: truncation.content }],
143
+ details: truncation.truncated ? { truncation } : undefined,
144
+ }));
145
+ });
146
+ });
147
+ },
148
+ renderCall(args, theme, context) {
149
+ const text = context.lastComponent ?? new Text("", 0, 0);
150
+ text.setText(formatWebSearchCall(args, theme));
151
+ return text;
152
+ },
153
+ renderResult(result, options, theme, context) {
154
+ const text = context.lastComponent ?? new Text("", 0, 0);
155
+ text.setText(formatWebSearchResult(result, options, theme, context.showImages));
156
+ return text;
157
+ },
158
+ };
159
+ }
160
+ export function createWebSearchTool(cwd, options) {
161
+ return wrapToolDefinition(createWebSearchToolDefinition(cwd, options));
162
+ }
@@ -16,7 +16,7 @@ const DEFAULT_MODELS_CONFIG = {
16
16
  authHeader: true,
17
17
  compat: {
18
18
  supportsDeveloperRole: true,
19
- supportsReasoningEffort: false,
19
+ supportsReasoningEffort: true,
20
20
  },
21
21
  models: [
22
22
  {
@@ -32,13 +32,14 @@ const DEFAULT_MODELS_CONFIG = {
32
32
  authHeader: true,
33
33
  compat: {
34
34
  supportsDeveloperRole: true,
35
- supportsReasoningEffort: false,
35
+ supportsReasoningEffort: true,
36
36
  },
37
37
  models: [
38
38
  {
39
39
  id: "qwen3-6",
40
40
  name: "qwen3.6 27b",
41
41
  reasoning: true,
42
+ input: ["text", "image"],
42
43
  },
43
44
  ],
44
45
  },
@@ -48,13 +49,14 @@ const DEFAULT_MODELS_CONFIG = {
48
49
  authHeader: true,
49
50
  compat: {
50
51
  supportsDeveloperRole: true,
51
- supportsReasoningEffort: false,
52
+ supportsReasoningEffort: true,
52
53
  },
53
54
  models: [
54
55
  {
55
56
  id: "gemma4",
56
57
  name: "gemma4",
57
58
  reasoning: true,
59
+ input: ["text", "image"],
58
60
  },
59
61
  ],
60
62
  },
@@ -110,8 +112,8 @@ function migrateAlemBaseUrls() {
110
112
  if (!gptOssProvider.compat || typeof gptOssProvider.compat !== "object") {
111
113
  gptOssProvider.compat = {};
112
114
  }
113
- if (gptOssProvider.compat.supportsReasoningEffort !== false) {
114
- gptOssProvider.compat.supportsReasoningEffort = false;
115
+ if (gptOssProvider.compat.supportsReasoningEffort !== true) {
116
+ gptOssProvider.compat.supportsReasoningEffort = true;
115
117
  changed = true;
116
118
  }
117
119
  }
@@ -126,6 +128,31 @@ function migrateAlemBaseUrls() {
126
128
  provider.baseUrl = "https://llm.alem.ai/v1";
127
129
  changed = true;
128
130
  }
131
+ if (!provider.compat || typeof provider.compat !== "object") {
132
+ provider.compat = {};
133
+ }
134
+ if (provider.compat.supportsReasoningEffort !== true) {
135
+ provider.compat.supportsReasoningEffort = true;
136
+ changed = true;
137
+ }
138
+ if (Array.isArray(provider.models)) {
139
+ for (const model of provider.models) {
140
+ if (!model || typeof model !== "object") {
141
+ continue;
142
+ }
143
+ if (!model.compat || typeof model.compat !== "object") {
144
+ model.compat = {};
145
+ }
146
+ if (model.compat.supportsReasoningEffort !== true) {
147
+ model.compat.supportsReasoningEffort = true;
148
+ changed = true;
149
+ }
150
+ if (model.reasoning !== true) {
151
+ model.reasoning = true;
152
+ changed = true;
153
+ }
154
+ }
155
+ }
129
156
  }
130
157
  if (changed) {
131
158
  writeFileSync(modelsPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
@@ -134,7 +134,7 @@ export class FooterComponent {
134
134
  if (state.model?.reasoning) {
135
135
  const thinkingLevel = state.thinkingLevel || "off";
136
136
  rightSideWithoutProvider =
137
- thinkingLevel === "off" ? `${modelName} | reasoning off` : `${modelName} | reasoning ${thinkingLevel}`;
137
+ thinkingLevel === "off" ? `${modelName} | reasoning fast` : `${modelName} | reasoning ${thinkingLevel}`;
138
138
  }
139
139
  const providerName = state.model?.provider || "none";
140
140
  const sessionValue = sessionName || "unnamed";
@@ -6,11 +6,10 @@ const THINKING_SELECT_LIST_LAYOUT = {
6
6
  maxPrimaryColumnWidth: 32,
7
7
  };
8
8
  const LEVEL_DESCRIPTIONS = {
9
- off: "No analysis pass",
10
- minimal: "Tiny analysis pass (~1k tokens)",
11
- low: "Light analysis pass (~2k tokens)",
12
- medium: "Balanced analysis pass (~8k tokens)",
13
- high: "Deep analysis pass (~16k tokens)",
9
+ off: "Fast mode. Direct output, lowest latency.",
10
+ low: "Light analysis. Quick check before answering.",
11
+ medium: "Balanced analysis. Best default for most tasks.",
12
+ high: "Deep analysis. Slower, but stronger reasoning.",
14
13
  xhigh: "Maximum analysis pass (~32k tokens)",
15
14
  };
16
15
  /**
@@ -22,7 +21,7 @@ export class ThinkingSelectorComponent extends Container {
22
21
  super();
23
22
  const thinkingLevels = availableLevels.map((level) => ({
24
23
  value: level,
25
- label: level,
24
+ label: level === "off" ? "fast" : level,
26
25
  description: LEVEL_DESCRIPTIONS[level],
27
26
  }));
28
27
  // Add top border
@@ -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") {
@@ -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();
@@ -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();
@@ -396,6 +396,11 @@ function buildParams(model, context, options, compat = getCompat(model), cacheRe
396
396
  else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
397
397
  // OpenAI-style reasoning_effort
398
398
  params.reasoning_effort = mapReasoningEffort(options.reasoningEffort, compat.reasoningEffortMap);
399
+ // LiteLLM proxies may reject OpenAI-only params unless they are explicitly allow-listed.
400
+ // Alem AI Plus sits behind LiteLLM and returns UnsupportedParamsError without this field.
401
+ if (model.baseUrl.includes("llm.alem.ai")) {
402
+ params.allowed_openai_params = ["reasoning_effort"];
403
+ }
399
404
  }
400
405
  // OpenRouter provider routing preferences
401
406
  if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) {
@@ -851,4 +856,4 @@ function getCompat(model) {
851
856
  sendSessionAffinityHeaders: model.compat.sendSessionAffinityHeaders ?? detected.sendSessionAffinityHeaders,
852
857
  };
853
858
  }
854
- //# sourceMappingURL=openai-completions.js.map
859
+ //# sourceMappingURL=openai-completions.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pzero-operator",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Operator is a coding-first terminal AI agent from ProjectZero for software development, shell execution, local project workflows, and broader device-level operator control.",