march-cli 0.1.10 → 0.1.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -1 +1 @@
1
- export const MARCH_BASE_TOOL_NAMES = ["grep", "ls"];
1
+ export const MARCH_BASE_TOOL_NAMES = ["grep", "find", "ls"];
@@ -3,7 +3,6 @@ import { Type } from "typebox";
3
3
  import { createCommandExecTool } from "./command-exec-tool.mjs";
4
4
  import { createContextStatsTool } from "./context-stats-tool.mjs";
5
5
  import { createEditFileTool } from "./file-edit-tool.mjs";
6
- import { createFindTool } from "./find-tool.mjs";
7
6
  import { createReadFileTool } from "./read-file-tool.mjs";
8
7
  import { toolText } from "./tool-result.mjs";
9
8
  import { createShellTools } from "../shell/tools.mjs";
@@ -14,7 +13,6 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
14
13
  const commandExecTool = createCommandExecTool({ cwd });
15
14
  const contextStatsTool = createContextStatsTool({ engine });
16
15
  const editFileTool = createEditFileTool({ engine, ui, lspService });
17
- const findTool = createFindTool({ cwd });
18
16
  const readFileTool = createReadFileTool({ engine });
19
17
 
20
18
  const tools = [
@@ -22,7 +20,6 @@ export function createMarchCustomTools({ cwd, engine, ui, memoryTools = [], shel
22
20
  contextStatsTool,
23
21
  commandExecTool,
24
22
  editFileTool,
25
- findTool,
26
23
  ...createShellTools(shellRuntime),
27
24
  ...memoryTools,
28
25
  ...mcpTools,
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { MODES, formatModeLabel } from "../input/mode-state.mjs";
3
- import { accent, text, PREFIX, R } from "../tui/ui-theme.mjs";
3
+ import { PREFIX, R } from "../tui/ui-theme.mjs";
4
4
 
5
5
  export function statusCommand({
6
6
  runner,
@@ -23,11 +23,6 @@ export function statusCommand({
23
23
 
24
24
  export function statusBarLine({
25
25
  runner,
26
- sessionState,
27
- sessionSource = "pi",
28
- extensionDiagnostics = [],
29
- lifecycleState = null,
30
- gitBranch = getGitBranch(runner.engine.cwd),
31
26
  mode = MODES.DO,
32
27
  contextTokens = null,
33
28
  activity = null,
@@ -35,12 +30,6 @@ export function statusBarLine({
35
30
  }) {
36
31
  return formatStatusBarLine({
37
32
  engine: runner.engine,
38
- sessionState,
39
- sessionStats: runner.getSessionStats?.() ?? null,
40
- sessionSource,
41
- extensionDiagnostics,
42
- lifecycleState,
43
- gitBranch,
44
33
  mode,
45
34
  contextTokens,
46
35
  activity,
@@ -137,7 +137,7 @@ export class ShellDrawer {
137
137
 
138
138
  getOutputLines(shellId = this.getSelectedShell()?.id) {
139
139
  if (!shellId || !this.shellRuntime) return [];
140
- const snapshot = this.shellRuntime.snapshotShell(shellId);
140
+ const snapshot = this.shellRuntime.snapshotShellScreen?.(shellId) ?? this.shellRuntime.snapshotShell(shellId);
141
141
  return formatAnsiLines(snapshot);
142
142
  }
143
143
 
@@ -0,0 +1,8 @@
1
+ export function sliceLinesWithTail(baseLines, tailLine, range) {
2
+ if (!range) return tailLine == null ? baseLines : [...baseLines, tailLine];
3
+
4
+ const { start, end } = range;
5
+ const visible = baseLines.slice(start, Math.min(end, baseLines.length));
6
+ if (tailLine != null && end > baseLines.length) visible.push(tailLine);
7
+ return visible;
8
+ }
@@ -4,6 +4,7 @@ import { renderMarkdown, renderStreamingMarkdown } from "./markdown-renderer.mjs
4
4
  import { renderEditDiffBlock } from "./tui-diff-rendering.mjs";
5
5
  import { OutputScrollState } from "./output/scroll-state.mjs";
6
6
  import { appendTextLines, wrapLine } from "./output/text-line-renderer.mjs";
7
+ import { sliceLinesWithTail } from "./output/visible-lines.mjs";
7
8
 
8
9
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
9
10
 
@@ -72,6 +73,7 @@ export class OutputBuffer {
72
73
  this.overlayStatus = null;
73
74
  this.scrollState = new OutputScrollState();
74
75
  this._segmentLinesCache = new Map();
76
+ this._baseLinesCache = new Map();
75
77
  }
76
78
 
77
79
  get scrollOffset() {
@@ -89,6 +91,7 @@ export class OutputBuffer {
89
91
  this.overlayStatus = null;
90
92
  this.scrollState.clear();
91
93
  this._segmentLinesCache = new Map();
94
+ this._baseLinesCache = new Map();
92
95
  }
93
96
 
94
97
  write(text) {
@@ -101,6 +104,7 @@ export class OutputBuffer {
101
104
 
102
105
  _writeText(text, markdown) {
103
106
  this.overlayStatus = null;
107
+ this._invalidateBaseLines();
104
108
  const current = this.currentText.at(-1);
105
109
  if (current.markdown !== markdown && current.text !== "") {
106
110
  this.currentText.push({ text: "", markdown });
@@ -114,6 +118,7 @@ export class OutputBuffer {
114
118
 
115
119
  writeln(text) {
116
120
  this.overlayStatus = null;
121
+ this._invalidateBaseLines();
117
122
  this.currentText[this.currentText.length - 1].text += text;
118
123
  this.currentText.push({ text: "", markdown: false });
119
124
  }
@@ -122,6 +127,7 @@ export class OutputBuffer {
122
127
  const current = this.currentText.at(-1);
123
128
  if (!current || current.text === "") return false;
124
129
  this.currentText.push({ text: "", markdown: false });
130
+ this._invalidateBaseLines();
125
131
  return true;
126
132
  }
127
133
 
@@ -141,12 +147,14 @@ export class OutputBuffer {
141
147
  if (lastIdx >= 0) this._activeThinking.content[lastIdx] += parts[0];
142
148
  else this._activeThinking.content.push(parts[0]);
143
149
  for (let i = 1; i < parts.length; i++) this._activeThinking.content.push(parts[i]);
150
+ this._invalidateBaseLines();
144
151
  }
145
152
 
146
153
  endThinking(tokens) {
147
154
  if (this._activeThinking) {
148
155
  this._activeThinking.tokens = tokens;
149
156
  this._activeThinking = null;
157
+ this._invalidateSegmentLines();
150
158
  }
151
159
  }
152
160
 
@@ -166,10 +174,12 @@ export class OutputBuffer {
166
174
 
167
175
  setOverlayStatus(lines) {
168
176
  this.overlayStatus = Array.isArray(lines) ? { type: "status", lines } : null;
177
+ this._invalidateBaseLines();
169
178
  }
170
179
 
171
180
  clearOverlayStatus() {
172
181
  this.overlayStatus = null;
182
+ this._invalidateBaseLines();
173
183
  }
174
184
 
175
185
  sealCurrentText() {
@@ -232,34 +242,33 @@ export class OutputBuffer {
232
242
 
233
243
  _invalidateSegmentLines() {
234
244
  this._segmentLinesCache.clear();
245
+ this._invalidateBaseLines();
246
+ }
247
+
248
+ _invalidateBaseLines() {
249
+ this._baseLinesCache.clear();
235
250
  }
236
251
 
237
252
  render(width) {
238
- const allLines = this._computeLines(width);
239
- this._cachedTotalLines = allLines.length;
240
- this.scrollState.setTotalLines(allLines.length);
241
- const range = this.scrollState.sliceRange();
242
- if (!range) return allLines;
243
- const { start, end } = range;
244
- return allLines.slice(start, end);
253
+ const baseLines = this._renderBaseLines(width);
254
+ const tailLine = this.spinning ? this._spinnerLine() : null;
255
+ this.scrollState.setTotalLines(baseLines.length + (tailLine == null ? 0 : 1));
256
+ return sliceLinesWithTail(baseLines, tailLine, this.scrollState.sliceRange());
257
+ }
258
+
259
+ _spinnerLine() {
260
+ return brightBlack(`${SPINNER_FRAMES[this.spinnerIdx]} ${this.spinnerText}`);
245
261
  }
246
262
 
247
- _computeLines(width) {
263
+ _renderBaseLines(width) {
264
+ const cached = this._baseLinesCache.get(width);
265
+ if (cached) return cached;
248
266
  const lines = [...this._renderCachedSegmentLines(width)];
249
267
  const dynamicStart = this._cachedSegmentPrefixCount();
250
- for (const seg of this.segments.slice(dynamicStart)) {
251
- for (const line of renderBlock(seg, width)) lines.push(line);
252
- }
253
- for (const block of currentTextToBlocks(this.currentText, false, this.currentTextCache)) {
254
- for (const line of renderBlock(block, width)) lines.push(line);
255
- }
256
- if (this.overlayStatus) {
257
- for (const line of renderBlock(this.overlayStatus, width)) lines.push(line);
258
- }
259
- if (this.spinning) {
260
- const frame = SPINNER_FRAMES[this.spinnerIdx];
261
- lines.push(brightBlack(`${frame} ${this.spinnerText}`));
262
- }
268
+ for (const seg of this.segments.slice(dynamicStart)) for (const line of renderBlock(seg, width)) lines.push(line);
269
+ for (const block of currentTextToBlocks(this.currentText, false, this.currentTextCache)) for (const line of renderBlock(block, width)) lines.push(line);
270
+ if (this.overlayStatus) for (const line of renderBlock(this.overlayStatus, width)) lines.push(line);
271
+ this._baseLinesCache.set(width, lines);
263
272
  return lines;
264
273
  }
265
274
 
@@ -0,0 +1,46 @@
1
+ export function createStreamDeltaBuffer({
2
+ writeText,
3
+ writeThinking,
4
+ renderSoon,
5
+ delayMs = 16,
6
+ setTimeoutImpl = setTimeout,
7
+ clearTimeoutImpl = clearTimeout,
8
+ } = {}) {
9
+ const queued = [];
10
+ let timer = null;
11
+
12
+ function append(kind, delta) {
13
+ if (!delta) return;
14
+ const last = queued.at(-1);
15
+ if (last?.kind === kind) last.text += delta;
16
+ else queued.push({ kind, text: delta });
17
+ schedule();
18
+ }
19
+
20
+ function schedule() {
21
+ if (timer) return;
22
+ timer = setTimeoutImpl(() => flush(), delayMs);
23
+ timer.unref?.();
24
+ }
25
+
26
+ function flush({ notify = true } = {}) {
27
+ if (timer) {
28
+ clearTimeoutImpl(timer);
29
+ timer = null;
30
+ }
31
+ if (!queued.length) return false;
32
+ const batch = queued.splice(0);
33
+ for (const item of batch) {
34
+ if (item.kind === "thinking") writeThinking(item.text);
35
+ else writeText(item.text);
36
+ }
37
+ if (notify) renderSoon();
38
+ return true;
39
+ }
40
+
41
+ return {
42
+ text: (delta) => append("text", delta),
43
+ thinking: (delta) => append("thinking", delta),
44
+ flush,
45
+ };
46
+ }
@@ -10,16 +10,19 @@ export class ScreenSelection {
10
10
  this.anchor = null;
11
11
  this.focus = null;
12
12
  this.lines = [];
13
+ this._plainLines = [];
13
14
  this.viewport = { topRow: 0, leftCol: 0, width: Infinity, height: 0 };
14
15
  }
15
16
 
16
17
  setLines(lines) {
17
- this.lines = lines.map((line) => stripAnsi(line));
18
+ this.lines = [...lines];
19
+ this._plainLines = [];
18
20
  this.viewport = { topRow: 0, leftCol: 0, width: Infinity, height: this.lines.length };
19
21
  }
20
22
 
21
23
  setViewport({ topRow = 0, leftCol = 0, width = Infinity, lines = [] } = {}) {
22
- this.lines = lines.map((line) => stripAnsi(line));
24
+ this.lines = lines;
25
+ this._plainLines = [];
23
26
  this.viewport = {
24
27
  topRow: Math.max(0, Math.trunc(topRow)),
25
28
  leftCol: Math.max(0, Math.trunc(leftCol)),
@@ -68,7 +71,7 @@ export class ScreenSelection {
68
71
  if (!range) return "";
69
72
  const selected = [];
70
73
  for (let row = range.start.row; row <= range.end.row; row += 1) {
71
- const line = this.lines[row] ?? "";
74
+ const line = this._plainLine(row);
72
75
  const startCol = row === range.start.row ? range.start.col : 0;
73
76
  const endCol = row === range.end.row ? range.end.col : visibleWidth(line);
74
77
  selected.push(sliceColumns(line, startCol, endCol));
@@ -81,7 +84,7 @@ export class ScreenSelection {
81
84
  if (!range) return lines;
82
85
  return lines.map((line, row) => {
83
86
  if (row < range.start.row || row > range.end.row) return line;
84
- const plain = stripAnsi(line);
87
+ const plain = this._plainLine(row);
85
88
  const startCol = row === range.start.row ? range.start.col : 0;
86
89
  const endCol = row === range.end.row ? range.end.col : visibleWidth(plain);
87
90
  if (endCol <= startCol) return line;
@@ -89,6 +92,11 @@ export class ScreenSelection {
89
92
  });
90
93
  }
91
94
 
95
+ _plainLine(row) {
96
+ if (this._plainLines[row] == null) this._plainLines[row] = stripAnsi(this.lines[row] ?? "");
97
+ return this._plainLines[row];
98
+ }
99
+
92
100
  range() {
93
101
  if (!this.anchor || !this.focus) return null;
94
102
  const [start, end] = comparePoints(this.anchor, this.focus) <= 0
@@ -19,7 +19,6 @@ import {
19
19
  } from "./languages.mjs";
20
20
 
21
21
  const RESOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "tree-sitter");
22
- const ENCODER = new TextEncoder();
23
22
 
24
23
  let initPromise;
25
24
  let initialized = false;
@@ -87,11 +86,10 @@ function treeSitterRuns(text, lang) {
87
86
  if (!initialized || !parser || !text) return null;
88
87
  try {
89
88
  const tree = parser.parse(text);
90
- const byteToIndex = buildByteToIndex(text);
91
89
  const scopes = Array.from({ length: text.length }, () => ({ scope: "default", priority: 0 }));
92
90
  const query = queries.get(lang);
93
- if (query) applyQueryScopes(query, tree.rootNode, byteToIndex, scopes);
94
- collectNodeScopes(tree.rootNode, byteToIndex, scopes);
91
+ if (query) applyQueryScopes(query, tree.rootNode, scopes);
92
+ collectNodeScopes(tree.rootNode, scopes);
95
93
  return scopesToRuns(text, scopes);
96
94
  } catch {
97
95
  return null;
@@ -109,11 +107,11 @@ function loadHighlightQuery(language, queryFile) {
109
107
  }
110
108
  }
111
109
 
112
- function applyQueryScopes(query, rootNode, byteToIndex, scopes) {
110
+ function applyQueryScopes(query, rootNode, scopes) {
113
111
  for (const capture of query.captures(rootNode)) {
114
112
  const scope = captureScope(capture.name);
115
113
  if (!scope) continue;
116
- applyScope(scopes, byteToIndex[capture.node.startIndex] ?? 0, byteToIndex[capture.node.endIndex] ?? scopes.length, scope);
114
+ applyScope(scopes, capture.node.startIndex, capture.node.endIndex, scope);
117
115
  }
118
116
  }
119
117
 
@@ -135,10 +133,10 @@ function captureScope(name) {
135
133
  return null;
136
134
  }
137
135
 
138
- function collectNodeScopes(node, byteToIndex, scopes) {
136
+ function collectNodeScopes(node, scopes) {
139
137
  const scope = classifyNode(node);
140
- if (scope) applyScope(scopes, byteToIndex[node.startIndex] ?? 0, byteToIndex[node.endIndex] ?? scopes.length, scope);
141
- for (const child of node.children ?? []) collectNodeScopes(child, byteToIndex, scopes);
138
+ if (scope) applyScope(scopes, node.startIndex, node.endIndex, scope);
139
+ for (const child of node.children ?? []) collectNodeScopes(child, scopes);
142
140
  }
143
141
 
144
142
  function classifyNode(node) {
@@ -259,19 +257,4 @@ export function styleSyntax(text, scope = "default", bg = "") {
259
257
  return `\x1b[${codes.join(";")}m${text}${R}`;
260
258
  }
261
259
 
262
- function buildByteToIndex(text) {
263
- const map = [];
264
- let byte = 0;
265
- for (let index = 0; index < text.length;) {
266
- const codePoint = text.codePointAt(index);
267
- const char = String.fromCodePoint(codePoint);
268
- const bytes = ENCODER.encode(char).length;
269
- for (let i = 0; i < bytes; i++) map[byte + i] = index;
270
- byte += bytes;
271
- index += char.length;
272
- }
273
- map[byte] = text.length;
274
- return map;
275
- }
276
-
277
260
  void initializeTreeSitterHighlighting();
@@ -25,7 +25,7 @@ export const LANG_ALIASES = new Map([
25
25
  ["cs", "csharp"], ["css", "css"], ["cts", "typescript"], ["csharp", "csharp"], ["cxx", "cpp"],
26
26
  ["diff", "diff"], ["go", "go"], ["h", "c"], ["hh", "cpp"], ["htm", "html"], ["html", "html"],
27
27
  ["hpp", "cpp"], ["hxx", "cpp"], ["java", "java"], ["javascript", "javascript"], ["js", "javascript"],
28
- ["json", "json"], ["jsonc", "json"], ["jsx", "jsx"], ["mjs", "javascript"], ["mts", "typescript"],
28
+ ["json", "json"], ["jsonc", "json"], ["jsx", "tsx"], ["mjs", "javascript"], ["mts", "typescript"],
29
29
  ["patch", "diff"], ["php", "php"], ["py", "python"], ["python", "python"], ["rb", "ruby"],
30
30
  ["rs", "rust"], ["ruby", "ruby"], ["rust", "rust"], ["sh", "bash"], ["toml", "toml"],
31
31
  ["ts", "typescript"], ["tsx", "tsx"], ["typescript", "typescript"], ["yaml", "yaml"], ["yml", "yaml"],
package/src/cli/ui.mjs CHANGED
@@ -24,6 +24,7 @@ import { writeMemoryHint } 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";
27
+ import { createStreamDeltaBuffer } from "./tui/render/stream-delta-buffer.mjs";
27
28
  import { writeTranscriptToOutput } from "../session/transcript.mjs";
28
29
 
29
30
  export { buildMarchCommands, MarchAutocompleteProvider } from "./input/autocomplete.mjs";
@@ -61,8 +62,9 @@ export function createTuiUI({
61
62
  let toolsExpanded = false;
62
63
  const activeToolBlocks = [];
63
64
  const renderScheduler = createRenderScheduler({ requestRender: () => tui.requestRender() });
64
- const requestRender = renderScheduler.renderNow;
65
-
65
+ const streamDeltas = createStreamDeltaBuffer({ writeText: (delta) => output.writeMarkdown(delta), writeThinking: (delta) => output.appendThinking(delta), renderSoon: renderScheduler.renderSoon });
66
+ const flushStreamDeltas = () => streamDeltas.flush({ notify: false });
67
+ const requestRender = () => { flushStreamDeltas(); renderScheduler.renderNow(); };
66
68
  const spinnerStatus = createSpinnerStatusController({ output, requestRender });
67
69
  const retryStatus = createRetryStatusController({ output, requestRender, stopSpinner: spinnerStatus.stop });
68
70
  const shellDrawerControls = createShellDrawerControls({ shellDrawer, output, requestRender });
@@ -169,12 +171,10 @@ export function createTuiUI({
169
171
  retryStatus.stop(); output.startThinking(); requestRender();
170
172
  },
171
173
 
172
- thinkingDelta: (delta) => {
173
- output.appendThinking(delta);
174
- renderScheduler.renderSoon();
175
- },
174
+ thinkingDelta: (delta) => streamDeltas.thinking(delta),
176
175
 
177
176
  thinkingEnd: (tokens) => {
177
+ flushStreamDeltas();
178
178
  output.endThinking(tokens);
179
179
  requestRender();
180
180
  },
@@ -186,7 +186,7 @@ export function createTuiUI({
186
186
  toggleLastThinking: () => false,
187
187
 
188
188
  toolStart: (name, args) => {
189
- ensureStarted(); retryStatus.stop(); spinnerStatus.stop(); activeToolBlocks.push(writeToolStart({ output, name, args })); requestRender();
189
+ ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); activeToolBlocks.push(writeToolStart({ output, name, args })); requestRender();
190
190
  },
191
191
 
192
192
  toolEnd: (name, isError, result) => {
@@ -194,30 +194,26 @@ export function createTuiUI({
194
194
  },
195
195
 
196
196
  textDelta: (delta) => {
197
- ensureStarted(); retryStatus.stop(); spinnerStatus.stop();
198
- output.writeMarkdown(delta);
199
- renderScheduler.renderSoon();
197
+ ensureStarted(); retryStatus.stop(); spinnerStatus.stop(); streamDeltas.text(delta);
200
198
  },
201
199
  assistantReplyEnd: () => {
202
200
  ensureStarted();
201
+ flushStreamDeltas();
203
202
  const changed = output.ensureNewline();
204
203
  if (output.sealCurrentText() || changed) requestRender();
205
204
  },
206
205
  status: (text) => {
207
- ensureStarted(); retryStatus.stop(); spinnerStatus.stop(); output.setOverlayStatus([brightBlack(`● ${text}`)]); requestRender();
206
+ ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.setOverlayStatus([brightBlack(`● ${text}`)]); requestRender();
208
207
  },
209
208
  memoryHint: ({ hints }) => {
210
- ensureStarted(); retryStatus.stop(); spinnerStatus.stop(); output.ensureNewline(); writeMemoryHint({ output, hints }); requestRender();
209
+ ensureStarted(); flushStreamDeltas(); retryStatus.stop(); spinnerStatus.stop(); output.ensureNewline(); writeMemoryHint({ output, hints }); requestRender();
211
210
  },
212
211
 
213
212
  clearOutput: () => {
214
- ensureStarted(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); requestRender();
213
+ ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); requestRender();
215
214
  },
216
-
217
215
  restoreTranscript: (turns) => {
218
- ensureStarted(); spinnerStatus.stop(); retryStatus.stop(); output.clear();
219
- writeTranscriptToOutput(output, turns);
220
- requestRender();
216
+ ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); writeTranscriptToOutput(output, turns); requestRender();
221
217
  },
222
218
 
223
219
  setStatusBar: (text) => {
@@ -229,6 +225,7 @@ export function createTuiUI({
229
225
  },
230
226
 
231
227
  turnEnd: () => {
228
+ flushStreamDeltas();
232
229
  const changed = output.ensureNewline();
233
230
  if (output.sealCurrentText() || changed) requestRender();
234
231
  },
@@ -238,6 +235,7 @@ export function createTuiUI({
238
235
 
239
236
  editDiff: (path, diffLines) => {
240
237
  ensureStarted();
238
+ flushStreamDeltas();
241
239
  spinnerStatus.stop();
242
240
  writeEditDiff({ output, path, diffLines });
243
241
  requestRender();
@@ -278,6 +276,7 @@ export function createTuiUI({
278
276
  toggleShellDrawer: () => shellDrawerControls.toggle(),
279
277
  requestExit: () => inputController.requestExit(),
280
278
  close: async () => {
279
+ flushStreamDeltas();
281
280
  renderScheduler.clearPending();
282
281
  spinnerStatus.stop();
283
282
  retryStatus.stop();
@@ -58,7 +58,7 @@ The user primarily asks for software engineering work: fixing bugs, adding behav
58
58
  - To edit an existing memory, use memory_open(id) to get its path, then edit_file with mode="patch" for targeted edits.
59
59
  - Use memory_save() to create memories or update whole fields. Before creating a new memory, first search/open related memories and merge updates into an existing memory when they share the same topic, project, or decision thread; prefer modifying the existing memory file over creating a scattered new one. Tags are the primary retrieval key for future recall. Prefer lowercase kebab-case tags like 'march-cli', 'tooling', 'permissions'.
60
60
  - When learning multiple related external workflows or skills, maintain memory as an evolving domain library: start with the specific source name when only one item exists, then rename and rewrite the memory title/description as the scope grows; merge new related learnings into the same memory, preserving each source's unique traits while distilling reusable principles.
61
- - Distinguish "migrating a Skill to memory" from "learning a Skill": migration preserves the complete Skill folder under memory_root/skills/ and creates a memory index with purpose, source, entry files, and local path; learning only reads and internalizes the Skill's methods, scenarios, and principles into ordinary memory without copying source files. Infer the action from the user's wording, and ask when ambiguous.
61
+ - Distinguish "migrating a Skill to memory" from "learning a Skill": migration preserves the complete Skill folder under memory_root/skills/ and creates a memory entry as its index; that memory should describe what the Skill is for and reference the copied Skill folder path so future recall knows how to use it. Learning only reads and internalizes the Skill's methods, scenarios, and principles into ordinary memory without copying source files. Infer the action from the user's wording, and ask when ambiguous.
62
62
  - Unlike memory hints, this system-core center is always visible in every model call. Only update the center for instructions that must always be followed; use memory for contextual, project-specific, or recall-dependent knowledge.
63
63
  - If execution takes a meaningful detour, create or update a memory after the task. A detour means the initial plan or assumption failed, multiple approaches were tried, and the final successful path contains reusable project knowledge. Record the failed assumption, what was tried, and the successful approach. Prefer updating an existing related memory over creating a new one.
64
64
  </memory_system>
@@ -10,7 +10,6 @@ import {
10
10
  normalizeSize,
11
11
  publicShell,
12
12
  requireShell,
13
- stripAnsi,
14
13
  touch,
15
14
  uniqueName,
16
15
  } from "./runtime-state.mjs";
@@ -194,6 +193,14 @@ export function createShellRuntime({
194
193
  };
195
194
  }
196
195
 
196
+ function snapshotShellScreen(id) {
197
+ const shell = requireShell(shells, id);
198
+ return {
199
+ shell: publicShell(shell),
200
+ screen: shell.screen?.snapshot?.() ?? null,
201
+ };
202
+ }
203
+
197
204
  function clearShell(id) {
198
205
  const shell = requireShell(shells, id);
199
206
  shell.rawChunks = [];
@@ -238,6 +245,7 @@ export function createShellRuntime({
238
245
  getShell,
239
246
  searchShell,
240
247
  snapshotShell,
248
+ snapshotShellScreen,
241
249
  clearShell,
242
250
  dispose,
243
251
  };
@@ -1,112 +0,0 @@
1
- import { readdirSync, statSync } from "node:fs";
2
- import { isAbsolute, relative, resolve } from "node:path";
3
- import { defineTool } from "@earendil-works/pi-coding-agent";
4
- import { Type } from "typebox";
5
- import { toolText } from "./tool-result.mjs";
6
-
7
- const DEFAULT_LIMIT = 1000;
8
- const DEFAULT_IGNORES = new Set([".git", "node_modules"]);
9
-
10
- export function createFindTool({ cwd }) {
11
- return defineTool({
12
- name: "find",
13
- label: "Find Files",
14
- description: "Find files by glob pattern. Pattern is matched relative to the search directory. Basename-only patterns like '*.mjs' search recursively, so find('*.mjs', path:'src') and find('src/**/*.mjs') both work.",
15
- parameters: Type.Object({
16
- pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.mjs', '**/*.json', or 'src/**/*.test.mjs'" }),
17
- path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
18
- limit: Type.Optional(Type.Number({ description: "Maximum number of results (default 1000)" })),
19
- }),
20
- execute: async (_toolCallId, params) => executeFind({ cwd, ...params }),
21
- });
22
- }
23
-
24
- export function executeFind({ cwd, pattern, path = ".", limit = DEFAULT_LIMIT }) {
25
- const searchRoot = resolveSearchRoot(cwd, path);
26
- const trimmedPattern = String(pattern ?? "").trim().replaceAll("\\", "/");
27
- if (!trimmedPattern) return toolText("Error: pattern is required", { error: true });
28
- const effectivePattern = normalizePattern(trimmedPattern);
29
-
30
- const max = Math.max(1, Number(limit) || DEFAULT_LIMIT);
31
- let files;
32
- try {
33
- files = listFiles(searchRoot);
34
- } catch (err) {
35
- return toolText(`Error: ${err.message}`, { error: true });
36
- }
37
-
38
- const matches = [];
39
- for (const file of files) {
40
- const rel = toPosix(relative(searchRoot, file));
41
- if (!matchesGlob(effectivePattern, rel)) continue;
42
- matches.push(rel);
43
- if (matches.length >= max) break;
44
- }
45
-
46
- if (matches.length === 0) return toolText("No files found matching pattern", { pattern: trimmedPattern, effectivePattern, path: searchRoot, count: 0 });
47
- const limitHint = matches.length >= max ? `\n\n[Results truncated to ${max}. Increase limit or refine pattern.]` : "";
48
- return toolText(`${matches.join("\n")}${limitHint}`, {
49
- pattern: trimmedPattern,
50
- effectivePattern: effectivePattern === trimmedPattern ? undefined : effectivePattern,
51
- path: searchRoot,
52
- count: matches.length,
53
- resultLimitReached: matches.length >= max ? max : undefined,
54
- });
55
- }
56
-
57
- function normalizePattern(pattern) {
58
- if (pattern.includes("/") || pattern.includes("**")) return pattern;
59
- return `**/${pattern}`;
60
- }
61
-
62
- function resolveSearchRoot(cwd, path) {
63
- const raw = String(path || ".");
64
- return isAbsolute(raw) ? raw : resolve(cwd, raw);
65
- }
66
-
67
- function listFiles(root) {
68
- const out = [];
69
- walk(root, out);
70
- return out.sort((a, b) => toPosix(a).localeCompare(toPosix(b)));
71
- }
72
-
73
- function walk(dir, out) {
74
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
75
- if (entry.isDirectory() && DEFAULT_IGNORES.has(entry.name)) continue;
76
- const path = resolve(dir, entry.name);
77
- if (entry.isDirectory()) walk(path, out);
78
- else if (entry.isFile()) out.push(path);
79
- }
80
- }
81
-
82
- function matchesGlob(pattern, candidate) {
83
- return matchSegments(splitGlob(pattern), splitGlob(candidate));
84
- }
85
-
86
- function matchSegments(patternSegments, candidateSegments) {
87
- if (patternSegments.length === 0) return candidateSegments.length === 0;
88
- const [head, ...tail] = patternSegments;
89
- if (head === "**") {
90
- if (matchSegments(tail, candidateSegments)) return true;
91
- return candidateSegments.length > 0 && matchSegments(patternSegments, candidateSegments.slice(1));
92
- }
93
- if (candidateSegments.length === 0) return false;
94
- return matchSegment(head, candidateSegments[0]) && matchSegments(tail, candidateSegments.slice(1));
95
- }
96
-
97
- function matchSegment(pattern, candidate) {
98
- const regex = new RegExp(`^${escapeRegex(pattern).replaceAll("\\*", "[^/]*").replaceAll("\\?", "[^/]")}$`);
99
- return regex.test(candidate);
100
- }
101
-
102
- function splitGlob(value) {
103
- return String(value).split("/").filter(Boolean);
104
- }
105
-
106
- function toPosix(value) {
107
- return String(value).replaceAll("\\", "/");
108
- }
109
-
110
- function escapeRegex(value) {
111
- return String(value).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
112
- }