march-cli 0.1.38 → 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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/agent/runner.mjs +8 -8
  3. package/src/agent/runtime/runner-process-factory.mjs +1 -1
  4. package/src/agent/turn/turn-runner.mjs +4 -4
  5. package/src/cli/args.mjs +3 -0
  6. package/src/cli/commands/mode-command.mjs +1 -0
  7. package/src/cli/commands/registry/slash-command-registry.mjs +2 -3
  8. package/src/cli/input/keybindings.mjs +2 -0
  9. package/src/cli/repl-commands.mjs +1 -1
  10. package/src/cli/repl-loop.mjs +1 -1
  11. package/src/cli/session/pi-session-switch-command.mjs +11 -11
  12. package/src/cli/session/session-list-command.mjs +1 -1
  13. package/src/cli/session/session-source-command.mjs +0 -76
  14. package/src/cli/startup/app-runtime.mjs +52 -22
  15. package/src/cli/tui/output/timeline-block-restore.mjs +45 -0
  16. package/src/cli/tui/output-buffer.mjs +5 -0
  17. package/src/cli/tui/tui-input-controller.mjs +16 -0
  18. package/src/cli/ui.mjs +6 -2
  19. package/src/cli/workspace/command.mjs +11 -37
  20. package/src/cli/workspace/output-router.mjs +62 -36
  21. package/src/cli/workspace/project-runtime.mjs +2 -0
  22. package/src/cli/workspace/runtime-session-state.mjs +9 -0
  23. package/src/cli/workspace/tui-timeline-projection.mjs +179 -0
  24. package/src/cli/workspace/tui-timeline.mjs +247 -0
  25. package/src/extensions/lifecycle-adapter.mjs +2 -2
  26. package/src/main.mjs +7 -1
  27. package/src/session/sidecar-sync.mjs +3 -17
  28. package/src/session/sidecar.mjs +40 -41
  29. package/src/session/state/march-session-state.mjs +165 -0
  30. package/src/session/state/march-session-sync.mjs +20 -0
  31. package/src/session/state/march-session-ui-state.mjs +89 -0
  32. package/src/workspace/session-index.mjs +27 -0
  33. package/src/workspace/supervisor.mjs +19 -13
  34. package/src/agent/pi-session/pi-session-sidecar-failure.mjs +0 -10
  35. package/src/cli/session/session-switch-command.mjs +0 -1
  36. package/src/session/persist.mjs +0 -1
@@ -3,8 +3,6 @@ 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 { resumePiSessionById } from "../session/pi-session-switch-command.mjs";
7
- import { loadPiSessionTranscriptTurns } from "../../session/transcript.mjs";
8
6
 
9
7
  export const WORKSPACE_SLASH_COMMANDS = [
10
8
  {
@@ -19,9 +17,9 @@ export const WORKSPACE_SLASH_COMMANDS = [
19
17
  run: async (ctx, command) => writeLines(ctx.ui, await handleProjectCommand(command, ctx)),
20
18
  },
21
19
  {
22
- metadata: [{ name: "switch", description: "Open cross-project session switcher" }],
23
- match: (trimmed) => trimmed === "/switch" ? { parsed: { type: "switch" } } : null,
24
- run: handleSwitchCommand,
20
+ metadata: [{ name: "session", description: "Open workspace session selector" }],
21
+ match: (trimmed) => trimmed === "/session" ? { parsed: { type: "session" } } : null,
22
+ run: handleSessionCommand,
25
23
  },
26
24
  ];
27
25
 
@@ -45,9 +43,9 @@ export async function handleProjectCommand(command, { stateRoot }) {
45
43
  return ["Registered projects:", ...projects.map((project) => `- ${project.displayName} ${brightBlack(project.rootPath)}`)];
46
44
  }
47
45
 
48
- export async function handleSwitchCommand({ stateRoot, currentProjectId, projectMarchDir, runner, workspaceSupervisor, workspaceOutputRouter, ui }) {
46
+ export async function handleSessionCommand({ stateRoot, currentProjectId, runner, workspaceSupervisor, workspaceOutputRouter, ui }) {
49
47
  if (!stateRoot) {
50
- ui.writeln("Session switcher is not available: workspace registry is missing.");
48
+ ui.writeln("Session selector is not available: workspace registry is missing.");
51
49
  return { handled: true };
52
50
  }
53
51
  const projects = await listWorkspaceSessions({ stateRoot, currentProjectId });
@@ -59,7 +57,7 @@ export async function handleSwitchCommand({ stateRoot, currentProjectId, project
59
57
  return { handled: true };
60
58
  }
61
59
  if (!ui.selectList) {
62
- ui.writeln("Session switcher is only available in TUI.");
60
+ ui.writeln("Session selector is only available in TUI.");
63
61
  return { handled: true };
64
62
  }
65
63
  const selectedIndex = Math.max(0, items.findIndex((item) => item.project.current && item.session?.id === currentSessionId));
@@ -82,7 +80,6 @@ export async function handleSwitchCommand({ stateRoot, currentProjectId, project
82
80
  }
83
81
  try {
84
82
  const { result } = await workspaceSupervisor.startNewWorkspaceSession(item.project);
85
- ui.restoreTranscript?.([]);
86
83
  ui.writeln(`Created session: ${item.project.displayName} / ${result?.sessionId ?? "new session"}`);
87
84
  return { handled: true, refreshContextTokens: true, activeChanged: true };
88
85
  } catch (err) {
@@ -93,25 +90,15 @@ export async function handleSwitchCommand({ stateRoot, currentProjectId, project
93
90
  if (workspaceSupervisor) {
94
91
  try {
95
92
  await workspaceSupervisor.activateWorkspaceSession({ project: item.project, session: item.session });
96
- restoreTranscriptFromSession(item.session, ui);
97
- const replayed = ctxReplayBufferedOutput({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
98
- ui.writeln(`Switched to session: ${item.project.displayName} / ${item.session.name || item.session.id}${replayed ? ` (${replayed} buffered events replayed)` : ""}`);
93
+ ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
99
94
  return { handled: true, refreshContextTokens: true, activeChanged: true };
100
95
  } catch (err) {
101
96
  ui.writeln(`Error: ${err.message}`);
102
97
  return { handled: true };
103
98
  }
104
99
  }
105
- if (!item.project.current) {
106
- ui.writeln(`Project switch target indexed: ${item.project.displayName}`);
107
- ui.writeln(brightBlack("Cross-project attach requires the workspace supervisor."));
108
- return { handled: true };
109
- }
110
- const sessions = projects.find((project) => project.current)?.sessions ?? [];
111
- const lines = await resumePiSessionById(item.session.id, { runner, sessions, projectMarchDir });
112
- if (isResumeSuccess(lines)) restoreTranscriptFromSession(item.session, ui);
113
- for (const line of lines) ui.writeln(line);
114
- return { handled: true, refreshContextTokens: isResumeSuccess(lines) };
100
+ ui.writeln("Workspace session activation requires the workspace supervisor.");
101
+ return { handled: true };
115
102
  }
116
103
 
117
104
  function annotateWorkspaceItems(items, runtimeSummaries) {
@@ -124,24 +111,11 @@ function annotateWorkspaceItems(items, runtimeSummaries) {
124
111
  });
125
112
  }
126
113
 
127
- function ctxReplayBufferedOutput({ workspaceOutputRouter, projectId, sessionId }) {
128
- return workspaceOutputRouter?.replayBufferedCalls?.(projectId, sessionId) ?? 0;
129
- }
130
-
131
- function restoreTranscriptFromSession(session, ui) {
132
- if (typeof ui.restoreTranscript !== "function") return;
133
- try {
134
- ui.restoreTranscript(loadPiSessionTranscriptTurns(session.path));
135
- } catch (err) {
136
- ui.writeln(`Warning: failed to restore session transcript: ${err.message}`);
137
- }
114
+ function ctxRenderActiveSession({ workspaceOutputRouter, projectId, sessionId }) {
115
+ return workspaceOutputRouter?.getRenderEventCount?.(projectId, sessionId) ?? 0;
138
116
  }
139
117
 
140
118
  function writeLines(ui, lines) {
141
119
  for (const line of lines) ui.writeln(line);
142
120
  return { handled: true };
143
121
  }
144
-
145
- function isResumeSuccess(lines) {
146
- return Array.isArray(lines) && lines.some((line) => String(line).startsWith("Resumed pi session:"));
147
- }
@@ -1,4 +1,8 @@
1
- const BACKGROUND_METHODS_TO_BUFFER = new Set([
1
+ import { createTuiTimelineRegistry } from "./tui-timeline.mjs";
2
+
3
+ const PERSIST_FLUSH_METHODS = new Set(["turnEnd", "assistantReplyEnd", "toolEnd"]);
4
+
5
+ const RENDER_METHODS = new Set([
2
6
  "turnStart",
3
7
  "turnEnd",
4
8
  "assistantReplyEnd",
@@ -6,28 +10,43 @@ const BACKGROUND_METHODS_TO_BUFFER = new Set([
6
10
  "thinkingStart",
7
11
  "thinkingDelta",
8
12
  "thinkingEnd",
13
+ "thinkingBlock",
9
14
  "toolStart",
10
15
  "toolEnd",
11
16
  "retryStart",
12
17
  "retryEnd",
13
18
  "status",
14
- "debugLines",
15
19
  "recall",
16
- "providerQuotaSnapshot",
17
20
  "editDiff",
21
+ "write",
18
22
  "writeln",
23
+ "clearOutput",
19
24
  ]);
20
25
 
21
- export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSessionId = null }) {
26
+ export function createWorkspaceOutputRouter({
27
+ ui,
28
+ activeProjectId,
29
+ activeSessionId = null,
30
+ onPersistRenderTimeline = null,
31
+ persistDebounceMs,
32
+ } = {}) {
22
33
  let active = routeKey(activeProjectId, activeSessionId);
23
- const buffers = new Map();
34
+ const timelineRegistry = createTuiTimelineRegistry({
35
+ persistDebounceMs,
36
+ onPersistTimeline: (change) => onPersistRenderTimeline?.({ ...parseRouteKey(change.key), ...change }),
37
+ });
24
38
 
25
39
  return {
26
40
  setActiveProject(projectId) {
27
- active = routeKey(projectId, null);
41
+ this.setActiveSession(projectId, null);
28
42
  },
29
- setActiveSession(projectId, sessionId) {
30
- active = routeKey(projectId, sessionId);
43
+ setActiveSession(projectId, sessionId, { renderTimeline = null } = {}) {
44
+ const next = routeKey(projectId, sessionId);
45
+ timelineRegistry.ensure(next, { events: renderTimeline });
46
+ if (next === active) return renderRoute(next);
47
+ timelineRegistry.flush(active, "session-switch");
48
+ active = next;
49
+ return renderRoute(next);
31
50
  },
32
51
  getActiveRouteKey() {
33
52
  return active;
@@ -46,8 +65,9 @@ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSession
46
65
  if (typeof value !== "function") return value;
47
66
  return (...args) => {
48
67
  const key = routeKey(projectId, typeof getSessionId === "function" ? getSessionId() : sessionId);
49
- if (isActiveRoute(key) || !BACKGROUND_METHODS_TO_BUFFER.has(prop)) return value.apply(ui, args);
50
- bufferBackgroundCall(key, prop, args);
68
+ if (!RENDER_METHODS.has(prop)) return value.apply(ui, args);
69
+ recordRenderEvent(key, prop, args);
70
+ if (key === active) return value.apply(ui, args);
51
71
  return undefined;
52
72
  };
53
73
  },
@@ -60,41 +80,47 @@ export function createWorkspaceOutputRouter({ ui, activeProjectId, activeSession
60
80
  },
61
81
  });
62
82
  },
63
- getBufferedCalls(projectId, sessionId = null) {
64
- return [...(buffers.get(routeKey(projectId, sessionId)) ?? [])];
83
+ renderActiveSession() {
84
+ return renderRoute(active);
65
85
  },
66
- getBufferedCallCount(projectId, sessionId = null) {
67
- return buffers.get(routeKey(projectId, sessionId))?.length ?? 0;
86
+ getRenderEvents(projectId, sessionId = null) {
87
+ return timelineRegistry.getEvents(routeKey(projectId, sessionId));
68
88
  },
69
- replayBufferedCalls(projectId, sessionId = null) {
89
+ getRenderBlocks(projectId, sessionId = null) {
90
+ return timelineRegistry.getBlocks(routeKey(projectId, sessionId));
91
+ },
92
+ setRenderEvents(projectId, sessionId = null, events = []) {
70
93
  const key = routeKey(projectId, sessionId);
71
- const calls = buffers.get(key) ?? [];
72
- buffers.delete(key);
73
- for (const call of calls) replayBufferedCall(call);
74
- return calls.length;
94
+ timelineRegistry.replaceEvents(key, events);
95
+ },
96
+ getRenderEventCount(projectId, sessionId = null) {
97
+ return timelineRegistry.getEventCount(routeKey(projectId, sessionId));
75
98
  },
76
- clearBufferedCalls(projectId, sessionId = null) {
77
- buffers.delete(routeKey(projectId, sessionId));
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);
78
107
  },
79
108
  };
80
109
 
81
- function isActiveRoute(key) {
82
- if (key === active) return true;
83
- const current = parseRouteKey(active);
84
- const candidate = parseRouteKey(key);
85
- return current.sessionId == null && current.projectId === candidate.projectId;
86
- }
87
-
88
- function replayBufferedCall({ method, args }) {
89
- const value = ui[method];
90
- if (typeof value === "function") value.apply(ui, args);
110
+ function renderRoute(key) {
111
+ const timeline = timelineRegistry.ensure(key);
112
+ if (typeof ui.restoreTimelineBlocks !== "function") ui.clearOutput?.();
113
+ return timeline.replayTo(ui);
91
114
  }
92
115
 
93
- function bufferBackgroundCall(key, method, args) {
94
- const calls = buffers.get(key) ?? [];
95
- calls.push({ method, args, at: Date.now() });
96
- if (calls.length > 2000) calls.splice(0, calls.length - 2000);
97
- buffers.set(key, calls);
116
+ function recordRenderEvent(key, method, args) {
117
+ if (method === "clearOutput") {
118
+ timelineRegistry.clear(key);
119
+ return;
120
+ }
121
+ const timeline = timelineRegistry.ensure(key);
122
+ timeline.apply(method, args);
123
+ if (PERSIST_FLUSH_METHODS.has(method)) timeline.flushPersist(method);
98
124
  }
99
125
  }
100
126
 
@@ -8,6 +8,7 @@ import { loadProjectLifecycleHookManifests } from "../../extensions/lifecycle-ma
8
8
  import { loadKeybindings } from "../input/keybindings.mjs";
9
9
  import { loadPromptTemplates } from "../input/prompt-templates.mjs";
10
10
  import { loadOrCreateProjectId } from "../../workspace/project-id.mjs";
11
+ import { syncRuntimeSessionStateFromRunner } from "./runtime-session-state.mjs";
11
12
 
12
13
  export async function createWorkspaceProjectRuntime({
13
14
  project,
@@ -73,6 +74,7 @@ export async function createWorkspaceProjectRuntime({
73
74
  refreshStatusBar,
74
75
  onNotificationActivation,
75
76
  });
77
+ syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot);
76
78
 
77
79
  return {
78
80
  project,
@@ -0,0 +1,9 @@
1
+ import { join } from "node:path";
2
+
3
+ export function syncRuntimeSessionStateFromRunner(sessionState, runner, sessionsRoot) {
4
+ const sessionId = runner?.getSessionStats?.()?.sessionId ?? null;
5
+ if (!sessionState || !sessionId) return null;
6
+ sessionState.sessionId = sessionId;
7
+ sessionState.sessionDir = sessionsRoot ? join(sessionsRoot, sessionId) : sessionState.sessionDir;
8
+ return sessionState;
9
+ }
@@ -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
+ }