march-cli 0.1.12 → 0.1.13

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/agent/command-exec-tool.mjs +42 -8
  3. package/src/agent/runner/runner-utils.mjs +6 -0
  4. package/src/agent/runner.mjs +16 -16
  5. package/src/agent/runtime/ipc/ipc-peer.mjs +99 -0
  6. package/src/agent/runtime/ipc/process-ipc-transport.mjs +16 -0
  7. package/src/agent/runtime/remote-runner-client.mjs +73 -0
  8. package/src/agent/runtime/remote-ui-client.mjs +19 -0
  9. package/src/agent/runtime/runner-ipc-target.mjs +126 -0
  10. package/src/agent/runtime/runner-process-client.mjs +47 -0
  11. package/src/agent/runtime/runner-process-entry.mjs +93 -0
  12. package/src/agent/runtime/ui-event-bridge.mjs +85 -0
  13. package/src/agent/tool-summary.mjs +112 -0
  14. package/src/agent/turn/turn-events.mjs +46 -0
  15. package/src/agent/turn/turn-runner.mjs +2 -1
  16. package/src/cli/commands/copy-command.mjs +16 -2
  17. package/src/cli/commands/status-command.mjs +7 -4
  18. package/src/cli/commands/thinking-command.mjs +10 -3
  19. package/src/cli/repl-loop.mjs +3 -1
  20. package/src/cli/startup/create-runtime-runner.mjs +61 -0
  21. package/src/cli/startup/startup-banner.mjs +64 -10
  22. package/src/cli/tui/layout/main-pane-layout.mjs +16 -7
  23. package/src/cli/tui/selection-screen.mjs +83 -34
  24. package/src/cli/tui/status/status-bar.mjs +154 -18
  25. package/src/cli/tui/tool-rendering.mjs +3 -113
  26. package/src/cli/tui/tui-handlers.mjs +1 -1
  27. package/src/cli/tui/ui-theme.mjs +14 -5
  28. package/src/cli/ui.mjs +1 -1
  29. package/src/context/engine.mjs +10 -9
  30. package/src/context/profiles.mjs +39 -0
  31. package/src/main.mjs +35 -29
  32. package/src/context/center-memory.mjs +0 -14
@@ -0,0 +1,93 @@
1
+ import { join } from "node:path";
2
+ import { createRunner } from "../runner.mjs";
3
+ import { createProcessRuntimeIpcPeer } from "./ipc/process-ipc-transport.mjs";
4
+ import { createRemoteRuntimeUiClient } from "./remote-ui-client.mjs";
5
+ import { createRunnerIpcTarget } from "./runner-ipc-target.mjs";
6
+ import { createMarchAuthStorage } from "../../auth/storage.mjs";
7
+ import { createCliShellRuntime } from "../../shell/cli-runtime.mjs";
8
+ import { MarkdownMemoryStore } from "../../memory/markdown-store.mjs";
9
+ import { createMarkdownMemoryTools } from "../../memory/markdown-tools.mjs";
10
+ import { initializeMcp } from "../../mcp/index.mjs";
11
+ import { createWebToolsFromConfig } from "../../web/tools.mjs";
12
+ import { createPermissionController } from "../../cli/permissions.mjs";
13
+ import { resolvePiSessionManager } from "../../session/pi-manager.mjs";
14
+ import { createModelContextDumper } from "../../debug/model-context-dumper.mjs";
15
+ import { createLogger, installProcessLogHandlers } from "../../debug/logger.mjs";
16
+ import { createDesktopTurnNotifier } from "../../notification/desktop-notifier.mjs";
17
+
18
+ const peer = createProcessRuntimeIpcPeer({
19
+ target: createRunnerIpcTarget({ createRunnerImpl: createIsolatedRunner }),
20
+ });
21
+
22
+ process.once("disconnect", () => peer.dispose());
23
+
24
+ async function createIsolatedRunner(options = {}) {
25
+ const ui = createRemoteRuntimeUiClient(peer);
26
+ const memoryStore = new MarkdownMemoryStore({ root: options.memoryRoot });
27
+ const memoryTools = createMarkdownMemoryTools(memoryStore);
28
+ const shellRuntime = options.shellRuntime ? createCliShellRuntime({ cwd: options.cwd }) : null;
29
+ const mcpInit = await initializeMcp({ projectDir: options.cwd });
30
+ const logger = createLogger({ logDir: options.logDir ?? (options.stateRoot ? join(options.stateRoot, "logs") : undefined) });
31
+ installProcessLogHandlers(logger);
32
+
33
+ const runner = await createRunner({
34
+ cwd: options.cwd,
35
+ modelId: options.modelId,
36
+ provider: options.provider,
37
+ serviceTier: options.serviceTier,
38
+ providers: options.providers,
39
+ stateRoot: options.stateRoot,
40
+ ui,
41
+ memoryRoot: options.memoryRoot,
42
+ profilePaths: options.profilePaths,
43
+ memoryStore,
44
+ memoryTools,
45
+ shellRuntime,
46
+ mcpTools: mcpInit.mcpTools,
47
+ mcpInjections: mcpInit.mcpInjections,
48
+ mcpClientManager: mcpInit.clientManager,
49
+ webTools: createWebToolsFromConfig(options.config ?? {}),
50
+ namespace: options.namespace,
51
+ projectMarchDir: options.projectMarchDir,
52
+ extensionPaths: options.extensionPaths ?? [],
53
+ sessionManager: resolvePiSessionManager({
54
+ cwd: options.cwd,
55
+ projectMarchDir: options.projectMarchDir,
56
+ enabled: true,
57
+ }),
58
+ useRuntimeHost: true,
59
+ syncPiSidecar: true,
60
+ lifecycleHooks: options.lifecycleHooks ?? [],
61
+ lifecycleDiagnostics: options.lifecycleDiagnostics ?? [],
62
+ authStorage: createMarchAuthStorage({
63
+ provider: options.provider ?? "deepseek",
64
+ providers: options.providers,
65
+ cwd: options.cwd,
66
+ }).authStorage,
67
+ maxTurns: options.config?.maxTurns ?? undefined,
68
+ trimBatch: options.config?.trimBatch ?? undefined,
69
+ hostedTools: options.config?.hostedTools,
70
+ permissionController: createPermissionController({ mode: options.permissionMode }),
71
+ modelContextDumper: createModelContextDumper(options.modelContextDumper ?? { enabled: false }),
72
+ turnNotifier: createDesktopTurnNotifier({
73
+ enabled: Boolean(options.config?.notifications?.turnEnd),
74
+ config: options.config?.notifications,
75
+ }),
76
+ logger,
77
+ onModelPayload: (event) => peer.notify("modelPayload", pickModelPayloadEvent(event)),
78
+ });
79
+
80
+ const originalDispose = runner.dispose;
81
+ runner.dispose = async () => {
82
+ try {
83
+ await originalDispose.call(runner);
84
+ } finally {
85
+ memoryStore.close?.();
86
+ }
87
+ };
88
+ return runner;
89
+ }
90
+
91
+ function pickModelPayloadEvent({ estimatedTokens, provider, model, kind, turnId } = {}) {
92
+ return { estimatedTokens, provider, model, kind, turnId };
93
+ }
@@ -0,0 +1,85 @@
1
+ export function createRuntimeUiEventTarget(ui) {
2
+ return {
3
+ uiEvent: (event) => dispatchRuntimeUiEvent(ui, event),
4
+ uiRequest: (event) => dispatchRuntimeUiEvent(ui, event),
5
+ };
6
+ }
7
+
8
+ export function createRuntimeUiEventBus() {
9
+ const listeners = new Set();
10
+ return {
11
+ on(listener) {
12
+ listeners.add(listener);
13
+ return () => listeners.delete(listener);
14
+ },
15
+ emit(event) {
16
+ for (const listener of [...listeners]) listener(event);
17
+ },
18
+ async request(event) {
19
+ let response;
20
+ for (const listener of [...listeners]) {
21
+ const result = await listener(event);
22
+ if (response === undefined && result !== undefined) response = result;
23
+ }
24
+ return response;
25
+ },
26
+ };
27
+ }
28
+
29
+ export function createRuntimeUiBridge(ui, { eventBus = createRuntimeUiEventBus() } = {}) {
30
+ const detach = eventBus.on((event) => dispatchRuntimeUiEvent(ui, event));
31
+ return {
32
+ ui: createRuntimeUiClient(eventBus),
33
+ eventBus,
34
+ detach,
35
+ };
36
+ }
37
+
38
+ export function createRuntimeUiClient(eventBus) {
39
+ return {
40
+ turnStart: () => eventBus.emit({ type: "turn_start" }),
41
+ turnEnd: () => eventBus.emit({ type: "turn_end" }),
42
+ assistantReplyEnd: () => eventBus.emit({ type: "assistant_reply_end" }),
43
+ textDelta: (delta) => eventBus.emit({ type: "text_delta", delta }),
44
+ thinkingStart: () => eventBus.emit({ type: "thinking_start" }),
45
+ thinkingDelta: (delta) => eventBus.emit({ type: "thinking_delta", delta }),
46
+ thinkingEnd: (tokens) => eventBus.emit({ type: "thinking_end", tokens }),
47
+ toolStart: (name, args) => eventBus.emit({ type: "tool_start", name, args }),
48
+ toolEnd: (name, isError, result) => eventBus.emit({ type: "tool_end", name, isError, result }),
49
+ retryStart: (event) => eventBus.emit({ type: "retry_start", ...event }),
50
+ retryEnd: (event) => eventBus.emit({ type: "retry_end", ...event }),
51
+ status: (text) => eventBus.emit({ type: "status", text }),
52
+ memoryHint: ({ source, hints }) => eventBus.emit({ type: "memory_hint", source, hints }),
53
+ editDiff: (path, diffLines) => eventBus.emit({ type: "edit_diff", path, diffLines }),
54
+ requestPermission: (request) => eventBus.request({ type: "permission_request", ...request }),
55
+ };
56
+ }
57
+
58
+ export function dispatchRuntimeUiEvent(ui, event) {
59
+ switch (event.type) {
60
+ case "turn_start": return ui.turnStart?.();
61
+ case "turn_end": return ui.turnEnd?.();
62
+ case "assistant_reply_end": return ui.assistantReplyEnd?.();
63
+ case "text_delta": return ui.textDelta?.(event.delta);
64
+ case "thinking_start": return ui.thinkingStart?.();
65
+ case "thinking_delta": return ui.thinkingDelta?.(event.delta);
66
+ case "thinking_end": return ui.thinkingEnd?.(event.tokens);
67
+ case "tool_start": return ui.toolStart?.(event.name, event.args);
68
+ case "tool_end": return ui.toolEnd?.(event.name, event.isError, event.result);
69
+ case "retry_start": return ui.retryStart?.(pickRetryStart(event));
70
+ case "retry_end": return ui.retryEnd?.(pickRetryEnd(event));
71
+ case "status": return ui.status?.(event.text);
72
+ case "memory_hint": return ui.memoryHint?.({ source: event.source, hints: event.hints });
73
+ case "edit_diff": return ui.editDiff?.(event.path, event.diffLines);
74
+ case "permission_request": return ui.requestPermission?.({ toolName: event.toolName, params: event.params, category: event.category });
75
+ default: return undefined;
76
+ }
77
+ }
78
+
79
+ function pickRetryStart({ attempt, maxAttempts, delayMs, errorMessage }) {
80
+ return { attempt, maxAttempts, delayMs, errorMessage };
81
+ }
82
+
83
+ function pickRetryEnd({ success, attempt, finalError }) {
84
+ return { success, attempt, finalError };
85
+ }
@@ -0,0 +1,112 @@
1
+ export function formatToolStartLine(name, args = {}) {
2
+ if (name === "edit_file") {
3
+ const path = compactPath(args?.path ?? "");
4
+ const editCount = Array.isArray(args?.edits) ? args.edits.length : 0;
5
+ const mode = args?.mode ?? "patch";
6
+ const summary = mode === "patch" ? `${editCount} edit${editCount === 1 ? "" : "s"}` : mode;
7
+ return joinToolParts("◆", name, [path, summary]);
8
+ }
9
+ if (name === "command_exec") return joinToolParts("◆", name, [compactText(args?.command ?? "")]);
10
+ if (name === "terminal_send") return joinToolParts("◆", name, [args?.shell_id, formatTerminalSendAction(args)]);
11
+ if (name?.startsWith?.("terminal_")) return joinToolParts("◆", name, [args?.shell_id, formatTerminalDetails(args)]);
12
+ if (name === "external_web_search") return joinToolParts("◆", name, [quoteCompact(args?.query ?? "")]);
13
+ if (name === "web_fetch") return joinToolParts("◆", name, [compactText(args?.url ?? "")]);
14
+ if (name === "context_stats") return joinToolParts("◆", name, []);
15
+ if (name === "read") {
16
+ const path = compactPath(args?.path ?? args?.filePath ?? "");
17
+ return joinToolParts("→", name, [path, formatReadRange(args)]);
18
+ }
19
+ if (name === "grep") {
20
+ const path = compactPath(args?.path ?? "");
21
+ return joinToolParts("✱", name, [quoteCompact(args?.pattern ?? ""), path]);
22
+ }
23
+ if (name === "glob") {
24
+ const path = compactPath(args?.path ?? "");
25
+ return joinToolParts("✱", name, [quoteCompact(args?.pattern ?? ""), path]);
26
+ }
27
+ if (name === "find") {
28
+ const path = compactPath(args?.path ?? "");
29
+ return joinToolParts("✱", name, [quoteCompact(args?.pattern ?? ""), path]);
30
+ }
31
+ return joinToolParts("◆", name, [formatSmallOptions(args)]);
32
+ }
33
+
34
+ export function formatToolSuccessSummary(name, result, out = "") {
35
+ if (name === "grep") {
36
+ const matches = result?.details?.results?.length ?? countMatchLines(out);
37
+ return `${matches} match${matches === 1 ? "" : "es"}`;
38
+ }
39
+ if (name === "glob") {
40
+ const matches = Array.isArray(result?.details?.matches) ? result.details.matches.length : countNonEmptyLines(out);
41
+ return `${matches} file${matches === 1 ? "" : "s"}`;
42
+ }
43
+ if (name === "find") {
44
+ const matches = result?.details?.count ?? countNonEmptyLines(out);
45
+ return `${matches} file${matches === 1 ? "" : "s"}`;
46
+ }
47
+ if (name === "memory_open") {
48
+ return compactText(result?.details?.entry?.name ?? compactPath(result?.details?.path ?? ""));
49
+ }
50
+ return "";
51
+ }
52
+
53
+ function joinToolParts(icon, name, parts) {
54
+ const clean = parts.map((part) => String(part ?? "").trim()).filter(Boolean);
55
+ return `${icon} ${name}${clean.length ? ` · ${clean.join(" · ")}` : ""}`;
56
+ }
57
+
58
+ function formatReadRange(args = {}) {
59
+ if (args.offset == null && args.limit == null) return "";
60
+ if (args.offset != null && args.limit != null) return `lines ${args.offset}-${Number(args.offset) + Number(args.limit) - 1}`;
61
+ if (args.offset != null) return `from line ${args.offset}`;
62
+ return `limit ${args.limit}`;
63
+ }
64
+
65
+ function formatTerminalSendAction(args = {}) {
66
+ const hasText = typeof args.text === "string" && args.text.length > 0;
67
+ const key = args.key ? String(args.key) : "";
68
+ if (hasText && key) return `text+${key}`;
69
+ if (hasText) return args.text.includes("\n") || args.text.includes("\r") ? "text+enter" : "text";
70
+ return key || "send";
71
+ }
72
+
73
+ function formatTerminalDetails(args = {}) {
74
+ const details = [];
75
+ if (args.pattern) details.push(quoteCompact(args.pattern));
76
+ if (args.cols && args.rows) details.push(`${args.cols}x${args.rows}`);
77
+ if (args.command) details.push(compactText(args.command));
78
+ return details.join(" · ");
79
+ }
80
+
81
+ function formatSmallOptions(args = {}) {
82
+ const parts = [];
83
+ for (const [key, value] of Object.entries(args ?? {})) {
84
+ if (value == null || typeof value === "object") continue;
85
+ parts.push(`${key}=${compactText(value)}`);
86
+ if (parts.length >= 2) break;
87
+ }
88
+ return parts.join(", ");
89
+ }
90
+
91
+ function compactPath(path) {
92
+ return String(path ?? "").split(/[/\\]/).filter(Boolean).slice(-4).join("\\");
93
+ }
94
+
95
+ function quoteCompact(value) {
96
+ return JSON.stringify(compactText(value));
97
+ }
98
+
99
+ function compactText(value, limit = 80) {
100
+ const text = String(value ?? "").replace(/\s+/g, " ").trim();
101
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
102
+ }
103
+
104
+ function countMatchLines(text) {
105
+ const match = String(text ?? "").match(/(\d+)\s+matches?\b/i);
106
+ if (match) return Number(match[1]);
107
+ return countNonEmptyLines(text);
108
+ }
109
+
110
+ function countNonEmptyLines(text) {
111
+ return String(text ?? "").split("\n").filter(Boolean).length;
112
+ }
@@ -1,3 +1,5 @@
1
+ import { formatToolStartLine, formatToolSuccessSummary } from "../tool-summary.mjs";
2
+
1
3
  export function createTurnEventState() {
2
4
  return {
3
5
  draft: "",
@@ -5,6 +7,8 @@ export function createTurnEventState() {
5
7
  thinkingAccumulator: "",
6
8
  recallCursor: { draftLength: 0, thinkingLength: 0 },
7
9
  assistantReplyOpen: false,
10
+ assistantContextParts: [],
11
+ activeToolContextPart: null,
8
12
  };
9
13
  }
10
14
 
@@ -14,9 +18,11 @@ export function handleRunnerSessionEvent(event, { ui, engine, state }) {
14
18
  }
15
19
  if (event.type === "tool_execution_start") {
16
20
  closeAssistantReply({ ui, state });
21
+ appendToolStartContext(state, event.toolName, event.args);
17
22
  ui.toolStart(event.toolName, event.args);
18
23
  }
19
24
  if (event.type === "tool_execution_end") {
25
+ updateToolEndContext(state, event.toolName, event.isError, event.result);
20
26
  ui.toolEnd(event.toolName, event.isError, event.result);
21
27
  }
22
28
  if (event.type === "auto_retry_start") {
@@ -45,6 +51,7 @@ export function closeAssistantReply({ ui, state }) {
45
51
  function handleAssistantMessageEvent(event, { ui, state }) {
46
52
  if (event.type === "text_delta") {
47
53
  state.draft += event.delta;
54
+ appendAssistantContextText(state, event.delta, "output");
48
55
  state.assistantReplyOpen = true;
49
56
  ui.textDelta(event.delta);
50
57
  }
@@ -54,6 +61,7 @@ function handleAssistantMessageEvent(event, { ui, state }) {
54
61
  }
55
62
  if (event.type === "thinking_delta") {
56
63
  state.thinkingText += event.delta;
64
+ appendAssistantContextText(state, event.delta, "thinking");
57
65
  ui.thinkingDelta(event.delta);
58
66
  }
59
67
  if (event.type === "thinking_end" && state.thinkingText) {
@@ -63,3 +71,41 @@ function handleAssistantMessageEvent(event, { ui, state }) {
63
71
  state.thinkingText = "";
64
72
  }
65
73
  }
74
+
75
+ export function compactAssistantContext(state) {
76
+ return (state?.assistantContextParts ?? [])
77
+ .map((part) => part?.text ?? "")
78
+ .join("")
79
+ .replace(/[ \t]+\n/g, "\n")
80
+ .replace(/\n{3,}/g, "\n\n")
81
+ .trim();
82
+ }
83
+
84
+ function appendAssistantContextText(state, text, type) {
85
+ if (!text) return;
86
+ const parts = state.assistantContextParts;
87
+ const last = parts.at(-1);
88
+ if (last?.type === type) {
89
+ last.text += text;
90
+ return;
91
+ }
92
+ if (last && !last.text.endsWith("\n")) last.text += "\n";
93
+ parts.push({ type, text });
94
+ }
95
+
96
+ function appendToolStartContext(state, name, args) {
97
+ const parts = state.assistantContextParts;
98
+ const last = parts.at(-1);
99
+ if (last && !last.text.endsWith("\n")) last.text += "\n";
100
+ const part = { type: "tool", name, text: `${formatToolStartLine(name, args)}\n` };
101
+ parts.push(part);
102
+ state.activeToolContextPart = part;
103
+ }
104
+
105
+ function updateToolEndContext(state, name, isError, result) {
106
+ const part = state.activeToolContextPart;
107
+ if (!part || part.name !== name) return;
108
+ const summary = isError ? "failed" : formatToolSuccessSummary(name, result, "");
109
+ if (summary && summary !== "done") part.text = `${part.text.trimEnd()} (${summary})\n`;
110
+ state.activeToolContextPart = null;
111
+ }
@@ -1,5 +1,5 @@
1
1
  import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
2
- import { closeAssistantReply, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
2
+ import { closeAssistantReply, compactAssistantContext, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
3
3
 
4
4
  export async function runRunnerTurn({
5
5
  prompt,
@@ -122,6 +122,7 @@ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, me
122
122
  engine.recordTurn({
123
123
  userMessage: userMessage ?? prompt.slice(0, 300),
124
124
  assistantMessage: turnState.draft,
125
+ assistantContext: compactAssistantContext(turnState),
125
126
  userRecallHints,
126
127
  assistantRecallHints: recordedAssistantRecallHints,
127
128
  });
@@ -61,13 +61,27 @@ export function writeSystemClipboardAsync(text, { platform = process.platform }
61
61
  });
62
62
  }
63
63
 
64
- function clipboardCommand(platform) {
64
+ export function clipboardCommand(platform) {
65
65
  if (platform === "win32") {
66
66
  return {
67
67
  bin: "powershell.exe",
68
- args: ["-NoProfile", "-Command", "Set-Clipboard -Value ([Console]::In.ReadToEnd())"],
68
+ args: ["-NoProfile", "-EncodedCommand", encodePowerShellCommand(readUtf8StdinToClipboardScript())],
69
69
  };
70
70
  }
71
71
  if (platform === "darwin") return { bin: "pbcopy", args: [] };
72
72
  return { bin: "sh", args: ["-lc", "command -v wl-copy >/dev/null && wl-copy || xclip -selection clipboard || xsel --clipboard --input"] };
73
73
  }
74
+
75
+ function readUtf8StdinToClipboardScript() {
76
+ return [
77
+ "$stdin = [Console]::OpenStandardInput()",
78
+ "$memory = New-Object System.IO.MemoryStream",
79
+ "$stdin.CopyTo($memory)",
80
+ "$text = [System.Text.Encoding]::UTF8.GetString($memory.ToArray())",
81
+ "Set-Clipboard -Value $text",
82
+ ].join("; ");
83
+ }
84
+
85
+ function encodePowerShellCommand(script) {
86
+ return Buffer.from(script, "utf16le").toString("base64");
87
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { MODES, formatModeLabel } from "../input/mode-state.mjs";
3
- import { PREFIX, R } from "../tui/ui-theme.mjs";
3
+ import { modeLabel, PREFIX, R } from "../tui/ui-theme.mjs";
4
4
 
5
5
  export function statusCommand({
6
6
  runner,
@@ -78,9 +78,7 @@ export function formatStatusBarLine({
78
78
 
79
79
  const C = PREFIX; // foreground-only color prefixes (no reset)
80
80
  const DIM = C.brightBlack;
81
- const OK = "\x1b[32m"; // green, no reset
82
- const WARN = "\x1b[33m"; // yellow, no reset
83
- const modeSegment = `${mode === MODES.DISCUSS ? WARN : OK}${formatModeLabel(mode)}`;
81
+ const modeSegment = formatModeSegment(mode);
84
82
  const runtime = `${C.cyan}${model}${DIM}·${thinking}`;
85
83
  const segments = [modeSegment, runtime];
86
84
  const lspText = formatLspSegment(lspStatus);
@@ -94,6 +92,11 @@ export function formatStatusBarLine({
94
92
  return `${inner}${R}`;
95
93
  }
96
94
 
95
+ function formatModeSegment(mode) {
96
+ const label = formatModeLabel(mode);
97
+ return (modeLabel[mode] ?? modeLabel.fallback)(label);
98
+ }
99
+
97
100
  export function formatLspSegment(lspStatus) {
98
101
  if (!lspStatus) return "";
99
102
  const servers = lspStatus.servers ?? [];
@@ -55,9 +55,9 @@ export async function handleThinkingCommand(parsed, { runner, ui = null } = {})
55
55
  runner.getThinkingLevel?.(),
56
56
  );
57
57
  }
58
- if (parsed.type === "select") return [selectThinkingByIndex(parsed.index, { runner })];
58
+ if (parsed.type === "select") return [await selectThinkingByIndexAsync(parsed.index, { runner })];
59
59
  if (parsed.type === "set") {
60
- const level = runner.setThinkingLevel(parsed.level);
60
+ const level = await runner.setThinkingLevel(parsed.level);
61
61
  return [`thinking: ${level}`];
62
62
  }
63
63
  if (parsed.type === "error") return [`Error: ${parsed.message}`];
@@ -76,5 +76,12 @@ async function selectThinkingInteractively({ runner, ui }) {
76
76
  suppressInitialConfirm: true,
77
77
  });
78
78
  if (!item) return "thinking: unchanged";
79
- return `thinking: ${runner.setThinkingLevel(item.level)}`;
79
+ return `thinking: ${await runner.setThinkingLevel(item.level)}`;
80
+ }
81
+
82
+ async function selectThinkingByIndexAsync(index, { runner }) {
83
+ const levels = runner.getAvailableThinkingLevels?.() || THINKING_LEVELS;
84
+ const level = levels[index - 1];
85
+ if (!level) return `Error: thinking index out of range: ${index}`;
86
+ return `thinking: ${await runner.setThinkingLevel(level)}`;
80
87
  }
@@ -119,7 +119,9 @@ export async function runInteractiveRepl({
119
119
  export function contextTokenRefreshOptions(slashResult, runner) {
120
120
  if (!slashResult?.refreshContextTokens) return undefined;
121
121
  if (typeof runner.estimateContextTokens !== "function") return undefined;
122
- return { contextTokens: runner.estimateContextTokens("") };
122
+ const contextTokens = runner.estimateContextTokens("");
123
+ if (contextTokens && typeof contextTokens.then === "function") return undefined;
124
+ return { contextTokens };
123
125
  }
124
126
 
125
127
  function handleInlineCommand(trimmed, { cwd, ui, lastInlineShellCommand }) {
@@ -0,0 +1,61 @@
1
+ import { createRunner } from "../../agent/runner.mjs";
2
+ import { createRunnerProcessClient } from "../../agent/runtime/runner-process-client.mjs";
3
+ import { resolvePiSessionManager } from "../../session/pi-manager.mjs";
4
+
5
+ export async function createRuntimeRunner({
6
+ useRuntimeProcess = false,
7
+ runnerOptions,
8
+ ui,
9
+ memoryStore,
10
+ memoryTools,
11
+ shellRuntime,
12
+ mcpTools,
13
+ mcpInjections,
14
+ mcpClientManager,
15
+ webTools,
16
+ usePiSessions,
17
+ usePiRuntimeHost,
18
+ authStorage,
19
+ permissionController,
20
+ modelContextDumper,
21
+ turnNotifier,
22
+ logger,
23
+ refreshStatusBar,
24
+ } = {}) {
25
+ const onModelPayload = ({ estimatedTokens }) => {
26
+ refreshStatusBar?.({ contextTokens: estimatedTokens });
27
+ };
28
+
29
+ const runner = useRuntimeProcess
30
+ ? (await createRunnerProcessClient({ runnerOptions, ui, onModelPayload })).runner
31
+ : await createRunner({
32
+ ...runnerOptions,
33
+ ui,
34
+ memoryStore,
35
+ memoryTools,
36
+ shellRuntime,
37
+ mcpTools,
38
+ mcpInjections,
39
+ mcpClientManager,
40
+ webTools,
41
+ sessionManager: resolvePiSessionManager({
42
+ cwd: runnerOptions.cwd,
43
+ projectMarchDir: runnerOptions.projectMarchDir,
44
+ enabled: usePiSessions,
45
+ }),
46
+ useRuntimeHost: usePiRuntimeHost,
47
+ syncPiSidecar: usePiSessions || usePiRuntimeHost,
48
+ authStorage,
49
+ maxTurns: runnerOptions.config?.maxTurns ?? undefined,
50
+ trimBatch: runnerOptions.config?.trimBatch ?? undefined,
51
+ hostedTools: runnerOptions.config?.hostedTools,
52
+ permissionController,
53
+ modelContextDumper,
54
+ turnNotifier,
55
+ logger,
56
+ onModelPayload,
57
+ });
58
+
59
+ runner.shellRuntime ??= shellRuntime;
60
+ return runner;
61
+ }
@@ -1,17 +1,71 @@
1
- import { MODES } from "../input/mode-state.mjs";
2
- import { bold, brightBlack, cyan } from "../tui/ui-theme.mjs";
1
+ import { createRequire } from "node:module";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { MODES, formatModeLabel } from "../input/mode-state.mjs";
4
+ import { brightBlack, cyan, modeLabel, white } from "../tui/ui-theme.mjs";
5
+
6
+ const CARD_WIDTH = 76;
7
+ const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
8
+ const { version: packageVersion = "0.1" } = createRequire(import.meta.url)("../../../package.json");
3
9
 
4
10
  export function formatStartupBanner({ cwd, modelId = "model?", thinkingLevel = "thinking?", mode = MODES.DO, dumpContextPath = null } = {}) {
5
- const nextMode = mode === MODES.DISCUSS ? "Do" : "Discuss";
6
- const hint = dumpContextPath
7
- ? brightBlack(`dumps: ${dumpContextPath}`)
8
- : brightBlack(`Tab to ${nextMode} · /help`);
11
+ const nextMode = mode === MODES.DISCUSS ? MODES.DO : MODES.DISCUSS;
12
+ const tip = dumpContextPath
13
+ ? `${brightBlack("Tip:")} ${cyan("dumps:")} ${brightBlack(dumpContextPath)}`
14
+ : `${brightBlack("Tip:")} ${brightBlack("Tab to")} ${formatModeTip(nextMode)} ${brightBlack("·")} ${cyan("/help")} ${brightBlack("for commands")}`;
9
15
  return [
10
- `${cyan(" █▙ ▟█")} ${bold("March")}`,
11
- `${cyan(" █▜▙▟▛█")} ${brightBlack(`${modelId} · ${thinkingLevel}`)}`,
12
- `${cyan(" ▀ ▀")} ${brightBlack(cwd ?? "")}`,
13
16
  "",
14
- ` ${hint}`,
17
+ ...renderStartupCard([
18
+ `${cyan(" █▙ ▟█")} ${white("March")} ${brightBlack(`v${packageVersion}`)}`,
19
+ `${cyan(" █▜▙▟▛█")} ${brightBlack("Describe a task to get started.")}`,
20
+ `${cyan(" ▀ ▀")}`,
21
+ "",
22
+ tip,
23
+ brightBlack("March uses AI. Check for mistakes."),
24
+ ]),
15
25
  "",
16
26
  ];
17
27
  }
28
+
29
+ function formatModeTip(mode) {
30
+ const label = formatModeLabel(mode);
31
+ return (modeLabel[mode] ?? modeLabel.fallback)(label);
32
+ }
33
+
34
+ function renderStartupCard(contentLines, width = CARD_WIDTH) {
35
+ const safeWidth = Math.max(24, Math.trunc(width));
36
+ const innerWidth = safeWidth - 4;
37
+ return [
38
+ white(`╭${"─".repeat(safeWidth - 2)}╮`),
39
+ ...contentLines.map((line) => white("│ ") + padAnsi(line, innerWidth) + white(" │")),
40
+ white(`╰${"─".repeat(safeWidth - 2)}╯`),
41
+ ];
42
+ }
43
+
44
+ function padAnsi(text, width) {
45
+ const clipped = clipAnsi(text, width);
46
+ const padding = Math.max(0, width - visibleWidth(stripAnsi(clipped)));
47
+ return `${clipped}${" ".repeat(padding)}`;
48
+ }
49
+
50
+ function clipAnsi(text, width) {
51
+ let output = "";
52
+ let plainWidth = 0;
53
+ let inAnsi = false;
54
+ for (const ch of Array.from(String(text ?? ""))) {
55
+ if (ch === "\x1b") inAnsi = true;
56
+ if (inAnsi) {
57
+ output += ch;
58
+ if (/[@-~]/.test(ch)) inAnsi = false;
59
+ continue;
60
+ }
61
+ const charWidth = visibleWidth(ch);
62
+ if (plainWidth + charWidth > width) break;
63
+ output += ch;
64
+ plainWidth += charWidth;
65
+ }
66
+ return output;
67
+ }
68
+
69
+ function stripAnsi(text) {
70
+ return String(text ?? "").replace(ANSI_RE, "");
71
+ }
@@ -9,19 +9,28 @@ export class MainPaneLayout {
9
9
 
10
10
  render(width) {
11
11
  const safeWidth = Math.max(1, Math.trunc(width));
12
- const statusLines = this.statusBar.render(safeWidth);
13
- const editorLines = this.editor.render(safeWidth);
14
- const fixedHeight = statusLines.length + editorLines.length;
12
+ const statusTopLines = this.statusBar.renderTop?.(safeWidth) ?? this.statusBar.render(safeWidth);
13
+ const rawEditorLines = this.editor.render(safeWidth);
14
+ const editorLines = this.statusBar.renderInputLines?.(rawEditorLines, safeWidth)
15
+ ?? rawEditorLines.map((line) => this.statusBar.renderInputLine?.(line, safeWidth) ?? line);
16
+ const statusBottomLines = this.statusBar.renderBottom?.(safeWidth) ?? [];
17
+ const fixedHeight = statusTopLines.length + editorLines.length + statusBottomLines.length;
15
18
  const viewportHeight = Math.max(1, (this.terminal?.rows || 30) - fixedHeight);
16
19
  this.output.setViewportHeight(viewportHeight);
17
20
  const outputLines = this.output.render(safeWidth);
18
21
  const outputTop = Math.max(0, viewportHeight - outputLines.length);
19
- this.selection?.setViewport({ topRow: outputTop, leftCol: 0, width: safeWidth, lines: outputLines });
20
- const selectedOutputLines = this.selection?.apply(outputLines) ?? outputLines;
22
+ const editorTop = viewportHeight + statusTopLines.length;
23
+ this.selection?.setRegions?.([
24
+ { id: "output", topRow: outputTop, leftCol: 0, width: safeWidth, lines: outputLines },
25
+ { id: "editor", topRow: editorTop, leftCol: 0, width: safeWidth, lines: editorLines },
26
+ ]);
27
+ const selectedOutputLines = this.selection?.applyRegion?.("output", outputLines) ?? outputLines;
28
+ const selectedEditorLines = this.selection?.applyRegion?.("editor", editorLines) ?? editorLines;
21
29
  return [
22
30
  ...padToHeight(selectedOutputLines, viewportHeight),
23
- ...statusLines,
24
- ...editorLines,
31
+ ...statusTopLines,
32
+ ...selectedEditorLines,
33
+ ...statusBottomLines,
25
34
  ];
26
35
  }
27
36