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
@@ -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) {
@@ -1,12 +1,17 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import { statusBar, R } from "../ui-theme.mjs";
2
+ import { modeLabel, statusBar, R } from "../ui-theme.mjs";
3
3
 
4
4
  const ANSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
5
5
  const DEFAULT_STATUS_TEXT = "March";
6
+ const DEFAULT_HELP_TEXT = "/ commands · ? help";
7
+ const INPUT_BG = "\x1b[48;2;32;34;38m";
8
+ const INPUT_PROMPT = "▌";
6
9
 
7
10
  export class StatusBar {
8
- constructor(text = DEFAULT_STATUS_TEXT) {
11
+ constructor(text = DEFAULT_STATUS_TEXT, { cwd = process.cwd(), helpText = DEFAULT_HELP_TEXT } = {}) {
9
12
  this.text = normalizeStatusText(text);
13
+ this.cwd = normalizeStatusText(cwd);
14
+ this.helpText = normalizeStatusText(helpText);
10
15
  }
11
16
 
12
17
  setText(text) {
@@ -16,22 +21,135 @@ export class StatusBar {
16
21
  return true;
17
22
  }
18
23
 
24
+ setCwd(cwd) {
25
+ const next = normalizeStatusText(cwd);
26
+ if (next === this.cwd) return false;
27
+ this.cwd = next;
28
+ return true;
29
+ }
30
+
19
31
  invalidate() {}
20
32
 
21
33
  render(width) {
34
+ return this.renderTop(width);
35
+ }
36
+
37
+ renderTop(width) {
22
38
  if (width <= 0) return [""];
23
- const text = fitStatusText(this.text, width);
24
- const padded = padToWidth(text, width);
25
- // If text already has ANSI coloring, only apply background
26
- if (hasAnsi(padded)) {
27
- return [statusBar.background(`${padded}${R}`)];
28
- }
29
- return [statusBar.background(statusBar.text(padded))];
39
+ const { left, innerWidth, right } = insetForWidth(width);
40
+ const parts = statusParts(this.text);
41
+ const cwdName = currentDirectoryName(this.cwd);
42
+ const lsp = formatLspStatus(parts.lsp);
43
+ const leftText = [cwdName, lsp, parts.context].filter(Boolean).join(" • ");
44
+ const line = composeMetaLine({ left: leftText, right: "", width: innerWidth });
45
+ return [`${left}${line}${right}`, ""];
46
+ }
47
+
48
+ renderInputLines(lines, width) {
49
+ if (width <= 0) return [""];
50
+ const { left, innerWidth, right } = insetForWidth(width);
51
+ const contentLines = lines.filter((line) => !isEditorChromeLine(line));
52
+ const visibleLines = contentLines.length > 0 ? contentLines : [""];
53
+ const paintWidth = inputPaintWidth(innerWidth);
54
+ const inputPadding = `${left}${renderInputPaddingLine(paintWidth)}${right}`;
55
+ const inputContent = visibleLines.map((line, index) =>
56
+ `${left}${this.renderInputLine(line, innerWidth, { isFirst: index === 0 })}${right}`,
57
+ );
58
+ return [inputPadding, ...inputContent, inputPadding];
59
+ }
60
+
61
+ renderInputLine(line, width, { isFirst = true } = {}) {
62
+ if (width <= 0) return "";
63
+ const paintWidth = inputPaintWidth(width);
64
+ 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);
68
+ return applyInputBackground(padToWidth(`${prompt}${content}`, paintWidth));
30
69
  }
70
+
71
+ renderBottom(width) {
72
+ if (width <= 0) return [""];
73
+ const { left: insetLeft, innerWidth, right: insetRight } = insetForWidth(width);
74
+ const parts = statusParts(this.text);
75
+ const mode = formatModeLabel(parts.mode || DEFAULT_STATUS_TEXT);
76
+ const activity = parts.activity ? statusBar.muted(`${parts.activity} · `) : "";
77
+ const right = [parts.model, parts.thinking].filter(Boolean).join(" • ");
78
+ const line = composeMetaLine({ left: `${activity}${mode}`, right, width: innerWidth, muteLeft: false });
79
+ return ["", `${insetLeft}${line}${insetRight}`];
80
+ }
81
+ }
82
+
83
+ function insetForWidth(width) {
84
+ const safeWidth = Math.max(1, Math.trunc(width));
85
+ return { left: "", innerWidth: safeWidth, right: "" };
31
86
  }
32
87
 
33
- function hasAnsi(text) {
34
- return ANSI_RE.test(text);
88
+ function composeMetaLine({ left, right, width, muteLeft = true }) {
89
+ const safeWidth = Math.max(1, Math.trunc(width));
90
+ const rightWidth = visibleWidth(stripAnsi(right));
91
+ const colorLeft = (text) => (muteLeft ? statusBar.muted(text) : text);
92
+ if (!right) return colorLeft(padToWidth(clipToWidth(left, safeWidth), safeWidth));
93
+ if (rightWidth >= safeWidth) return statusBar.muted(padToWidth(clipToWidth(right, safeWidth), safeWidth));
94
+
95
+ const maxLeftWidth = Math.max(0, safeWidth - rightWidth - 1);
96
+ const fittedLeft = maxLeftWidth > 0 ? clipToWidth(left, maxLeftWidth) : "";
97
+ const gap = Math.max(1, safeWidth - visibleWidth(stripAnsi(fittedLeft)) - rightWidth);
98
+ return `${colorLeft(fittedLeft)}${" ".repeat(gap)}${statusBar.muted(right)}${R}`;
99
+ }
100
+
101
+ function renderInputPaddingLine(width) {
102
+ return applyInputBackground(" ".repeat(Math.max(1, Math.trunc(width))));
103
+ }
104
+
105
+ function inputPaintWidth(width) {
106
+ const safeWidth = Math.max(1, Math.trunc(width));
107
+ return safeWidth > 1 ? safeWidth - 1 : safeWidth;
108
+ }
109
+
110
+ function applyInputBackground(line) {
111
+ return `${INPUT_BG}${String(line).replaceAll(R, `${R}${INPUT_BG}`)}${R}`;
112
+ }
113
+
114
+ function isEditorChromeLine(line) {
115
+ const plain = stripAnsi(line).trim();
116
+ return plain.length > 0 && (/^─+$/.test(plain) || /^─+\s[↑↓].*more\s─*$/.test(plain));
117
+ }
118
+
119
+ function currentDirectoryName(path) {
120
+ const normalized = normalizeStatusText(path);
121
+ const parts = normalized.split(/[\\/]+/).filter(Boolean);
122
+ return parts.at(-1) || normalized || DEFAULT_STATUS_TEXT;
123
+ }
124
+
125
+ function formatLspStatus(lsp) {
126
+ if (!lsp) return "LSP off";
127
+ const server = lsp.replace(/^lsp:/, "").replace(/[✓✗]$/u, "").trim();
128
+ return server ? `LSP [${server}]` : "LSP off";
129
+ }
130
+
131
+ function formatModeLabel(mode) {
132
+ const label = normalizeStatusText(mode);
133
+ const color = modeLabel[label.toLowerCase()] || modeLabel.fallback;
134
+ return color(label);
135
+ }
136
+
137
+ function statusParts(text) {
138
+ const segments = plainSegments(text);
139
+ const runtime = segments.find((segment) => segment.includes("·")) || "";
140
+ const [model = "", thinking = ""] = runtime.split("·").map((part) => part.trim());
141
+ const lsp = segments.find((segment) => segment.startsWith("lsp:")) || "";
142
+ const context = [...segments].reverse().find((segment) => /^\d+(?:\.\d+)?[KM]?$/.test(segment)) || "";
143
+ const activity = segments.find((segment) => /(?:Working|Aborted)$/.test(segment)) || "";
144
+ const mode = segments.find((segment) => segment && segment !== runtime && segment !== lsp && segment !== activity && segment !== context) || segments[0] || "";
145
+ return { mode, model, thinking, lsp, activity, context };
146
+ }
147
+
148
+ function plainSegments(text) {
149
+ return stripAnsi(normalizeStatusText(text))
150
+ .split(" | ")
151
+ .map((segment) => segment.trim())
152
+ .filter(Boolean);
35
153
  }
36
154
 
37
155
  export function normalizeStatusText(text) {
@@ -64,15 +182,15 @@ export function fitStatusText(text, width) {
64
182
  }
65
183
 
66
184
  export function clipToWidth(text, width) {
67
- // For ANSI-containing text, build output character by character and measure plain width
68
185
  let output = "";
69
186
  let plainWidth = 0;
70
- let inAnsi = false;
71
- for (const ch of Array.from(String(text || ""))) {
72
- if (ch === "\x1b") inAnsi = true;
73
- if (inAnsi) {
74
- output += ch;
75
- if (/[@-~]/.test(ch)) inAnsi = false;
187
+ const chars = Array.from(String(text || ""));
188
+ for (let index = 0; index < chars.length; index += 1) {
189
+ const ch = chars[index];
190
+ if (ch === "\x1b") {
191
+ const { sequence, nextIndex } = readAnsiSequence(chars, index);
192
+ output += sequence;
193
+ index = nextIndex;
76
194
  continue;
77
195
  }
78
196
  const charWidth = visibleWidth(ch);
@@ -83,6 +201,24 @@ export function clipToWidth(text, width) {
83
201
  return output;
84
202
  }
85
203
 
204
+ function readAnsiSequence(chars, startIndex) {
205
+ let sequence = chars[startIndex];
206
+ let index = startIndex;
207
+ const intro = chars[startIndex + 1];
208
+ if (!intro) return { sequence, nextIndex: index };
209
+ sequence += intro;
210
+ index += 1;
211
+ if (intro !== "[") return { sequence, nextIndex: index };
212
+
213
+ while (index + 1 < chars.length) {
214
+ index += 1;
215
+ const ch = chars[index];
216
+ sequence += ch;
217
+ if (/[\x40-\x7e]/.test(ch)) break;
218
+ }
219
+ return { sequence, nextIndex: index };
220
+ }
221
+
86
222
  function stripAnsi(text) {
87
223
  return String(text ?? "").replace(ANSI_RE, "");
88
224
  }
@@ -1,6 +1,9 @@
1
1
  import { extractToolOutput } from "../tool-output.mjs";
2
+ import { formatToolStartLine, formatToolSuccessSummary } from "../../agent/tool-summary.mjs";
2
3
  import { dim, red } from "./ui-theme.mjs";
3
4
 
5
+ export { formatToolStartLine, formatToolSuccessSummary } from "../../agent/tool-summary.mjs";
6
+
4
7
  const TOOL_BODY_LIMIT = 40;
5
8
  const TOOL_ERROR_LIMIT = 6;
6
9
 
@@ -44,58 +47,6 @@ export function writeToolEnd({
44
47
  return true;
45
48
  }
46
49
 
47
- export function formatToolStartLine(name, args = {}) {
48
- if (name === "edit_file") {
49
- const path = compactPath(args?.path ?? "");
50
- const editCount = Array.isArray(args?.edits) ? args.edits.length : 0;
51
- const mode = args?.mode ?? "patch";
52
- const summary = mode === "patch" ? `${editCount} edit${editCount === 1 ? "" : "s"}` : mode;
53
- return joinToolParts("◆", name, [path, summary]);
54
- }
55
- if (name === "command_exec") return joinToolParts("◆", name, [compactText(args?.command ?? "")]);
56
- if (name === "terminal_send") return joinToolParts("◆", name, [args?.shell_id, formatTerminalSendAction(args)]);
57
- if (name?.startsWith?.("terminal_")) return joinToolParts("◆", name, [args?.shell_id, formatTerminalDetails(args)]);
58
- if (name === "external_web_search") return joinToolParts("◆", name, [quoteCompact(args?.query ?? "")]);
59
- if (name === "web_fetch") return joinToolParts("◆", name, [compactText(args?.url ?? "")]);
60
- if (name === "context_stats") return joinToolParts("◆", name, []);
61
- if (name === "read") {
62
- const path = compactPath(args?.path ?? args?.filePath ?? "");
63
- return joinToolParts("→", name, [path, formatReadRange(args)]);
64
- }
65
- if (name === "grep") {
66
- const path = compactPath(args?.path ?? "");
67
- return joinToolParts("✱", name, [quoteCompact(args?.pattern ?? ""), path]);
68
- }
69
- if (name === "glob") {
70
- const path = compactPath(args?.path ?? "");
71
- return joinToolParts("✱", name, [quoteCompact(args?.pattern ?? ""), path]);
72
- }
73
- if (name === "find") {
74
- const path = compactPath(args?.path ?? "");
75
- return joinToolParts("✱", name, [quoteCompact(args?.pattern ?? ""), path]);
76
- }
77
- return joinToolParts("◆", name, [formatSmallOptions(args)]);
78
- }
79
-
80
- export function formatToolSuccessSummary(name, result, out = "") {
81
- if (name === "grep") {
82
- const matches = result?.details?.results?.length ?? countMatchLines(out);
83
- return `${matches} match${matches === 1 ? "" : "es"}`;
84
- }
85
- if (name === "glob") {
86
- const matches = Array.isArray(result?.details?.matches) ? result.details.matches.length : countNonEmptyLines(out);
87
- return `${matches} file${matches === 1 ? "" : "s"}`;
88
- }
89
- if (name === "find") {
90
- const matches = result?.details?.count ?? countNonEmptyLines(out);
91
- return `${matches} file${matches === 1 ? "" : "s"}`;
92
- }
93
- if (name === "memory_open") {
94
- return compactText(result?.details?.entry?.name ?? compactPath(result?.details?.path ?? ""));
95
- }
96
- return "";
97
- }
98
-
99
50
  function formatToolEndCard({ name, isError, result, extractToolOutputImpl }) {
100
51
  const out = extractToolOutputImpl(result);
101
52
  if (isError) {
@@ -134,64 +85,3 @@ function writeStructuredLines(output, block) {
134
85
  for (const line of lines) output.writeln(line);
135
86
  }
136
87
  }
137
-
138
- function joinToolParts(icon, name, parts) {
139
- const clean = parts.map((part) => String(part ?? "").trim()).filter(Boolean);
140
- return `${icon} ${name}${clean.length ? ` · ${clean.join(" · ")}` : ""}`;
141
- }
142
-
143
- function formatReadRange(args = {}) {
144
- if (args.offset == null && args.limit == null) return "";
145
- if (args.offset != null && args.limit != null) return `lines ${args.offset}-${Number(args.offset) + Number(args.limit) - 1}`;
146
- if (args.offset != null) return `from line ${args.offset}`;
147
- return `limit ${args.limit}`;
148
- }
149
-
150
- function formatTerminalSendAction(args = {}) {
151
- const hasText = typeof args.text === "string" && args.text.length > 0;
152
- const key = args.key ? String(args.key) : "";
153
- if (hasText && key) return `text+${key}`;
154
- if (hasText) return args.text.includes("\n") || args.text.includes("\r") ? "text+enter" : "text";
155
- return key || "send";
156
- }
157
-
158
- function formatTerminalDetails(args = {}) {
159
- const details = [];
160
- if (args.pattern) details.push(quoteCompact(args.pattern));
161
- if (args.cols && args.rows) details.push(`${args.cols}x${args.rows}`);
162
- if (args.command) details.push(compactText(args.command));
163
- return details.join(" · ");
164
- }
165
-
166
- function formatSmallOptions(args = {}) {
167
- const parts = [];
168
- for (const [key, value] of Object.entries(args ?? {})) {
169
- if (value == null || typeof value === "object") continue;
170
- parts.push(`${key}=${compactText(value)}`);
171
- if (parts.length >= 2) break;
172
- }
173
- return parts.join(", ");
174
- }
175
-
176
- function compactPath(path) {
177
- return String(path ?? "").split(/[/\\]/).filter(Boolean).slice(-4).join("\\");
178
- }
179
-
180
- function quoteCompact(value) {
181
- return JSON.stringify(compactText(value));
182
- }
183
-
184
- function compactText(value, limit = 80) {
185
- const text = String(value ?? "").replace(/\s+/g, " ").trim();
186
- return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
187
- }
188
-
189
- function countMatchLines(text) {
190
- const match = String(text ?? "").match(/(\d+)\s+matches?\b/i);
191
- if (match) return Number(match[1]);
192
- return countNonEmptyLines(text);
193
- }
194
-
195
- function countNonEmptyLines(text) {
196
- return String(text ?? "").split("\n").filter(Boolean).length;
197
- }
@@ -56,7 +56,7 @@ export function wireTuiHandlers({
56
56
  ui.writeln(brightBlack(`● thinking: unchanged`));
57
57
  return;
58
58
  }
59
- ui.writeln(brightBlack(`● thinking: ${runner.setThinkingLevel(item.level)}`));
59
+ ui.writeln(brightBlack(`● thinking: ${await runner.setThinkingLevel(item.level)}`));
60
60
  refreshStatusBar();
61
61
  } catch (err) {
62
62
  ui.writeln(`Error: ${err.message}`);
@@ -17,6 +17,7 @@ const brightRed = (s) => `\x1b[91m${s}${R}`;
17
17
  const brightGreen = (s) => `\x1b[92m${s}${R}`;
18
18
  const orange = (s) => `\x1b[38;2;245;167;66m${s}${R}`;
19
19
  const softGreen = (s) => `\x1b[38;2;127;216;143m${s}${R}`;
20
+ const violet = (s) => `\x1b[38;2;232;91;226m${s}${R}`;
20
21
 
21
22
  // ── Formatters ───────────────────────────────────────────────────────
22
23
  const bold = (s) => `${B}${s}${R}`;
@@ -79,9 +80,16 @@ const message = {
79
80
  };
80
81
 
81
82
  const statusBar = {
82
- background: bg256(236),
83
- text: fg256(250),
84
- accent: cyan,
83
+ muted: brightBlack,
84
+ cwd: (s) => `${D}\x1b[38;5;244m${s}${R}`,
85
+ prompt: fg256(250),
86
+ accent: violet,
87
+ };
88
+
89
+ const modeLabel = {
90
+ do: orange,
91
+ discuss: green,
92
+ fallback: orange,
85
93
  };
86
94
 
87
95
  const shell = {
@@ -105,7 +113,7 @@ const selectList = {
105
113
 
106
114
  // ── Editor theme (consumed by pi-tui Editor component) ──────────────
107
115
  const EDITOR_THEME = {
108
- borderColor: border.default,
116
+ borderColor: fg256(238),
109
117
  selectList,
110
118
  };
111
119
 
@@ -127,7 +135,7 @@ export {
127
135
  R, B, D,
128
136
  black, red, green, yellow, blue, magenta, cyan, white,
129
137
  brightBlack, brightRed, brightGreen,
130
- orange, softGreen,
138
+ orange, softGreen, violet,
131
139
  bold, dim, inverse,
132
140
  fg256, bg256,
133
141
  // Semantic
@@ -140,6 +148,7 @@ export {
140
148
  tool,
141
149
  message,
142
150
  statusBar,
151
+ modeLabel,
143
152
  shell,
144
153
  spinner,
145
154
  selectList,
package/src/cli/ui.mjs CHANGED
@@ -41,7 +41,7 @@ export function createTuiUI({
41
41
  const tui = new TUI(terminal);
42
42
  const output = new OutputBuffer();
43
43
  const shellDrawer = new ShellDrawer({ shellRuntime });
44
- const statusBar = new StatusBar();
44
+ const statusBar = new StatusBar(undefined, { cwd });
45
45
  const editor = new Editor(tui, EDITOR_THEME, { paddingX: 1 });
46
46
  const selection = new ScreenSelection();
47
47
  const mainPane = new MainPaneLayout({ output, statusBar, editor, terminal, selection });
@@ -3,14 +3,14 @@ import { buildSessionIdentity } from "./session-status.mjs";
3
3
  import { buildSystemCore, resolveSystemCorePromptKey } from "./system-core.mjs";
4
4
  import { buildInjectionsLayer } from "./injections.mjs";
5
5
  import { buildProjectContext } from "./project-context.mjs";
6
- import { buildCenterMemory } from "./center-memory.mjs";
6
+ import { buildProfileLayers } from "./profiles.mjs";
7
7
  import { formatRecallHints } from "../memory/markdown-store.mjs";
8
8
 
9
9
  export class ContextEngine {
10
- constructor({ cwd, modelId, provider = "deepseek", thinkingLevel = "medium", namespace = "", memoryRoot = null, centerMemoryPath = null, shellRuntime = null, lspService = null, injections = [], maxTurns, trimBatch }) {
10
+ constructor({ cwd, modelId, provider = "deepseek", thinkingLevel = "medium", namespace = "", memoryRoot = null, profilePaths = null, shellRuntime = null, lspService = null, injections = [], maxTurns, trimBatch }) {
11
11
  this.cwd = cwd;
12
12
  this.memoryRoot = memoryRoot;
13
- this.centerMemoryPath = centerMemoryPath;
13
+ this.profilePaths = profilePaths;
14
14
  this.modelId = modelId;
15
15
  this.provider = provider;
16
16
  this.thinkingLevel = thinkingLevel;
@@ -62,19 +62,19 @@ export class ContextEngine {
62
62
  const projectCtx = buildProjectContext(this.cwd);
63
63
  if (projectCtx) layers.push({ name: "project_context", text: projectCtx });
64
64
 
65
- const centerMemory = buildCenterMemory(this.centerMemoryPath);
66
- if (centerMemory) layers.push({ name: "center_memory", text: centerMemory });
65
+ layers.push(...buildProfileLayers(this.profilePaths));
67
66
 
68
67
  layers.push({ name: "recent_chat", text: this.#buildRecentChat() });
69
68
 
70
69
  return layers;
71
70
  }
72
71
 
73
- recordTurn({ userMessage, assistantMessage, userRecallHints = [], assistantRecallHints = [] }) {
72
+ recordTurn({ userMessage, assistantMessage, assistantContext = "", userRecallHints = [], assistantRecallHints = [] }) {
74
73
  this.turns.push({
75
74
  index: this.turns.length + 1,
76
75
  userMessage,
77
76
  assistantMessage: assistantMessage ?? "",
77
+ assistantContext: assistantContext ?? "",
78
78
  userRecallHints,
79
79
  assistantRecallHints,
80
80
  });
@@ -167,9 +167,10 @@ export class ContextEngine {
167
167
  `[user]\n${String(turn.userMessage ?? "")}\n`;
168
168
  const userRecall = formatRecallHints("user", turn.userRecallHints ?? []);
169
169
  if (userRecall) block += `\n${userRecall}\n`;
170
- block += `\n[March]\n`;
171
- if (turn.assistantMessage) {
172
- block += `\n${String(turn.assistantMessage ?? "")}\n`;
170
+ block += `\n[assistant]\n`;
171
+ const assistantText = turn.assistantContext || turn.assistantMessage;
172
+ if (assistantText) {
173
+ block += `\n${String(assistantText ?? "")}\n`;
173
174
  }
174
175
  const assistantRecall = formatRecallHints("assistant", turn.assistantRecallHints ?? []);
175
176
  if (assistantRecall) block += `\n${assistantRecall}\n`;