march-cli 0.1.27 → 0.1.29

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/runner.mjs +1 -1
  3. package/src/agent/runtime/remote-ui-client.mjs +1 -1
  4. package/src/agent/runtime/runner-runtime-host.mjs +1 -0
  5. package/src/agent/runtime/ui-event-bridge.mjs +2 -2
  6. package/src/agent/session/session-options.mjs +2 -1
  7. package/src/agent/tools.mjs +3 -1
  8. package/src/agent/turn/turn-runner.mjs +2 -2
  9. package/src/browser/cli/command.mjs +61 -0
  10. package/src/browser/cli/open-url.mjs +21 -0
  11. package/src/browser/client/http.mjs +18 -0
  12. package/src/browser/client/lifecycle.mjs +57 -0
  13. package/src/browser/client/rpc.mjs +8 -0
  14. package/src/browser/client/state.mjs +35 -0
  15. package/src/browser/daemon/constants.mjs +3 -0
  16. package/src/browser/daemon/entry.mjs +28 -0
  17. package/src/browser/daemon/server.mjs +146 -0
  18. package/src/browser/extension/background.js +225 -0
  19. package/src/browser/extension/errors.js +19 -0
  20. package/src/browser/extension/execute-code.js +53 -0
  21. package/src/browser/extension/manifest.json +15 -0
  22. package/src/browser/extension-install.mjs +21 -0
  23. package/src/browser/tools/index.mjs +89 -0
  24. package/src/cli/args.mjs +4 -1
  25. package/src/cli/fallback-ui.mjs +4 -4
  26. package/src/cli/repl-loop.mjs +5 -5
  27. package/src/cli/tui/layout/main-pane-layout.mjs +2 -1
  28. package/src/cli/tui/output/tool-card-renderer.mjs +8 -2
  29. package/src/cli/tui/recall-rendering.mjs +6 -7
  30. package/src/cli/tui/status/status-bar.mjs +11 -3
  31. package/src/cli/tui/tui-input-controller.mjs +2 -1
  32. package/src/cli/ui.mjs +9 -10
  33. package/src/context/system-core/base.md +4 -3
  34. package/src/main.mjs +14 -10
  35. package/src/memory/markdown/markdown-recall.mjs +1 -1
  36. package/src/memory/markdown-store.mjs +1 -1
  37. package/src/memory/markdown-tools.mjs +5 -5
  38. package/src/memory/root.mjs +7 -0
@@ -0,0 +1,225 @@
1
+ import { buildExecCode } from "./execute-code.js";
2
+ import { serializeError } from "./errors.js";
3
+
4
+ const DAEMON_WS = "ws://127.0.0.1:4328/extension";
5
+ let socket = null;
6
+ let reconnectTimer = null;
7
+ let connecting = false;
8
+
9
+ startBridge();
10
+ chrome.runtime.onStartup.addListener(startBridge);
11
+ chrome.runtime.onInstalled.addListener(startBridge);
12
+ chrome.alarms.onAlarm.addListener((alarm) => {
13
+ if (alarm.name === "march-browser-reconnect") connect();
14
+ if (alarm.name === "march-browser-keepalive") keepAlive();
15
+ });
16
+
17
+ function startBridge() {
18
+ connect();
19
+ chrome.alarms.create("march-browser-reconnect", { periodInMinutes: 0.5 });
20
+ }
21
+
22
+ function connect() {
23
+ if (connecting || socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return;
24
+ connecting = true;
25
+ try {
26
+ socket = new WebSocket(DAEMON_WS);
27
+ } catch {
28
+ connecting = false;
29
+ scheduleReconnect();
30
+ return;
31
+ }
32
+ socket.onopen = () => {
33
+ connecting = false;
34
+ setBadge("on");
35
+ scheduleKeepAlive();
36
+ };
37
+ socket.onmessage = (event) => handleMessage(event.data);
38
+ socket.onclose = scheduleReconnect;
39
+ socket.onerror = () => socket?.close();
40
+ }
41
+
42
+ function keepAlive() {
43
+ if (socket?.readyState === WebSocket.OPEN) {
44
+ send({ type: "ping" });
45
+ scheduleKeepAlive();
46
+ } else {
47
+ scheduleReconnect();
48
+ }
49
+ }
50
+
51
+ function scheduleKeepAlive() {
52
+ chrome.alarms.create("march-browser-keepalive", { delayInMinutes: 0.4 });
53
+ }
54
+
55
+ function scheduleReconnect() {
56
+ connecting = false;
57
+ setBadge("off");
58
+ if (reconnectTimer) return;
59
+ reconnectTimer = setTimeout(() => {
60
+ reconnectTimer = null;
61
+ connect();
62
+ }, 1000);
63
+ }
64
+
65
+ async function handleMessage(data) {
66
+ const request = JSON.parse(data);
67
+ if (!request.id) return;
68
+ try {
69
+ const result = await dispatch(request.method, request.params ?? {});
70
+ send({ id: request.id, ok: true, result });
71
+ } catch (err) {
72
+ send({ id: request.id, ok: false, error: serializeError(err) });
73
+ }
74
+ }
75
+
76
+ function send(payload) {
77
+ if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(payload));
78
+ }
79
+
80
+ function setBadge(state) {
81
+ chrome.action.setBadgeText({ text: state === "on" ? "ON" : "" });
82
+ chrome.action.setBadgeBackgroundColor({ color: "#16a34a" });
83
+ }
84
+
85
+ async function dispatch(method, params) {
86
+ if (method === "tabs") return { tabs: (await chrome.tabs.query({})).map(formatTab) };
87
+ if (method === "open") return await openTab(params);
88
+ if (method === "read") return await readTab(params);
89
+ if (method === "script") return await runScript(params);
90
+ throw new Error(`Unknown browser method: ${method}`);
91
+ }
92
+
93
+ async function openTab(params) {
94
+ const action = params.action;
95
+ const tabId = parseTabId(params.tabId);
96
+ if (action === "new") return { tab: formatTab(await chrome.tabs.create({ url: requiredUrl(params.url), active: params.active ?? true })) };
97
+ if (!tabId) throw new Error(`${action} requires tabId`);
98
+ if (action === "navigate") return { tab: formatTab(await chrome.tabs.update(tabId, { url: requiredUrl(params.url), active: params.active })) };
99
+ if (action === "focus") return await focusTab(tabId);
100
+ if (action === "close") { await chrome.tabs.remove(tabId); return { closed: String(tabId) }; }
101
+ if (action === "reload") { await chrome.tabs.reload(tabId); return { reloaded: String(tabId) }; }
102
+ if (action === "back" || action === "forward") return await navHistory(tabId, action);
103
+ throw new Error(`Unknown open action: ${action}`);
104
+ }
105
+
106
+ async function focusTab(tabId) {
107
+ const tab = await chrome.tabs.update(tabId, { active: true });
108
+ if (tab.windowId) await chrome.windows.update(tab.windowId, { focused: true });
109
+ return { tab: formatTab(tab) };
110
+ }
111
+
112
+ async function navHistory(tabId, action) {
113
+ await executePageCode(tabId, action === "back" ? "history.back(); true" : "history.forward(); true");
114
+ return { tabId: String(tabId), action };
115
+ }
116
+
117
+ async function readTab(params) {
118
+ const tabId = requireTabId(params.tabId, "read");
119
+ const include = params.include ?? { text: true, elements: true };
120
+ const page = await executePageCode(tabId, buildReadCode(include));
121
+ const tab = await chrome.tabs.get(tabId);
122
+ return { tab: formatTab(tab), page };
123
+ }
124
+
125
+ async function runScript(params) {
126
+ const tabId = requireTabId(params.tabId, "script");
127
+ const result = await executePageCode(tabId, String(params.code ?? ""));
128
+ return { tabId: String(tabId), result };
129
+ }
130
+
131
+ async function executePageCode(tabId, code) {
132
+ const wrapped = buildExecCode(code);
133
+ const injected = await executeViaScripting(tabId, wrapped).catch((err) => ({ ok: false, csp: true, error: serializeError(err) }));
134
+ if (injected?.ok) return injected.data;
135
+ if (!injected?.csp) throwErrorResult(injected);
136
+ const cdp = await executeViaCdp(tabId, wrapped).catch((err) => ({ ok: false, error: serializeError(err) }));
137
+ if (cdp?.ok) return cdp.data;
138
+ throwErrorResult(cdp);
139
+ }
140
+
141
+ async function executeViaScripting(tabId, source) {
142
+ const [injection] = await chrome.scripting.executeScript({
143
+ target: { tabId },
144
+ world: "MAIN",
145
+ func: (script) => eval(script),
146
+ args: [source],
147
+ });
148
+ if (!injection) return { ok: false, error: { message: `No script injection result for tab ${tabId}` } };
149
+ if (injection.result == null) return { ok: false, csp: true, error: { message: "Script returned no value" } };
150
+ return injection.result;
151
+ }
152
+
153
+ async function executeViaCdp(tabId, expression) {
154
+ const target = { tabId };
155
+ let attached = false;
156
+ try {
157
+ await chrome.debugger.attach(target, "1.3");
158
+ attached = true;
159
+ const response = await chrome.debugger.sendCommand(target, "Runtime.evaluate", {
160
+ expression,
161
+ awaitPromise: true,
162
+ returnByValue: true,
163
+ });
164
+ if (response.exceptionDetails) {
165
+ return { ok: false, error: { message: response.exceptionDetails.exception?.description || "CDP Runtime.evaluate failed" } };
166
+ }
167
+ return response.result?.value ?? { ok: true, data: undefined };
168
+ } finally {
169
+ if (attached) await chrome.debugger.detach(target).catch(() => {});
170
+ }
171
+ }
172
+
173
+ function buildReadCode(include) {
174
+ return `return (() => {
175
+ const include = ${JSON.stringify(include)};
176
+ const page = { title: document.title, url: location.href };
177
+ if (include.text !== false) page.text = document.body?.innerText || "";
178
+ if (include.html) page.html = document.documentElement?.outerHTML || "";
179
+ if (include.elements !== false) page.elements = Array.from(document.querySelectorAll("a,button,input,textarea,select,[role=button],[role=link],[contenteditable=true]")).slice(0, 300).map((el, index) => {
180
+ const tag = el.tagName.toLowerCase();
181
+ const text = (el.innerText || el.value || el.getAttribute("aria-label") || "").trim().slice(0, 200);
182
+ return { index, tag, type: el.getAttribute("type") || undefined, role: el.getAttribute("role") || undefined, text, placeholder: el.getAttribute("placeholder") || undefined, href: el.href || undefined, selector: selectorFor(el) };
183
+ });
184
+ return page;
185
+ function selectorFor(el) {
186
+ if (el.id) return "#" + CSS.escape(el.id);
187
+ const name = el.getAttribute("name");
188
+ if (name) return tagName(el) + "[name=\\\"" + CSS.escape(name) + "\\\"]";
189
+ const parts = [];
190
+ for (let node = el; node && node.nodeType === Node.ELEMENT_NODE && parts.length < 4; node = node.parentElement) {
191
+ const tag = tagName(node);
192
+ const siblings = Array.from(node.parentElement?.children || []).filter((child) => child.tagName === node.tagName);
193
+ parts.unshift(siblings.length > 1 ? tag + ":nth-of-type(" + (siblings.indexOf(node) + 1) + ")" : tag);
194
+ }
195
+ return parts.join(" > ");
196
+ }
197
+ function tagName(el) { return el.tagName.toLowerCase(); }
198
+ })()`;
199
+ }
200
+
201
+ function throwErrorResult(result) {
202
+ const error = result?.error;
203
+ if (typeof error === "string") throw new Error(error);
204
+ throw new Error(error?.stack || error?.message || "Browser script failed");
205
+ }
206
+
207
+ function formatTab(tab) {
208
+ return { id: String(tab.id), windowId: tab.windowId, active: tab.active, title: tab.title, url: tab.url, status: tab.status };
209
+ }
210
+
211
+ function requireTabId(value, action) {
212
+ const tabId = parseTabId(value);
213
+ if (!tabId) throw new Error(`${action} requires tabId`);
214
+ return tabId;
215
+ }
216
+
217
+ function parseTabId(value) {
218
+ const id = Number(value);
219
+ return Number.isFinite(id) ? id : null;
220
+ }
221
+
222
+ function requiredUrl(url) {
223
+ if (!url) throw new Error("url is required");
224
+ return url;
225
+ }
@@ -0,0 +1,19 @@
1
+ export function serializeError(err) {
2
+ if (!err) return { message: "Unknown error" };
3
+ if (typeof err === "string") return { message: err };
4
+ const name = typeof err.name === "string" && err.name ? err.name : "Error";
5
+ const stack = typeof err.stack === "string" ? err.stack : "";
6
+ const message = readableErrorMessage(err);
7
+ return { name, message, stack };
8
+ }
9
+
10
+ function readableErrorMessage(err) {
11
+ if (typeof err?.message === "string" && err.message && err.message !== "[object Object]") return err.message;
12
+ if (typeof err?.message === "object") return safeStringify(err.message);
13
+ const json = safeStringify(err);
14
+ return json === "{}" ? String(err) : json;
15
+ }
16
+
17
+ function safeStringify(value) {
18
+ try { return JSON.stringify(value); } catch { return String(value); }
19
+ }
@@ -0,0 +1,53 @@
1
+ export function buildExecCode(code) {
2
+ return `(async () => {
3
+ function smartResult(value) {
4
+ if (value == null || typeof value !== "object") return value;
5
+ try { if (value.window === value && value.document) return "[Window: " + (value.location?.href || "about:blank") + "]"; } catch {}
6
+ if (typeof Node !== "undefined" && value.nodeType === Node.ELEMENT_NODE) return value.outerHTML;
7
+ if (typeof NodeList !== "undefined" && (value instanceof NodeList || value instanceof HTMLCollection)) {
8
+ return Array.from(value).slice(0, 300).map((item) => item?.nodeType === Node.ELEMENT_NODE ? item.outerHTML : String(item));
9
+ }
10
+ try {
11
+ return JSON.parse(JSON.stringify(value, (_key, item) => {
12
+ if (item && typeof item === "object") {
13
+ if (typeof Node !== "undefined" && item.nodeType === Node.ELEMENT_NODE) return item.outerHTML;
14
+ try { if (item.window === item && item.document) return "[Window]"; } catch {}
15
+ }
16
+ return item;
17
+ }));
18
+ } catch (err) {
19
+ return "[Unserializable: " + (err?.message || String(err)) + "]";
20
+ }
21
+ }
22
+ function executable(source) {
23
+ const lines = source.split(/\\r?\\n/);
24
+ let index = lines.length - 1;
25
+ while (index >= 0 && !lines[index].trim()) index--;
26
+ if (index < 0) return source;
27
+ const last = lines[index].trim();
28
+ if (/^(return\\b|let\\b|const\\b|var\\b|if\\b|for\\b|while\\b|switch\\b|try\\b|throw\\b|class\\b|function\\b|async\\b|import\\b|export\\b|\\/\\/|})/.test(last)) return source;
29
+ lines[index] = lines[index].match(/^(\\s*)/)[1] + "return " + last;
30
+ return lines.join("\\n");
31
+ }
32
+ try {
33
+ const source = ${JSON.stringify(code)}.trim();
34
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
35
+ let value;
36
+ if (/^return\\b/.test(source) || /\\n\\s*return\\b/.test(source)) {
37
+ value = await (new AsyncFunction(source))();
38
+ } else {
39
+ try {
40
+ value = eval(source);
41
+ if (value && typeof value.then === "function") value = await value;
42
+ } catch (err) {
43
+ if (err instanceof SyntaxError) value = await (new AsyncFunction(executable(source)))();
44
+ else throw err;
45
+ }
46
+ }
47
+ return { ok: true, data: smartResult(value) };
48
+ } catch (err) {
49
+ const message = err?.message || String(err);
50
+ return { ok: false, csp: /unsafe-eval|Content Security Policy|Refused to evaluate/i.test(message), error: { name: err?.name || "Error", message, stack: err?.stack || "" } };
51
+ }
52
+ })()`;
53
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "March Browser Bridge",
4
+ "version": "0.1.0",
5
+ "description": "Connects the user's browser tabs to March CLI.",
6
+ "permissions": ["tabs", "scripting", "webNavigation", "storage", "alarms", "debugger"],
7
+ "host_permissions": ["<all_urls>"],
8
+ "background": {
9
+ "service_worker": "background.js",
10
+ "type": "module"
11
+ },
12
+ "action": {
13
+ "default_title": "March Browser Bridge"
14
+ }
15
+ }
@@ -0,0 +1,21 @@
1
+ import { cpSync, existsSync, rmSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export function sourceBrowserExtensionPath() {
6
+ const path = resolve(dirname(fileURLToPath(import.meta.url)), "extension");
7
+ if (!existsSync(path)) throw new Error(`Browser extension not found: ${path}`);
8
+ return path;
9
+ }
10
+
11
+ export function installedBrowserExtensionPath(stateRoot) {
12
+ return resolve(stateRoot, "browser-extension");
13
+ }
14
+
15
+ export function syncBrowserExtensionInstall(stateRoot) {
16
+ const source = sourceBrowserExtensionPath();
17
+ const target = installedBrowserExtensionPath(stateRoot);
18
+ rmSync(target, { recursive: true, force: true });
19
+ cpSync(source, target, { recursive: true });
20
+ return target;
21
+ }
@@ -0,0 +1,89 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { defineTool } from "@earendil-works/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { toolText } from "../../agent/tool-result.mjs";
6
+ import { callBrowserDaemon } from "../client/rpc.mjs";
7
+
8
+ export function createBrowserTools({ stateRoot = join(homedir(), ".march") } = {}) {
9
+ return [
10
+ browserTabsTool(stateRoot),
11
+ browserOpenTool(stateRoot),
12
+ browserReadTool(stateRoot),
13
+ browserScriptTool(stateRoot),
14
+ ];
15
+ }
16
+
17
+ function browserTabsTool(stateRoot) {
18
+ return defineTool({
19
+ name: "browser_tabs",
20
+ label: "Browser Tabs",
21
+ description: "List all tabs visible to the March browser extension. Use this first to choose a tabId.",
22
+ parameters: Type.Object({}),
23
+ execute: async () => safeToolJson(() => callBrowserDaemon({ stateRoot, method: "tabs" })),
24
+ });
25
+ }
26
+
27
+ function browserOpenTool(stateRoot) {
28
+ return defineTool({
29
+ name: "browser_open",
30
+ label: "Browser Open",
31
+ description: "Create, navigate, focus, close, reload, back, or forward browser tabs. Returns the affected tab when available.",
32
+ parameters: Type.Object({
33
+ action: Type.String({ enum: ["new", "navigate", "focus", "close", "reload", "back", "forward"] }),
34
+ url: Type.Optional(Type.String({ description: "URL for new or navigate actions." })),
35
+ tabId: Type.Optional(Type.String({ description: "Target tab id for existing-tab actions." })),
36
+ active: Type.Optional(Type.Boolean({ description: "Whether a new tab should become active. Default true." })),
37
+ }),
38
+ execute: async (_id, params) => safeToolJson(() => callBrowserDaemon({ stateRoot, method: "open", params })),
39
+ });
40
+ }
41
+
42
+ function browserReadTool(stateRoot) {
43
+ return defineTool({
44
+ name: "browser_read",
45
+ label: "Browser Read",
46
+ description: "Read content from a specific browser tab. Defaults to visible text and interactive elements; can include HTML.",
47
+ parameters: Type.Object({
48
+ tabId: Type.String({ description: "Target tab id from browser_tabs or browser_open." }),
49
+ include: Type.Optional(Type.Object({
50
+ text: Type.Optional(Type.Boolean()),
51
+ html: Type.Optional(Type.Boolean()),
52
+ elements: Type.Optional(Type.Boolean()),
53
+ })),
54
+ }),
55
+ execute: async (_id, params) => safeToolJson(() => callBrowserDaemon({ stateRoot, method: "read", params })),
56
+ });
57
+ }
58
+
59
+ function browserScriptTool(stateRoot) {
60
+ return defineTool({
61
+ name: "browser_script",
62
+ label: "Browser Script",
63
+ description: "Execute arbitrary JavaScript in a specific browser tab. The code may return a JSON-serializable value or a Promise.",
64
+ parameters: Type.Object({
65
+ tabId: Type.String({ description: "Target tab id from browser_tabs or browser_open." }),
66
+ code: Type.String({ description: "JavaScript function body. Use return to send a result back." }),
67
+ awaitPromise: Type.Optional(Type.Boolean({ description: "Await a returned Promise. Default true." })),
68
+ timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds. Default 30000." })),
69
+ }),
70
+ execute: async (_id, params) => safeToolJson(() => callBrowserDaemon({
71
+ stateRoot,
72
+ method: "script",
73
+ params,
74
+ timeoutMs: params.timeoutMs ?? 30000,
75
+ })),
76
+ });
77
+ }
78
+
79
+ async function safeToolJson(run) {
80
+ try {
81
+ return toolJson(await run());
82
+ } catch (err) {
83
+ return toolJson({ ok: false, error: err.message }, { error: true });
84
+ }
85
+ }
86
+
87
+ function toolJson(payload, details = {}) {
88
+ return toolText(JSON.stringify(payload, null, 2), details);
89
+ }
package/src/cli/args.mjs CHANGED
@@ -28,7 +28,7 @@ export function parseCliArgs(argv) {
28
28
  allowPositionals: true,
29
29
  });
30
30
 
31
- const commandName = ["login", "provider", "websearch", "memory"].includes(positionals[0]) ? positionals[0] : null;
31
+ const commandName = ["login", "provider", "websearch", "memory", "browser"].includes(positionals[0]) ? positionals[0] : null;
32
32
 
33
33
  return {
34
34
  command: commandName ? { name: commandName, args: positionals.slice(1) } : null,
@@ -70,6 +70,9 @@ Usage:
70
70
  march memory add <url>
71
71
  march memory list
72
72
  march memory remove <name>
73
+ march browser install Install the developer browser extension
74
+ march browser status Show browser daemon/extension status
75
+ march browser restart Restart the browser daemon
73
76
 
74
77
  Options:
75
78
  -m, --model <id> Initial model ID override
@@ -1,6 +1,6 @@
1
1
  import { stdout } from "node:process";
2
2
  import { extractToolOutput } from "./tool-output.mjs";
3
- import { formatMemoryHintLines } from "./tui/recall-rendering.mjs";
3
+ import { formatRecallLines } from "./tui/recall-rendering.mjs";
4
4
  import { formatToolStartLine } from "./tui/tool-rendering.mjs";
5
5
  import { brightBlack, dim, red, green, yellow } from "./tui/ui-theme.mjs";
6
6
 
@@ -32,7 +32,7 @@ export function createJsonUI() {
32
32
  stdout.write(delta);
33
33
  },
34
34
  status: () => {},
35
- memoryHint: () => {},
35
+ recall: () => {},
36
36
  clearOutput: () => {},
37
37
  restoreTranscript: () => {},
38
38
  setStatusBar: () => {},
@@ -111,9 +111,9 @@ export function createPlainUI() {
111
111
  },
112
112
  textDelta: writeText,
113
113
  status: (text) => { ensureNewline(); stdout.write(`${brightBlack(`● ${text}`)}\n`); },
114
- memoryHint: ({ hints }) => {
114
+ recall: ({ hints }) => {
115
115
  ensureNewline();
116
- for (const line of formatMemoryHintLines(hints)) stdout.write(`${brightBlack(line)}\n`);
116
+ for (const line of formatRecallLines(hints)) stdout.write(`${brightBlack(line)}\n`);
117
117
  },
118
118
  clearOutput: () => {},
119
119
  restoreTranscript: () => {},
@@ -27,8 +27,8 @@ export async function runSingleShotPrompt({
27
27
  const modePrompt = appendModeReminder(prompt, modeState?.get?.());
28
28
  const fullPrompt = appendPromptBlocks(modePrompt, recallBlock, carryoverRecallBlock, shellHints);
29
29
  ui.writeln(formatUserDisplayMessage(prompt));
30
- ui.memoryHint?.({ source: "user", hints: userRecallHints });
31
- if (carryoverRecallHints.length > 0 && !carryoverAlreadyRendered) ui.memoryHint?.({ source: "assistant", hints: carryoverRecallHints });
30
+ ui.recall?.({ source: "user", hints: userRecallHints });
31
+ if (carryoverRecallHints.length > 0 && !carryoverAlreadyRendered) ui.recall?.({ source: "assistant", hints: carryoverRecallHints });
32
32
  refreshStatusBar.startWorking?.();
33
33
  try {
34
34
  await runner.runTurn(fullPrompt, prompt, { userRecallHints, currentProject });
@@ -149,8 +149,8 @@ async function runReplTurn({ prompt, args, runner, memoryStore, currentProject,
149
149
  const fullPrompt = appendPromptBlocks(modePrompt, recallBlock, carryoverRecallBlock, shellHints);
150
150
  try {
151
151
  ui.writeln(formatUserDisplayMessage(prompt));
152
- ui.memoryHint?.({ source: "user", hints: userRecallHints });
153
- if (carryoverRecallHints.length > 0 && !carryoverAlreadyRendered) ui.memoryHint?.({ source: "assistant", hints: carryoverRecallHints });
152
+ ui.recall?.({ source: "user", hints: userRecallHints });
153
+ if (carryoverRecallHints.length > 0 && !carryoverAlreadyRendered) ui.recall?.({ source: "assistant", hints: carryoverRecallHints });
154
154
  setTurnRunning(true);
155
155
  refreshStatusBar.startWorking?.();
156
156
  await runner.runTurn(fullPrompt, prompt, { userRecallHints, currentProject });
@@ -178,6 +178,6 @@ function renderPendingAssistantRecallPreview({ runner, ui }) {
178
178
  if (runner.engine.hasRenderedPendingAssistantRecallHints?.()) return;
179
179
  const hints = runner.engine.peekPendingAssistantRecallHints?.() ?? [];
180
180
  if (hints.length === 0) return;
181
- ui.memoryHint?.({ source: "assistant", hints });
181
+ ui.recall?.({ source: "assistant", hints });
182
182
  runner.engine.markPendingAssistantRecallHintsRendered?.();
183
183
  }
@@ -10,7 +10,8 @@ export class MainPaneLayout {
10
10
  render(width) {
11
11
  const safeWidth = Math.max(1, Math.trunc(width));
12
12
  const statusTopLines = this.statusBar.renderTop?.(safeWidth) ?? this.statusBar.render(safeWidth);
13
- const rawEditorLines = this.editor.render(safeWidth);
13
+ const editorWidth = this.statusBar.inputContentWidth?.(safeWidth) ?? safeWidth;
14
+ const rawEditorLines = this.editor.render(Math.max(1, editorWidth));
14
15
  const editorLines = this.statusBar.renderInputLines?.(rawEditorLines, safeWidth)
15
16
  ?? rawEditorLines.map((line) => this.statusBar.renderInputLine?.(line, safeWidth) ?? line);
16
17
  const statusBottomLines = this.statusBar.renderBottom?.(safeWidth) ?? [];
@@ -1,5 +1,5 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import { R, brightBlack, dim, red } from "../ui-theme.mjs";
2
+ import { R, brightBlack, dim, red, warmAmber } from "../ui-theme.mjs";
3
3
 
4
4
  export function renderToolCardBlock(block, width) {
5
5
  const lines = [];
@@ -7,7 +7,7 @@ export function renderToolCardBlock(block, width) {
7
7
  const marker = block.state === "running" ? "▶" : block.expanded ? "▾" : "▸";
8
8
  const summary = block.summary ? ` · ${block.summary}` : "";
9
9
  const head = `${marker} ${block.title}${summary}`;
10
- appendCardWrapped(lines, border, (block.isError ? red : dim)(head), width);
10
+ appendCardWrapped(lines, border, colorToolHead(block)(head), width);
11
11
 
12
12
  if (block.expanded && block.bodyLines?.length) {
13
13
  lines.push(border);
@@ -16,6 +16,12 @@ export function renderToolCardBlock(block, width) {
16
16
  return lines;
17
17
  }
18
18
 
19
+ function colorToolHead(block) {
20
+ if (block.isError) return red;
21
+ if (block.name === "memory_open") return warmAmber;
22
+ return dim;
23
+ }
24
+
19
25
  function appendCardWrapped(lines, border, text, width, indent = "") {
20
26
  const prefix = `${border} ${indent}`;
21
27
  const contentWidth = Math.max(8, width - visibleWidth(prefix));
@@ -1,8 +1,8 @@
1
- import { brightBlack, warmAmber } from "./ui-theme.mjs";
1
+ import { brightBlack } from "./ui-theme.mjs";
2
2
 
3
3
  const RECALL_ICON = "✦";
4
4
 
5
- export function formatMemoryHintLines(hints = []) {
5
+ export function formatRecallLines(hints = []) {
6
6
  if (!hints.length) return [];
7
7
  const noun = hints.length === 1 ? "note" : "notes";
8
8
  return [
@@ -11,11 +11,10 @@ export function formatMemoryHintLines(hints = []) {
11
11
  ];
12
12
  }
13
13
 
14
- export function writeMemoryHint({ output, hints = [] }) {
15
- const lines = formatMemoryHintLines(hints);
16
- lines.forEach((line, index) => {
17
- if (index === 0) output.writeln(warmAmber(line));
18
- else if (line.startsWith(" ")) output.writeln(brightBlack(line));
14
+ export function writeRecall({ output, hints = [] }) {
15
+ const lines = formatRecallLines(hints);
16
+ lines.forEach((line) => {
17
+ if (line.startsWith(" ")) output.writeln(brightBlack(line));
19
18
  else output.writeln(line);
20
19
  });
21
20
  }
@@ -45,6 +45,10 @@ export class StatusBar {
45
45
  return [`${left}${line}${right}`, ""];
46
46
  }
47
47
 
48
+ inputContentWidth(width) {
49
+ return maxInputContentWidth(width);
50
+ }
51
+
48
52
  renderInputLines(lines, width) {
49
53
  if (width <= 0) return [""];
50
54
  const { left, innerWidth, right } = insetForWidth(width);
@@ -62,9 +66,7 @@ export class StatusBar {
62
66
  if (width <= 0) return "";
63
67
  const paintWidth = inputPaintWidth(width);
64
68
  const prompt = isFirst ? statusBar.prompt(INPUT_PROMPT) : " ";
65
- const promptWidth = visibleWidth(stripAnsi(INPUT_PROMPT));
66
- const maxContentWidth = Math.max(0, paintWidth - promptWidth - 2);
67
- const content = clipToWidth(line, maxContentWidth);
69
+ const content = clipToWidth(line, maxInputContentWidth(width));
68
70
  return applyInputBackground(padToWidth(`${prompt}${content}`, paintWidth));
69
71
  }
70
72
 
@@ -108,6 +110,12 @@ function inputPaintWidth(width) {
108
110
  return safeWidth > 1 ? safeWidth - 1 : safeWidth;
109
111
  }
110
112
 
113
+ function maxInputContentWidth(width) {
114
+ const paintWidth = inputPaintWidth(width);
115
+ const promptWidth = visibleWidth(stripAnsi(INPUT_PROMPT));
116
+ return Math.max(0, paintWidth - promptWidth - 2);
117
+ }
118
+
111
119
  function applyInputBackground(line) {
112
120
  return `${INPUT_BG}${String(line).replaceAll(R, `${R}${INPUT_BG}`)}${R}`;
113
121
  }
@@ -1,6 +1,6 @@
1
1
  import { resolveAttachmentTokens, uniqueAttachmentToken, withLeadingSpace } from "../input/attachment-tokens.mjs";
2
2
 
3
- export function createTuiInputController({ editor, requestRender, historyStore = null }) {
3
+ export function createTuiInputController({ editor, requestRender, historyStore = null, onSubmit = null }) {
4
4
  let onSubmitResolve = null;
5
5
  const attachmentTokens = new Map();
6
6
 
@@ -17,6 +17,7 @@ export function createTuiInputController({ editor, requestRender, historyStore =
17
17
  }
18
18
  clearSubmitState();
19
19
  attachmentTokens.clear();
20
+ onSubmit?.();
20
21
  resolve(resolvedText);
21
22
  };
22
23
  });
package/src/cli/ui.mjs CHANGED
@@ -20,7 +20,7 @@ import { createMouseSelectionController } from "./tui/input/mouse-selection-cont
20
20
  import { ScreenSelection } from "./tui/selection-screen.mjs";
21
21
  import { writeEditDiff } from "./tui/tui-diff-rendering.mjs";
22
22
  import { createTuiInputController } from "./tui/tui-input-controller.mjs";
23
- import { writeMemoryHint } from "./tui/recall-rendering.mjs";
23
+ import { writeRecall } from "./tui/recall-rendering.mjs";
24
24
  import { writeToolEnd, writeToolStart } from "./tui/tool-rendering.mjs";
25
25
  import { EDITOR_THEME, brightBlack } from "./tui/ui-theme.mjs";
26
26
  import { createRenderScheduler } from "./tui/render/render-scheduler.mjs";
@@ -104,12 +104,6 @@ export function createTuiUI({
104
104
  if (copyKeyResult) return copyKeyResult;
105
105
  const dispatched = keybindingDispatcher.dispatch(data);
106
106
  if (dispatched) return dispatched;
107
- // When output is scrolled up, the next render has fewer lines.
108
- // On new input, reset scroll to tail so the editor stays at bottom.
109
- if (output.scrollOffset > 0) {
110
- output.resetScroll();
111
- requestRender();
112
- }
113
107
  if (shellDrawer.isInputActive()) {
114
108
  shellDrawer.sendInput(data);
115
109
  requestRender();
@@ -150,7 +144,12 @@ export function createTuiUI({
150
144
  retryStatus.end({ success, attempt, finalError });
151
145
  }
152
146
 
153
- const inputController = createTuiInputController({ editor, requestRender, historyStore });
147
+ const resetOutputScrollOnSubmit = () => {
148
+ if (output.scrollOffset <= 0) return;
149
+ output.resetScroll();
150
+ requestRender();
151
+ };
152
+ const inputController = createTuiInputController({ editor, requestRender, historyStore, onSubmit: resetOutputScrollOnSubmit });
154
153
 
155
154
  return {
156
155
  readline: (_prompt) => {
@@ -205,8 +204,8 @@ export function createTuiUI({
205
204
  status: (text) => {
206
205
  ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.setOverlayStatus([brightBlack(`● ${text}`)]); requestRender();
207
206
  },
208
- memoryHint: ({ hints }) => {
209
- ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.ensureNewline(); writeMemoryHint({ output, hints }); requestRender();
207
+ recall: ({ hints }) => {
208
+ ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.ensureNewline(); writeRecall({ output, hints }); requestRender();
210
209
  },
211
210
 
212
211
  clearOutput: () => {