march-cli 0.1.12 → 0.1.14

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 (50) hide show
  1. package/package.json +2 -1
  2. package/src/agent/command-exec-tool.mjs +42 -8
  3. package/src/agent/{read-file-tool.mjs → file-tools/read-file-tool.mjs} +2 -2
  4. package/src/agent/file-tools/read-image-tool.mjs +76 -0
  5. package/src/agent/runner/runner-utils.mjs +6 -0
  6. package/src/agent/runner.mjs +17 -16
  7. package/src/agent/runtime/ipc/ipc-peer.mjs +99 -0
  8. package/src/agent/runtime/ipc/process-ipc-transport.mjs +16 -0
  9. package/src/agent/runtime/remote-runner-client.mjs +73 -0
  10. package/src/agent/runtime/remote-ui-client.mjs +19 -0
  11. package/src/agent/runtime/runner-ipc-target.mjs +126 -0
  12. package/src/agent/runtime/runner-process-client.mjs +47 -0
  13. package/src/agent/runtime/runner-process-entry.mjs +93 -0
  14. package/src/agent/runtime/runner-runtime-host.mjs +1 -0
  15. package/src/agent/runtime/ui-event-bridge.mjs +85 -0
  16. package/src/agent/screen-tools/list-windows-tool.mjs +39 -0
  17. package/src/agent/screen-tools/screen-tool.mjs +49 -0
  18. package/src/agent/screen-tools/windows-screen.mjs +133 -0
  19. package/src/agent/session/session-options.mjs +2 -1
  20. package/src/agent/tool-summary.mjs +112 -0
  21. package/src/agent/tools.mjs +12 -5
  22. package/src/agent/turn/turn-events.mjs +46 -0
  23. package/src/agent/turn/turn-runner.mjs +2 -1
  24. package/src/agent/vision-capability.mjs +14 -0
  25. package/src/cli/args.mjs +8 -0
  26. package/src/cli/commands/copy-command.mjs +16 -2
  27. package/src/cli/commands/status-command.mjs +7 -4
  28. package/src/cli/commands/thinking-command.mjs +10 -3
  29. package/src/cli/repl-loop.mjs +3 -1
  30. package/src/cli/startup/create-runtime-runner.mjs +61 -0
  31. package/src/cli/startup/startup-banner.mjs +64 -10
  32. package/src/cli/tui/layout/main-pane-layout.mjs +16 -7
  33. package/src/cli/tui/selection-screen.mjs +83 -34
  34. package/src/cli/tui/status/status-bar.mjs +155 -18
  35. package/src/cli/tui/tool-rendering.mjs +3 -113
  36. package/src/cli/tui/tui-handlers.mjs +1 -1
  37. package/src/cli/tui/ui-theme.mjs +14 -5
  38. package/src/cli/ui.mjs +1 -1
  39. package/src/config/config-json.mjs +11 -0
  40. package/src/context/engine.mjs +10 -9
  41. package/src/context/profiles.mjs +39 -0
  42. package/src/context/system-core/base.md +2 -1
  43. package/src/main.mjs +42 -35
  44. package/src/provider/accept-command.mjs +89 -0
  45. package/src/provider/command.mjs +21 -0
  46. package/src/provider/custom-provider.mjs +5 -4
  47. package/src/provider/share-command.mjs +79 -0
  48. package/src/provider/share-payload.mjs +52 -0
  49. package/src/supergrok/tool.mjs +6 -6
  50. package/src/context/center-memory.mjs +0 -14
@@ -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
  });
@@ -0,0 +1,14 @@
1
+ export function modelSupportsImageInput(model) {
2
+ if (!model || typeof model !== "object") return false;
3
+ if (Array.isArray(model.input) && model.input.includes("image")) return true;
4
+ if (model.capabilities?.images === true || model.capabilities?.vision === true) return true;
5
+ return false;
6
+ }
7
+
8
+ export function currentModelImageInputError(getCurrentModel) {
9
+ if (typeof getCurrentModel !== "function") return null;
10
+ const model = getCurrentModel();
11
+ if (modelSupportsImageInput(model)) return null;
12
+ const label = model ? `${model.name || model.id || "unknown"} (${model.provider || "unknown provider"})` : "unknown";
13
+ return `Current model does not support image input: ${label}. Switch to a vision-capable model before using read_image or screen.`;
14
+ }
package/src/cli/args.mjs CHANGED
@@ -11,6 +11,8 @@ export function parseCliArgs(argv) {
11
11
  extension: { type: "string", short: "e", multiple: true },
12
12
  extension: { type: "string", short: "e", multiple: true },
13
13
  config: { type: "boolean" },
14
+ "include-key": { type: "boolean" },
15
+ "profile-only": { type: "boolean" },
14
16
  "dump-context": { type: "boolean" },
15
17
  "pi-sessions": { type: "boolean" },
16
18
  "pi-runtime-host": { type: "boolean" },
@@ -33,6 +35,8 @@ export function parseCliArgs(argv) {
33
35
  extensions: values.extension ?? [],
34
36
  dumpContext: values["dump-context"] ?? false,
35
37
  providerConfig: values.config ?? false,
38
+ includeKey: values["include-key"] ?? false,
39
+ profileOnly: values["profile-only"] ?? false,
36
40
  piSessions: values["pi-sessions"] ?? false,
37
41
  piRuntimeHost: values["pi-runtime-host"] ?? false,
38
42
  shellRuntime: values["no-shell-runtime"] ? false : true,
@@ -50,6 +54,8 @@ Usage:
50
54
  march [options] (starts REPL)
51
55
  march login [provider] Login to an OAuth provider
52
56
  march provider --config Configure provider credentials
57
+ march provider share [id] Share a provider profile
58
+ march provider accept <token>
53
59
  march websearch --config Configure web search credentials
54
60
 
55
61
  Options:
@@ -58,6 +64,8 @@ Options:
58
64
  --resume <id> Resume a pi session by default
59
65
  --json JSON output mode (no TUI)
60
66
  --config With provider/websearch command, open configuration
67
+ --include-key With provider share, include API key
68
+ --profile-only With provider share, omit API key
61
69
  --dump-context Write every prompt sent to the model under .march/context-dumps/
62
70
  --pi-sessions Force pi JSONL SessionManager persistence
63
71
  --pi-runtime-host Force pi AgentSessionRuntime host path
@@ -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
 
@@ -9,30 +9,37 @@ export class ScreenSelection {
9
9
  this.active = false;
10
10
  this.anchor = null;
11
11
  this.focus = null;
12
- this.lines = [];
13
- this._plainLines = [];
12
+ this.regions = [];
13
+ this._plainLines = new Map();
14
14
  this.viewport = { topRow: 0, leftCol: 0, width: Infinity, height: 0 };
15
15
  }
16
16
 
17
17
  setLines(lines) {
18
- this.lines = [...lines];
19
- this._plainLines = [];
20
- this.viewport = { topRow: 0, leftCol: 0, width: Infinity, height: this.lines.length };
18
+ this.setViewport({ topRow: 0, leftCol: 0, width: Infinity, lines });
21
19
  }
22
20
 
23
21
  setViewport({ topRow = 0, leftCol = 0, width = Infinity, lines = [] } = {}) {
24
- this.lines = lines;
25
- this._plainLines = [];
26
- this.viewport = {
27
- topRow: Math.max(0, Math.trunc(topRow)),
28
- leftCol: Math.max(0, Math.trunc(leftCol)),
29
- width: Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : Infinity,
30
- height: this.lines.length,
31
- };
22
+ this.setRegions([{ id: "default", topRow, leftCol, width, lines }]);
23
+ }
24
+
25
+ setRegions(regions = []) {
26
+ let docRow = 0;
27
+ this.regions = regions
28
+ .map((region, index) => normalizeRegion(region, index))
29
+ .filter((region) => region.lines.length > 0)
30
+ .sort((a, b) => a.topRow - b.topRow || a.leftCol - b.leftCol)
31
+ .map((region) => {
32
+ const normalized = { ...region, docStart: docRow };
33
+ docRow += region.lines.length;
34
+ return normalized;
35
+ });
36
+ this.lines = this.regions.flatMap((region) => region.lines);
37
+ this._plainLines = new Map();
38
+ this.viewport = { topRow: 0, leftCol: 0, width: Infinity, height: this.lines.length };
32
39
  }
33
40
 
34
41
  start(point) {
35
- const normalized = normalizePoint(point, this.viewport, true);
42
+ const normalized = normalizePoint(point, this.regions, true);
36
43
  if (!normalized) {
37
44
  this.clear();
38
45
  return false;
@@ -45,13 +52,13 @@ export class ScreenSelection {
45
52
 
46
53
  update(point) {
47
54
  if (!this.active || !this.anchor) return false;
48
- this.focus = normalizePoint(point, this.viewport, true) ?? this.focus;
55
+ this.focus = normalizePoint(point, this.regions, true) ?? this.focus;
49
56
  return true;
50
57
  }
51
58
 
52
59
  finish(point, { clear = true } = {}) {
53
60
  if (!this.active || !this.anchor) return "";
54
- this.focus = normalizePoint(point, this.viewport, true) ?? this.focus;
61
+ this.focus = normalizePoint(point, this.regions, true) ?? this.focus;
55
62
  const text = this.text();
56
63
  if (clear) this.clear();
57
64
  else this.active = false;
@@ -80,21 +87,27 @@ export class ScreenSelection {
80
87
  }
81
88
 
82
89
  apply(lines) {
90
+ return this.applyRegion("default", lines);
91
+ }
92
+
93
+ applyRegion(id, lines) {
83
94
  const range = this.range();
84
- if (!range) return lines;
95
+ const region = this.regions.find((candidate) => candidate.id === id);
96
+ if (!range || !region) return lines;
85
97
  return lines.map((line, row) => {
86
- if (row < range.start.row || row > range.end.row) return line;
87
- const plain = this._plainLine(row);
88
- const startCol = row === range.start.row ? range.start.col : 0;
89
- const endCol = row === range.end.row ? range.end.col : visibleWidth(plain);
98
+ const docRow = region.docStart + row;
99
+ if (docRow < range.start.row || docRow > range.end.row) return line;
100
+ const plain = this._plainLine(docRow);
101
+ const startCol = docRow === range.start.row ? range.start.col : 0;
102
+ const endCol = docRow === range.end.row ? range.end.col : visibleWidth(plain);
90
103
  if (endCol <= startCol) return line;
91
104
  return highlightAnsiLine(line, startCol, endCol);
92
105
  });
93
106
  }
94
107
 
95
108
  _plainLine(row) {
96
- if (this._plainLines[row] == null) this._plainLines[row] = stripAnsi(this.lines[row] ?? "");
97
- return this._plainLines[row];
109
+ if (!this._plainLines.has(row)) this._plainLines.set(row, stripAnsi(this.lines[row] ?? ""));
110
+ return this._plainLines.get(row);
98
111
  }
99
112
 
100
113
  range() {
@@ -111,19 +124,55 @@ export function stripAnsi(text) {
111
124
  return String(text ?? "").replace(CONTROL_RE, "");
112
125
  }
113
126
 
114
- function normalizePoint({ row, col }, viewport, clamp) {
127
+ function normalizeRegion(region, index) {
128
+ const lines = [...(region.lines ?? [])];
129
+ const width = Number.isFinite(region.width) ? Math.max(1, Math.trunc(region.width)) : Infinity;
130
+ return {
131
+ id: region.id ?? `region-${index}`,
132
+ lines,
133
+ topRow: Math.max(0, Math.trunc(region.topRow ?? 0)),
134
+ leftCol: Math.max(0, Math.trunc(region.leftCol ?? 0)),
135
+ width,
136
+ };
137
+ }
138
+
139
+ function normalizePoint({ row, col }, regions, clamp) {
115
140
  const screenRow = Math.trunc(row) - 1;
116
141
  const screenCol = Math.trunc(col) - 1;
117
- const height = viewport?.height ?? 0;
118
- if (height <= 0) return null;
119
-
120
- let localRow = screenRow - (viewport?.topRow ?? 0);
121
- let localCol = screenCol - (viewport?.leftCol ?? 0);
122
- const maxCol = Number.isFinite(viewport?.width) ? viewport.width : Infinity;
123
- if (!clamp && (localRow < 0 || localRow >= height || localCol < 0 || localCol > maxCol)) return null;
124
- localRow = clampNumber(localRow, 0, height - 1);
125
- localCol = clampNumber(localCol, 0, maxCol);
126
- return { row: localRow, col: localCol };
142
+ if (regions.length === 0) return null;
143
+
144
+ for (const region of regions) {
145
+ const localRow = screenRow - region.topRow;
146
+ const localCol = screenCol - region.leftCol;
147
+ const maxCol = Number.isFinite(region.width) ? region.width : Infinity;
148
+ if (localRow >= 0 && localRow < region.lines.length) {
149
+ if (!clamp && (localCol < 0 || localCol > maxCol)) return null;
150
+ return {
151
+ row: region.docStart + localRow,
152
+ col: clampNumber(localCol, 0, maxCol),
153
+ };
154
+ }
155
+ }
156
+
157
+ if (!clamp) return null;
158
+ const first = regions[0];
159
+ const last = regions.at(-1);
160
+ if (screenRow < first.topRow) return { row: first.docStart, col: 0 };
161
+ if (screenRow > last.topRow + last.lines.length - 1) {
162
+ return { row: last.docStart + last.lines.length - 1, col: last.width };
163
+ }
164
+
165
+ let nearest = null;
166
+ for (const region of regions) {
167
+ const beforeDistance = Math.abs(screenRow - region.topRow);
168
+ const afterDistance = Math.abs(screenRow - (region.topRow + region.lines.length - 1));
169
+ const before = { row: region.docStart, col: 0, distance: beforeDistance };
170
+ const after = { row: region.docStart + region.lines.length - 1, col: region.width, distance: afterDistance };
171
+ for (const candidate of [before, after]) {
172
+ if (!nearest || candidate.distance < nearest.distance) nearest = candidate;
173
+ }
174
+ }
175
+ return nearest ? { row: nearest.row, col: nearest.col } : null;
127
176
  }
128
177
 
129
178
  function comparePoints(a, b) {