march-cli 0.1.39 → 0.1.41

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.39",
3
+ "version": "0.1.41",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
package/src/cli/args.mjs CHANGED
@@ -69,6 +69,7 @@ Usage:
69
69
  march [options] (starts REPL)
70
70
  march login [provider] Login to an OAuth provider
71
71
  march provider --config Configure provider credentials
72
+ march provider remove Remove a configured provider interactively
72
73
  march provider share [id] Share a provider profile
73
74
  march provider accept <token>
74
75
  march web [path] Start the local Web UI session manager
@@ -9,6 +9,7 @@ export const DEFAULT_KEYBINDINGS = Object.freeze({
9
9
  thinkingSelector: "Ctrl+T",
10
10
  modelSelector: "Ctrl+L",
11
11
  externalEditor: "Ctrl+G",
12
+ clearInput: "Ctrl+U",
12
13
  toggleToolOutput: "Ctrl+O",
13
14
  toggleShellDrawer: "Alt+S",
14
15
  nextShell: "Alt+N",
@@ -27,6 +28,7 @@ export const KEYBINDING_ACTIONS = Object.freeze({
27
28
  thinkingSelector: "Open thinking selector",
28
29
  modelSelector: "Open model selector",
29
30
  externalEditor: "Open external editor ($VISUAL or $EDITOR)",
31
+ clearInput: "Clear current input draft",
30
32
  toggleToolOutput: "Toggle tool output collapsed/expanded",
31
33
  toggleShellDrawer: "Toggle right-side shell pane",
32
34
  nextShell: "Select next shell in pane",
@@ -104,6 +104,10 @@ export async function runInteractiveRepl({
104
104
  trimmed = templateResult.prompt;
105
105
  }
106
106
 
107
+ if (turnActive.viewOnly) {
108
+ ui.writeln("This session is view-only. Use /session and Take over control before sending prompts.");
109
+ continue;
110
+ }
107
111
  if (turnActive.turnTask) {
108
112
  ui.writeln("This session is still running. Use /session to start or inspect another session.");
109
113
  continue;
@@ -94,7 +94,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
94
94
  ui,
95
95
  activeProjectId: currentProjectInfo.projectId,
96
96
  activeSessionId: sessionState.sessionId,
97
- onRenderTimelineChange: persistRenderTimeline,
97
+ onPersistRenderTimeline: persistRenderTimeline,
98
98
  });
99
99
  const runtimeUi = outputRouter.createProjectUi(currentProjectInfo.projectId, () => sessionState.sessionId);
100
100
  let turnRunning = false;
@@ -176,7 +176,10 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
176
176
  onNotificationActivation,
177
177
  });
178
178
  },
179
- onActivate: ({ projectId, sessionId }) => outputRouter.setActiveSession(projectId, sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDirs.get(projectId), sessionId) }),
179
+ onActivate: ({ projectId, sessionId, runtime }) => {
180
+ if (runtime?.projectMarchDir) projectMarchDirs.set(projectId, runtime.projectMarchDir);
181
+ outputRouter.setActiveSession(projectId, sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDirs.get(projectId), sessionId) });
182
+ },
180
183
  });
181
184
  runner = workspaceSupervisor.runner;
182
185
 
@@ -213,8 +216,8 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
213
216
  outputRouter.setActiveSession(currentProjectInfo.projectId, sessionState.sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDir, sessionState.sessionId) });
214
217
  refreshStatusBar();
215
218
 
216
- function persistRenderTimeline({ projectId, sessionId, events, event }) {
217
- if (!sessionId || !shouldPersistRenderEvent(event?.method)) return;
219
+ function persistRenderTimeline({ projectId, sessionId, events }) {
220
+ if (!sessionId) return;
218
221
  const routeProjectMarchDir = projectMarchDirs.get(projectId);
219
222
  if (!routeProjectMarchDir) return;
220
223
  try {
@@ -259,10 +262,6 @@ function loadStoredRenderTimeline(projectMarchDir, sessionId) {
259
262
  }
260
263
  }
261
264
 
262
- function shouldPersistRenderEvent(method) {
263
- return method === "turnEnd" || method === "assistantReplyEnd" || method === "toolEnd" || method === "writeln" || method === "clearOutput";
264
- }
265
-
266
265
  async function handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, ui }) {
267
266
  if (activation?.type !== "workspace-session" || !activation.projectId) return;
268
267
  if (!workspaceSupervisor) throw new Error("workspace supervisor is not ready");
@@ -0,0 +1,45 @@
1
+ import { createToolCardBlock, writeToolEnd } from "../tool-rendering.mjs";
2
+ import { formatRecallLines } from "../recall-rendering.mjs";
3
+
4
+ export function restoreTimelineBlocksToOutputBuffer(output, blocks) {
5
+ output.clear();
6
+ for (const block of Array.isArray(blocks) ? blocks : []) appendTimelineBlock(output, block);
7
+ output.invalidate?.();
8
+ }
9
+
10
+ function appendTimelineBlock(output, block) {
11
+ if (!block || typeof block !== "object") return;
12
+ switch (block.type) {
13
+ case "assistant":
14
+ if (block.content) output.addBlock({ type: "markdown", text: String(block.content), sealed: Boolean(block.closed), cache: new Map() });
15
+ break;
16
+ case "thinking":
17
+ output.addBlock({ type: "thinking", tokens: block.tokens ?? 0, content: splitLines(block.content) });
18
+ break;
19
+ case "tool": {
20
+ const card = createToolCardBlock({ name: block.name, args: block.args });
21
+ output.addBlock(card);
22
+ if (block.closed) writeToolEnd({ output, name: block.name, isError: block.isError, result: block.result, toolBlock: card });
23
+ break;
24
+ }
25
+ case "output":
26
+ if (block.newline) output.writeln(String(block.content ?? ""));
27
+ else output.write(String(block.content ?? ""));
28
+ break;
29
+ case "status":
30
+ output.addBlock({ type: "status", lines: [String(block.content ?? "")] });
31
+ break;
32
+ case "recall":
33
+ output.addBlock({ type: "plain", lines: formatRecallLines(block.hints ?? []) });
34
+ break;
35
+ case "editDiff":
36
+ output.addBlock({ type: "diff", path: block.path, diffLines: block.diffLines ?? [] });
37
+ break;
38
+ default:
39
+ break;
40
+ }
41
+ }
42
+
43
+ function splitLines(value) {
44
+ return String(value ?? "").split("\n");
45
+ }
@@ -2,6 +2,7 @@ import { brightBlack, dim } from "./ui-theme.mjs";
2
2
  import { renderToolCardBlock } from "./output/tool-card-renderer.mjs";
3
3
  import { renderMarkdown, renderStreamingMarkdown } from "./markdown-renderer.mjs";
4
4
  import { renderEditDiffBlock } from "./tui-diff-rendering.mjs";
5
+ import { restoreTimelineBlocksToOutputBuffer } from "./output/timeline-block-restore.mjs";
5
6
  import { OutputScrollState } from "./output/scroll-state.mjs";
6
7
  import { appendTextLines, wrapLine } from "./output/text-line-renderer.mjs";
7
8
  import { appendSelectableEntries, copySourceTextForRange, sliceEntriesWithTail } from "./output/selectable-copy.mjs";
@@ -89,6 +90,10 @@ export class OutputBuffer {
89
90
  this._baseEntriesCache = new Map();
90
91
  }
91
92
 
93
+ restoreTimelineBlocks(blocks) {
94
+ restoreTimelineBlocksToOutputBuffer(this, blocks);
95
+ }
96
+
92
97
  write(text) { this._writeText(text, false); }
93
98
  writeMarkdown(text) { this._writeText(text, true); }
94
99
 
@@ -40,6 +40,14 @@ export function createTuiInputController({ editor, requestRender, historyStore =
40
40
  requestRender();
41
41
  },
42
42
 
43
+ clearInput() {
44
+ attachmentTokens.clear();
45
+ editor.lastAction = null;
46
+ editor.historyIndex = -1;
47
+ setEditorText("");
48
+ requestRender();
49
+ },
50
+
43
51
  insertAttachmentAtCursor({ marker, label }) {
44
52
  const token = uniqueAttachmentToken(label || "[image]", attachmentTokens);
45
53
  attachmentTokens.set(token, marker);
@@ -59,4 +67,12 @@ export function createTuiInputController({ editor, requestRender, historyStore =
59
67
  historyStore?.save?.(editor.history);
60
68
  } catch {}
61
69
  }
70
+
71
+ function setEditorText(text) {
72
+ if (typeof editor.setTextInternal === "function") {
73
+ editor.setTextInternal(text);
74
+ return;
75
+ }
76
+ editor.setText?.(text);
77
+ }
62
78
  }
package/src/cli/ui.mjs CHANGED
@@ -87,6 +87,7 @@ export function createTuiUI({
87
87
  thinkingSelector: () => onCtrlTHandler?.(),
88
88
  modelSelector: () => onCtrlLHandler?.(),
89
89
  externalEditor: () => openExternalEditor(),
90
+ clearInput: () => inputController.clearInput(),
90
91
  toggleToolOutput: () => toggleToolOutput(),
91
92
  toggleShellDrawer: () => shellDrawerControls.toggle(),
92
93
  nextShell: () => shellDrawerControls.selectNext(),
@@ -216,10 +217,13 @@ export function createTuiUI({
216
217
  },
217
218
 
218
219
  clearOutput: () => {
219
- ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); requestRender();
220
+ ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); activeToolBlocks.length = 0; output.clear(); requestRender();
221
+ },
222
+ restoreTimelineBlocks: (blocks) => {
223
+ ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); activeToolBlocks.length = 0; output.restoreTimelineBlocks(blocks); requestRender();
220
224
  },
221
225
  restoreTranscript: (turns) => {
222
- ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); writeTranscriptToOutput(output, turns); requestRender();
226
+ ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); activeToolBlocks.length = 0; output.clear(); writeTranscriptToOutput(output, turns); requestRender();
223
227
  },
224
228
 
225
229
  setStatusBar: (text) => {
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { brightBlack } from "../tui/ui-theme.mjs";
4
4
  import { registerProject, listRegisteredProjects } from "../../workspace/project-registry.mjs";
5
5
  import { buildWorkspaceSessionSelectItems, listWorkspaceSessions, workspaceSessionSearchText } from "../../workspace/session-index.mjs";
6
+ import { SessionControllerLeaseConflictError } from "../../session/control/controller-lease.mjs";
6
7
 
7
8
  export const WORKSPACE_SLASH_COMMANDS = [
8
9
  {
@@ -93,6 +94,9 @@ export async function handleSessionCommand({ stateRoot, currentProjectId, runner
93
94
  ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
94
95
  return { handled: true, refreshContextTokens: true, activeChanged: true };
95
96
  } catch (err) {
97
+ if (err instanceof SessionControllerLeaseConflictError) {
98
+ return await handleSessionControllerConflict({ err, item, workspaceSupervisor, workspaceOutputRouter, ui });
99
+ }
96
100
  ui.writeln(`Error: ${err.message}`);
97
101
  return { handled: true };
98
102
  }
@@ -101,6 +105,46 @@ export async function handleSessionCommand({ stateRoot, currentProjectId, runner
101
105
  return { handled: true };
102
106
  }
103
107
 
108
+ async function handleSessionControllerConflict({ err, item, workspaceSupervisor, workspaceOutputRouter, ui }) {
109
+ ui.writeln(err.message);
110
+ if (!ui.selectList) return { handled: true };
111
+ const choice = await ui.selectList({
112
+ items: [
113
+ { value: "view-only", label: "View only", description: "inspect this session without taking control" },
114
+ { value: "take-over", label: "Take over control", description: "steal the controller lease for this session" },
115
+ { value: "cancel", label: "Cancel", description: "leave current controller unchanged" },
116
+ ],
117
+ selectedIndex: 0,
118
+ width: 70,
119
+ suppressInitialConfirm: true,
120
+ });
121
+ if (choice?.value === "view-only") {
122
+ try {
123
+ await workspaceSupervisor.viewWorkspaceSession({ project: item.project, session: item.session });
124
+ ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
125
+ ui.writeln(`Viewing session: ${item.project.displayName} / ${item.session.name || item.session.id}`);
126
+ ui.writeln("View-only mode: prompts are disabled until you take over control.");
127
+ return { handled: true, refreshContextTokens: true, activeChanged: true };
128
+ } catch (viewErr) {
129
+ ui.writeln(`Error: ${viewErr.message}`);
130
+ return { handled: true };
131
+ }
132
+ }
133
+ if (choice?.value !== "take-over") {
134
+ ui.writeln("Session unchanged.");
135
+ return { handled: true };
136
+ }
137
+ try {
138
+ await workspaceSupervisor.activateWorkspaceSession({ project: item.project, session: item.session, force: true });
139
+ ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
140
+ ui.writeln(`Took over session: ${item.project.displayName} / ${item.session.name || item.session.id}`);
141
+ return { handled: true, refreshContextTokens: true, activeChanged: true };
142
+ } catch (takeoverErr) {
143
+ ui.writeln(`Error: ${takeoverErr.message}`);
144
+ return { handled: true };
145
+ }
146
+ }
147
+
104
148
  function annotateWorkspaceItems(items, runtimeSummaries) {
105
149
  if (!runtimeSummaries.length) return items;
106
150
  const running = new Set(runtimeSummaries.filter((runtime) => runtime.running).map((runtime) => `${runtime.projectId}:${runtime.sessionId}`));
@@ -1,3 +1,7 @@
1
+ import { createTuiTimelineRegistry } from "./tui-timeline.mjs";
2
+
3
+ const PERSIST_FLUSH_METHODS = new Set(["turnEnd", "assistantReplyEnd", "toolEnd"]);
4
+
1
5
  const RENDER_METHODS = new Set([
2
6
  "turnStart",
3
7
  "turnEnd",
@@ -19,11 +23,18 @@ const RENDER_METHODS = new Set([
19
23
  "clearOutput",
20
24
  ]);
21
25
 
22
- const MAX_RENDER_EVENTS_PER_ROUTE = 4000;
23
-
24
- export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSessionId = null, onRenderTimelineChange = null }) {
26
+ export function createWorkspaceOutputRouter({
27
+ ui,
28
+ activeProjectId,
29
+ activeSessionId = null,
30
+ onPersistRenderTimeline = null,
31
+ persistDebounceMs,
32
+ } = {}) {
25
33
  let active = routeKey(activeProjectId, activeSessionId);
26
- const timelines = new Map();
34
+ const timelineRegistry = createTuiTimelineRegistry({
35
+ persistDebounceMs,
36
+ onPersistTimeline: (change) => onPersistRenderTimeline?.({ ...parseRouteKey(change.key), ...change }),
37
+ });
27
38
 
28
39
  return {
29
40
  setActiveProject(projectId) {
@@ -31,8 +42,9 @@ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSession
31
42
  },
32
43
  setActiveSession(projectId, sessionId, { renderTimeline = null } = {}) {
33
44
  const next = routeKey(projectId, sessionId);
34
- if (Array.isArray(renderTimeline)) setRenderEvents(next, renderTimeline);
45
+ timelineRegistry.ensure(next, { events: renderTimeline });
35
46
  if (next === active) return renderRoute(next);
47
+ timelineRegistry.flush(active, "session-switch");
36
48
  active = next;
37
49
  return renderRoute(next);
38
50
  },
@@ -72,43 +84,43 @@ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSession
72
84
  return renderRoute(active);
73
85
  },
74
86
  getRenderEvents(projectId, sessionId = null) {
75
- return [...(timelines.get(routeKey(projectId, sessionId)) ?? [])];
87
+ return timelineRegistry.getEvents(routeKey(projectId, sessionId));
88
+ },
89
+ getRenderBlocks(projectId, sessionId = null) {
90
+ return timelineRegistry.getBlocks(routeKey(projectId, sessionId));
76
91
  },
77
92
  setRenderEvents(projectId, sessionId = null, events = []) {
78
- setRenderEvents(routeKey(projectId, sessionId), events);
93
+ const key = routeKey(projectId, sessionId);
94
+ timelineRegistry.replaceEvents(key, events);
79
95
  },
80
96
  getRenderEventCount(projectId, sessionId = null) {
81
- return timelines.get(routeKey(projectId, sessionId))?.length ?? 0;
97
+ return timelineRegistry.getEventCount(routeKey(projectId, sessionId));
98
+ },
99
+ getRenderTimelineMetadata(projectId, sessionId = null) {
100
+ return timelineRegistry.getMetadata(routeKey(projectId, sessionId));
101
+ },
102
+ flushRenderTimeline(projectId, sessionId = null, reason = "manual") {
103
+ return timelineRegistry.flush(routeKey(projectId, sessionId), reason);
104
+ },
105
+ flushAllRenderTimelines(reason = "manual") {
106
+ return timelineRegistry.flushAll(reason);
82
107
  },
83
108
  };
84
109
 
85
110
  function renderRoute(key) {
86
- ui.clearOutput?.();
87
- const events = timelines.get(key) ?? [];
88
- for (const event of events) applyRenderEvent(event);
89
- return events.length;
90
- }
91
-
92
- function applyRenderEvent({ method, args }) {
93
- const value = ui[method];
94
- if (typeof value === "function") value.apply(ui, args);
111
+ const timeline = timelineRegistry.ensure(key);
112
+ if (typeof ui.restoreTimelineBlocks !== "function") ui.clearOutput?.();
113
+ return timeline.replayTo(ui);
95
114
  }
96
115
 
97
116
  function recordRenderEvent(key, method, args) {
98
117
  if (method === "clearOutput") {
99
- timelines.delete(key);
100
- onRenderTimelineChange?.({ ...parseRouteKey(key), events: [], event: { method, args } });
118
+ timelineRegistry.clear(key);
101
119
  return;
102
120
  }
103
- const events = timelines.get(key) ?? [];
104
- events.push({ method, args, at: Date.now() });
105
- if (events.length > MAX_RENDER_EVENTS_PER_ROUTE) events.splice(0, events.length - MAX_RENDER_EVENTS_PER_ROUTE);
106
- timelines.set(key, events);
107
- onRenderTimelineChange?.({ ...parseRouteKey(key), events: [...events], event: { method, args } });
108
- }
109
-
110
- function setRenderEvents(key, events) {
111
- timelines.set(key, normalizeRenderEvents(events));
121
+ const timeline = timelineRegistry.ensure(key);
122
+ timeline.apply(method, args);
123
+ if (PERSIST_FLUSH_METHODS.has(method)) timeline.flushPersist(method);
112
124
  }
113
125
  }
114
126
 
@@ -120,8 +132,3 @@ function parseRouteKey(key) {
120
132
  const [projectId, sessionId = ""] = String(key ?? "").split(":", 2);
121
133
  return { projectId: projectId || null, sessionId: sessionId || null };
122
134
  }
123
-
124
- function normalizeRenderEvents(events) {
125
- if (!Array.isArray(events)) return [];
126
- return events.filter((event) => typeof event?.method === "string" && Array.isArray(event.args));
127
- }
@@ -0,0 +1,179 @@
1
+ export function createTuiTimelineProjection() {
2
+ let blocks = [];
3
+ let openAssistantBlock = null;
4
+ let openThinkingBlock = null;
5
+ let openToolBlocks = [];
6
+ let nextBlockIndex = 1;
7
+
8
+ return {
9
+ apply(event) {
10
+ applyProjectionEvent(event);
11
+ },
12
+ rebuild(events) {
13
+ resetProjection();
14
+ for (const event of events) applyProjectionEvent(event);
15
+ },
16
+ clear() {
17
+ resetProjection();
18
+ },
19
+ getBlocks() {
20
+ return blocks.map((block) => structuredCloneSafe(block));
21
+ },
22
+ getMetadata() {
23
+ return {
24
+ blockCount: blocks.length,
25
+ openAssistant: Boolean(openAssistantBlock),
26
+ openThinking: Boolean(openThinkingBlock),
27
+ openToolCount: openToolBlocks.length,
28
+ };
29
+ },
30
+ };
31
+
32
+ function applyProjectionEvent(event) {
33
+ const [first, second, third] = event.args ?? [];
34
+ switch (event.method) {
35
+ case "turnStart":
36
+ closeAssistantBlock();
37
+ blocks.push(createBlock("turn", event.at, { phase: "start" }));
38
+ break;
39
+ case "turnEnd":
40
+ closeAssistantBlock(event.at);
41
+ closeThinkingBlock(event.at);
42
+ blocks.push(createBlock("turn", event.at, { phase: "end" }));
43
+ break;
44
+ case "textDelta":
45
+ ensureAssistantBlock(event.at).content += String(first ?? "");
46
+ touchBlock(openAssistantBlock, event.at);
47
+ break;
48
+ case "assistantReplyEnd":
49
+ closeAssistantBlock(event.at);
50
+ break;
51
+ case "thinkingStart":
52
+ closeThinkingBlock(event.at);
53
+ openThinkingBlock = createBlock("thinking", event.at, { content: "", closed: false });
54
+ blocks.push(openThinkingBlock);
55
+ break;
56
+ case "thinkingDelta":
57
+ ensureThinkingBlock(event.at).content += String(first ?? "");
58
+ touchBlock(openThinkingBlock, event.at);
59
+ break;
60
+ case "thinkingEnd":
61
+ ensureThinkingBlock(event.at).tokens = first ?? null;
62
+ closeThinkingBlock(event.at);
63
+ break;
64
+ case "thinkingBlock":
65
+ closeThinkingBlock(event.at);
66
+ blocks.push(createBlock("thinking", event.at, { tokens: first ?? null, content: String(second ?? ""), closed: true }));
67
+ break;
68
+ case "toolStart": {
69
+ closeAssistantBlock(event.at);
70
+ const block = createBlock("tool", event.at, { name: first ?? null, args: second ?? null, result: null, isError: false, closed: false });
71
+ blocks.push(block);
72
+ openToolBlocks.push(block);
73
+ break;
74
+ }
75
+ case "toolEnd": {
76
+ const block = popOpenToolBlock(first);
77
+ if (block) {
78
+ block.name ??= first ?? null;
79
+ block.isError = Boolean(second);
80
+ block.result = third ?? null;
81
+ block.closed = true;
82
+ touchBlock(block, event.at);
83
+ } else {
84
+ blocks.push(createBlock("tool", event.at, { name: first ?? null, isError: Boolean(second), result: third ?? null, closed: true }));
85
+ }
86
+ break;
87
+ }
88
+ case "write":
89
+ case "writeln":
90
+ closeAssistantBlock(event.at);
91
+ blocks.push(createBlock("output", event.at, { content: String(first ?? ""), newline: event.method === "writeln" }));
92
+ break;
93
+ case "status":
94
+ blocks.push(createBlock("status", event.at, { content: String(first ?? "") }));
95
+ break;
96
+ case "recall":
97
+ blocks.push(createBlock("recall", event.at, { hints: first?.hints ?? [] }));
98
+ break;
99
+ case "editDiff":
100
+ closeAssistantBlock(event.at);
101
+ blocks.push(createBlock("editDiff", event.at, { path: first ?? null, diffLines: second ?? [] }));
102
+ break;
103
+ case "retryStart":
104
+ case "retryEnd":
105
+ blocks.push(createBlock("retry", event.at, { method: event.method, payload: first ?? null }));
106
+ break;
107
+ default:
108
+ blocks.push(createBlock("event", event.at, { method: event.method, args: event.args ?? [] }));
109
+ break;
110
+ }
111
+ }
112
+
113
+ function ensureAssistantBlock(at) {
114
+ if (!openAssistantBlock) {
115
+ openAssistantBlock = createBlock("assistant", at, { content: "", closed: false });
116
+ blocks.push(openAssistantBlock);
117
+ }
118
+ return openAssistantBlock;
119
+ }
120
+
121
+ function closeAssistantBlock(at = null) {
122
+ if (!openAssistantBlock) return;
123
+ openAssistantBlock.closed = true;
124
+ touchBlock(openAssistantBlock, at);
125
+ openAssistantBlock = null;
126
+ }
127
+
128
+ function ensureThinkingBlock(at) {
129
+ if (!openThinkingBlock) {
130
+ openThinkingBlock = createBlock("thinking", at, { content: "", closed: false });
131
+ blocks.push(openThinkingBlock);
132
+ }
133
+ return openThinkingBlock;
134
+ }
135
+
136
+ function closeThinkingBlock(at = null) {
137
+ if (!openThinkingBlock) return;
138
+ openThinkingBlock.closed = true;
139
+ touchBlock(openThinkingBlock, at);
140
+ openThinkingBlock = null;
141
+ }
142
+
143
+ function popOpenToolBlock(name) {
144
+ if (openToolBlocks.length === 0) return null;
145
+ if (name == null) return openToolBlocks.pop();
146
+ const index = findLastIndex(openToolBlocks, (block) => block.name === name);
147
+ if (index < 0) return openToolBlocks.pop();
148
+ return openToolBlocks.splice(index, 1)[0];
149
+ }
150
+ function resetProjection() {
151
+ blocks = [];
152
+ openAssistantBlock = null;
153
+ openThinkingBlock = null;
154
+ openToolBlocks = [];
155
+ nextBlockIndex = 1;
156
+ }
157
+
158
+ function createBlock(type, at, fields = {}) {
159
+ const id = `${type}-${nextBlockIndex++}`;
160
+ return { id, type, createdAt: at ?? null, updatedAt: at ?? null, ...fields };
161
+ }
162
+ }
163
+
164
+ function touchBlock(block, at = null) {
165
+ if (!block || at == null) return;
166
+ block.updatedAt = at;
167
+ }
168
+
169
+ function findLastIndex(items, predicate) {
170
+ for (let index = items.length - 1; index >= 0; index -= 1) {
171
+ if (predicate(items[index], index)) return index;
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ function structuredCloneSafe(value) {
177
+ if (typeof structuredClone === "function") return structuredClone(value);
178
+ return JSON.parse(JSON.stringify(value));
179
+ }