march-cli 0.1.39 → 0.1.40

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.40",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -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",
@@ -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;
@@ -213,8 +213,8 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
213
213
  outputRouter.setActiveSession(currentProjectInfo.projectId, sessionState.sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDir, sessionState.sessionId) });
214
214
  refreshStatusBar();
215
215
 
216
- function persistRenderTimeline({ projectId, sessionId, events, event }) {
217
- if (!sessionId || !shouldPersistRenderEvent(event?.method)) return;
216
+ function persistRenderTimeline({ projectId, sessionId, events }) {
217
+ if (!sessionId) return;
218
218
  const routeProjectMarchDir = projectMarchDirs.get(projectId);
219
219
  if (!routeProjectMarchDir) return;
220
220
  try {
@@ -259,10 +259,6 @@ function loadStoredRenderTimeline(projectMarchDir, sessionId) {
259
259
  }
260
260
  }
261
261
 
262
- function shouldPersistRenderEvent(method) {
263
- return method === "turnEnd" || method === "assistantReplyEnd" || method === "toolEnd" || method === "writeln" || method === "clearOutput";
264
- }
265
-
266
262
  async function handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, ui }) {
267
263
  if (activation?.type !== "workspace-session" || !activation.projectId) return;
268
264
  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) => {
@@ -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
+ }
@@ -0,0 +1,247 @@
1
+ import { createTuiTimelineProjection } from "./tui-timeline-projection.mjs";
2
+
3
+ export const DEFAULT_MAX_TIMELINE_EVENTS = 4000;
4
+ export const DEFAULT_TIMELINE_PERSIST_DEBOUNCE_MS = 250;
5
+
6
+ export function createTuiTimelineRegistry({
7
+ maxEventsPerTimeline = DEFAULT_MAX_TIMELINE_EVENTS,
8
+ persistDebounceMs = DEFAULT_TIMELINE_PERSIST_DEBOUNCE_MS,
9
+ onPersistTimeline = null,
10
+ } = {}) {
11
+ const timelines = new Map();
12
+
13
+ return {
14
+ ensure(key, { events = null } = {}) {
15
+ let timeline = timelines.get(key);
16
+ if (!timeline) {
17
+ timeline = createTuiTimelineInstance({
18
+ key,
19
+ maxEvents: maxEventsPerTimeline,
20
+ persistDebounceMs,
21
+ onPersist: onPersistTimeline,
22
+ });
23
+ timelines.set(key, timeline);
24
+ }
25
+ if (Array.isArray(events)) timeline.hydrateIfEmpty(events);
26
+ return timeline;
27
+ },
28
+ get(key) {
29
+ return timelines.get(key) ?? null;
30
+ },
31
+ has(key) {
32
+ return timelines.has(key);
33
+ },
34
+ clear(key, options) {
35
+ const timeline = this.ensure(key);
36
+ timeline.clear(options);
37
+ return timeline;
38
+ },
39
+ flush(key, reason = "manual") {
40
+ return this.get(key)?.flushPersist(reason) ?? false;
41
+ },
42
+ flushAll(reason = "manual") {
43
+ let flushed = 0;
44
+ for (const timeline of timelines.values()) {
45
+ if (timeline.flushPersist(reason)) flushed += 1;
46
+ }
47
+ return flushed;
48
+ },
49
+ replaceEvents(key, events) {
50
+ const timeline = this.ensure(key);
51
+ timeline.replaceEvents(events);
52
+ return timeline;
53
+ },
54
+ getEvents(key) {
55
+ return this.get(key)?.getEvents() ?? [];
56
+ },
57
+ getBlocks(key) {
58
+ return this.get(key)?.getBlocks() ?? [];
59
+ },
60
+ getEventCount(key) {
61
+ return this.get(key)?.getEventCount() ?? 0;
62
+ },
63
+ getMetadata(key) {
64
+ return this.get(key)?.getMetadata() ?? null;
65
+ },
66
+ };
67
+ }
68
+
69
+ export function createTuiTimelineInstance({
70
+ key,
71
+ maxEvents = DEFAULT_MAX_TIMELINE_EVENTS,
72
+ persistDebounceMs = DEFAULT_TIMELINE_PERSIST_DEBOUNCE_MS,
73
+ onPersist = null,
74
+ } = {}) {
75
+ let events = [];
76
+ const projection = createTuiTimelineProjection();
77
+ let hydrated = false;
78
+ let dirty = false;
79
+ let lastAccessedAt = Date.now();
80
+ let lastUpdatedAt = null;
81
+ let lastPersistedAt = null;
82
+ let estimatedBytes = 0;
83
+ let persistTimer = null;
84
+
85
+ return {
86
+ key,
87
+ apply(method, args, { at = Date.now(), persist = true } = {}) {
88
+ touch();
89
+ const event = { method, args, at };
90
+ events.push(event);
91
+ projection.apply(event);
92
+ trimToBudget();
93
+ dirty = true;
94
+ lastUpdatedAt = at;
95
+ if (persist) schedulePersist("debounce");
96
+ return event;
97
+ },
98
+ hydrateIfEmpty(nextEvents) {
99
+ touch();
100
+ if (events.length > 0) return false;
101
+ replaceEvents(nextEvents, { markHydrated: true });
102
+ return true;
103
+ },
104
+ replaceEvents(nextEvents) {
105
+ touch();
106
+ replaceEvents(nextEvents, { markHydrated: false });
107
+ },
108
+ clear({ flush = true } = {}) {
109
+ touch();
110
+ events = [];
111
+ projection.clear();
112
+ dirty = true;
113
+ lastUpdatedAt = Date.now();
114
+ estimatedBytes = 0;
115
+ if (flush) this.flushPersist("clear");
116
+ else schedulePersist("debounce");
117
+ },
118
+ replayTo(ui) {
119
+ touch();
120
+ if (typeof ui.restoreTimelineBlocks === "function") {
121
+ const blocks = projection.getBlocks();
122
+ ui.restoreTimelineBlocks(blocks);
123
+ return blocks.length;
124
+ }
125
+ for (const event of events) applyRenderEvent(ui, event);
126
+ return events.length;
127
+ },
128
+ flushPersist(reason = "manual") {
129
+ clearPersistTimer();
130
+ if (!dirty || typeof onPersist !== "function") return false;
131
+ onPersist({ key, events: this.getEvents(), reason, timeline: this.getMetadata() });
132
+ dirty = false;
133
+ lastPersistedAt = Date.now();
134
+ return true;
135
+ },
136
+ getEvents() {
137
+ touch();
138
+ return cloneEvents(events);
139
+ },
140
+ getBlocks() {
141
+ touch();
142
+ return projection.getBlocks();
143
+ },
144
+ getEventCount() {
145
+ return events.length;
146
+ },
147
+ markPersisted() {
148
+ clearPersistTimer();
149
+ dirty = false;
150
+ lastPersistedAt = Date.now();
151
+ },
152
+ getMetadata() {
153
+ return buildMetadata();
154
+ },
155
+ };
156
+
157
+ function schedulePersist(reason) {
158
+ if (typeof onPersist !== "function") return;
159
+ clearPersistTimer();
160
+ persistTimer = setTimeout(() => {
161
+ persistTimer = null;
162
+ if (!dirty) return;
163
+ onPersist({ key, events: cloneEvents(events), reason, timeline: buildMetadata() });
164
+ dirty = false;
165
+ lastPersistedAt = Date.now();
166
+ }, Math.max(0, persistDebounceMs));
167
+ persistTimer.unref?.();
168
+ }
169
+
170
+ function clearPersistTimer() {
171
+ if (!persistTimer) return;
172
+ clearTimeout(persistTimer);
173
+ persistTimer = null;
174
+ }
175
+
176
+ function replaceEvents(nextEvents, { markHydrated }) {
177
+ clearPersistTimer();
178
+ events = normalizeTimelineEvents(nextEvents);
179
+ trimToBudget();
180
+ rebuildProjection();
181
+ hydrated = markHydrated;
182
+ dirty = false;
183
+ lastUpdatedAt = events.at(-1)?.at ?? null;
184
+ lastPersistedAt = null;
185
+ updateEstimatedBytes();
186
+ }
187
+
188
+ function trimToBudget() {
189
+ if (events.length > maxEvents) {
190
+ events.splice(0, events.length - maxEvents);
191
+ rebuildProjection();
192
+ }
193
+ updateEstimatedBytes();
194
+ }
195
+
196
+ function rebuildProjection() {
197
+ projection.rebuild(events);
198
+ }
199
+
200
+ function updateEstimatedBytes() {
201
+ estimatedBytes = estimateJsonBytes(events) + estimateJsonBytes(projection.getBlocks());
202
+ }
203
+
204
+ function touch() {
205
+ lastAccessedAt = Date.now();
206
+ }
207
+
208
+ function buildMetadata() {
209
+ return {
210
+ key,
211
+ eventCount: events.length,
212
+ maxEvents,
213
+ hydrated,
214
+ dirty,
215
+ lastAccessedAt,
216
+ lastUpdatedAt,
217
+ lastPersistedAt,
218
+ estimatedBytes,
219
+ persistScheduled: Boolean(persistTimer),
220
+ ...projection.getMetadata(),
221
+ };
222
+ }
223
+ }
224
+
225
+ export function normalizeTimelineEvents(events) {
226
+ if (!Array.isArray(events)) return [];
227
+ return events
228
+ .filter((event) => typeof event?.method === "string" && Array.isArray(event.args))
229
+ .map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
230
+ }
231
+
232
+ function applyRenderEvent(ui, { method, args }) {
233
+ const value = ui[method];
234
+ if (typeof value === "function") value.apply(ui, args);
235
+ }
236
+
237
+ function cloneEvents(items) {
238
+ return items.map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
239
+ }
240
+
241
+ function estimateJsonBytes(value) {
242
+ try {
243
+ return JSON.stringify(value).length;
244
+ } catch {
245
+ return 0;
246
+ }
247
+ }
@@ -45,7 +45,7 @@ export function saveMarchSessionState({ projectMarchDir, sessionId, engine, back
45
45
  export function saveMarchSessionStateValue({ projectMarchDir, sessionId, state }) {
46
46
  if (!sessionId) throw new Error("March session id is required");
47
47
  const existing = loadMarchSessionState({ projectMarchDir, sessionId })?.state ?? null;
48
- const nextState = normalizeMarchSessionStateForSave({ ...existing, ...state, renderTimeline: state.renderTimeline ?? existing?.renderTimeline });
48
+ const nextState = normalizeMarchSessionStateForSave({ ...existing, ...state });
49
49
  validateMarchSessionState(nextState);
50
50
  const dir = getMarchSessionStateDir(projectMarchDir, sessionId);
51
51
  mkdirSync(dir, { recursive: true });
@@ -135,22 +135,12 @@ function validateMarchSessionState(state) {
135
135
  function isValidMarchSessionState(state) {
136
136
  return state?.version === MARCH_SESSION_STATE_VERSION
137
137
  && Boolean(state.cwd)
138
- && Array.isArray(state.turns)
139
- && Array.isArray(state.renderTimeline);
138
+ && Array.isArray(state.turns);
140
139
  }
141
140
 
142
141
  function normalizeMarchSessionStateForSave(state) {
143
- return {
144
- ...state,
145
- renderTimeline: normalizePersistedRenderTimeline(state.renderTimeline),
146
- };
147
- }
148
-
149
- function normalizePersistedRenderTimeline(events) {
150
- if (!Array.isArray(events)) return [];
151
- return events
152
- .filter((event) => typeof event?.method === "string" && Array.isArray(event.args))
153
- .map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
142
+ const { renderTimeline: _renderTimeline, renderTimelineUpdatedAt: _renderTimelineUpdatedAt, ...coreState } = state ?? {};
143
+ return coreState;
154
144
  }
155
145
 
156
146
  function normalizeSessionRef(sessionRef) {
@@ -1,29 +1,45 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { getMarchSessionStatePath, loadMarchSessionState, saveMarchSessionStateValue } from "./march-session-state.mjs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getMarchSessionStatePath, loadMarchSessionState, normalizeSessionId } from "./march-session-state.mjs";
4
+
5
+ export const TUI_SESSION_TIMELINE_VERSION = 1;
6
+
7
+ export function getTuiSessionUiStateRoot(projectMarchDir) {
8
+ return join(projectMarchDir, "ui", "tui", "sessions");
9
+ }
10
+
11
+ export function getTuiSessionTimelinePath(projectMarchDir, sessionId) {
12
+ return join(getTuiSessionUiStateRoot(projectMarchDir), normalizeSessionId(sessionId), "timeline.json");
13
+ }
3
14
 
4
15
  export function loadMarchSessionRenderTimeline({ projectMarchDir, sessionId }) {
5
- const persistedRender = readPersistedRenderTimelineInfo({ projectMarchDir, sessionId });
16
+ const tuiTimeline = loadTuiRenderTimeline({ projectMarchDir, sessionId });
17
+ if (tuiTimeline) return tuiTimeline;
18
+
19
+ const legacyTimeline = loadLegacyCoreRenderTimeline({ projectMarchDir, sessionId });
20
+ if (legacyTimeline) return legacyTimeline;
21
+
6
22
  const stored = loadMarchSessionState({ projectMarchDir, sessionId });
7
23
  if (!stored) return null;
8
- const renderTimeline = normalizeSessionRenderTimeline(stored.state.renderTimeline);
9
24
  return {
10
25
  path: stored.path,
11
- renderTimeline: persistedRender.hasUiOwnedTimeline ? renderTimeline : renderTimelineFromTurns(stored.state.turns ?? []),
26
+ renderTimeline: renderTimelineFromTurns(stored.state.turns ?? []),
27
+ source: "core-turns",
12
28
  };
13
29
  }
14
30
 
15
31
  export function saveMarchSessionRenderTimeline({ projectMarchDir, sessionId, renderTimeline }) {
16
- const stored = loadMarchSessionState({ projectMarchDir, sessionId });
17
- if (!stored) return null;
18
- return saveMarchSessionStateValue({
19
- projectMarchDir,
32
+ if (!sessionId) throw new Error("March session id is required");
33
+ const state = {
34
+ version: TUI_SESSION_TIMELINE_VERSION,
35
+ savedAt: new Date().toISOString(),
20
36
  sessionId,
21
- state: {
22
- ...stored.state,
23
- renderTimeline: normalizeSessionRenderTimeline(renderTimeline),
24
- renderTimelineUpdatedAt: new Date().toISOString(),
25
- },
26
- });
37
+ renderTimeline: normalizeSessionRenderTimeline(renderTimeline),
38
+ };
39
+ const path = getTuiSessionTimelinePath(projectMarchDir, sessionId);
40
+ mkdirSync(dirname(path), { recursive: true });
41
+ writeFileSync(path, JSON.stringify(state, null, 2), "utf8");
42
+ return { path, renderTimeline: state.renderTimeline, state };
27
43
  }
28
44
 
29
45
  export function normalizeSessionRenderTimeline(events) {
@@ -33,15 +49,28 @@ export function normalizeSessionRenderTimeline(events) {
33
49
  .map((event) => ({ method: event.method, args: event.args, at: event.at ?? null }));
34
50
  }
35
51
 
36
- function readPersistedRenderTimelineInfo({ projectMarchDir, sessionId }) {
52
+ function loadTuiRenderTimeline({ projectMarchDir, sessionId }) {
53
+ try {
54
+ const path = getTuiSessionTimelinePath(projectMarchDir, sessionId);
55
+ if (!existsSync(path)) return null;
56
+ const state = JSON.parse(readFileSync(path, "utf8"));
57
+ if (state?.version !== TUI_SESSION_TIMELINE_VERSION) return null;
58
+ return { path, renderTimeline: normalizeSessionRenderTimeline(state.renderTimeline), source: "tui" };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function loadLegacyCoreRenderTimeline({ projectMarchDir, sessionId }) {
37
65
  try {
38
66
  const path = getMarchSessionStatePath(projectMarchDir, sessionId);
39
- if (!existsSync(path)) return { hasUiOwnedTimeline: false };
67
+ if (!existsSync(path)) return null;
40
68
  const raw = JSON.parse(readFileSync(path, "utf8"));
41
69
  const rawTimeline = normalizeSessionRenderTimeline(raw.renderTimeline);
42
- return { hasUiOwnedTimeline: Boolean(raw.renderTimelineUpdatedAt) || rawTimeline.length > 0 };
70
+ if (!raw.renderTimelineUpdatedAt && rawTimeline.length === 0) return null;
71
+ return { path, renderTimeline: rawTimeline, source: "legacy-core-render" };
43
72
  } catch {
44
- return { hasUiOwnedTimeline: false };
73
+ return null;
45
74
  }
46
75
  }
47
76
 
@@ -96,12 +96,13 @@ export function createWorkspaceSessionSupervisor({ initialRuntime, createProject
96
96
  let runtime = session?.id ? runtimes.get(runtimeKey(project.projectId, session.id)) : findIdleRuntime(project.projectId);
97
97
  if (!runtime) runtime = await createProjectRuntime(project);
98
98
 
99
+ const targetSessionId = session?.id ?? null;
99
100
  let restoreState = null;
100
- if (session?.path && getRuntimeSessionId(runtime) !== session.id) {
101
+ if (session?.path && getRuntimeSessionId(runtime) !== targetSessionId) {
101
102
  restoreState = loadWorkspaceMarchSessionState({ runtime, session });
102
103
  await runtime.runner.switchPiSession(session.path, restoreState);
103
- syncSessionState(runtime, session.id);
104
104
  }
105
+ if (targetSessionId) syncSessionState(runtime, targetSessionId);
105
106
 
106
107
  active = runtime;
107
108
  rememberRuntime(runtime);
@@ -153,12 +154,11 @@ function loadWorkspaceMarchSessionState({ runtime, session }) {
153
154
  if (stored.state.cwd && stored.state.cwd !== runtime.runner.engine.cwd) {
154
155
  throw new Error(`March session state cwd mismatch for ${session.id}: ${stored.state.cwd}`);
155
156
  }
156
- const { renderTimeline: _renderTimeline, ...contextState } = stored.state;
157
- return contextState;
157
+ return stored.state;
158
158
  }
159
159
 
160
160
  function getRuntimeSessionId(runtime) {
161
- return runtime.runner.getSessionStats?.()?.sessionId ?? runtime.sessionState?.sessionId ?? null;
161
+ return runtime.sessionState?.sessionId ?? runtime.runner.getSessionStats?.()?.sessionId ?? null;
162
162
  }
163
163
 
164
164
  function syncSessionState(runtime, sessionId) {