march-cli 0.1.33 → 0.1.35

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 (55) hide show
  1. package/package.json +12 -1
  2. package/src/agent/lifecycle/runner-lifecycle.mjs +16 -0
  3. package/src/agent/lifecycle/runtime-restart-tool.mjs +22 -0
  4. package/src/agent/runner.mjs +9 -14
  5. package/src/agent/runtime/remote-runner-client.mjs +7 -15
  6. package/src/agent/runtime/runner-ipc-target.mjs +3 -22
  7. package/src/agent/runtime/runner-process-client.mjs +101 -24
  8. package/src/agent/runtime/runner-runtime-host.mjs +2 -0
  9. package/src/agent/runtime/state/runner-state.mjs +80 -0
  10. package/src/agent/session/session-options.mjs +2 -1
  11. package/src/agent/tools.mjs +3 -1
  12. package/src/cli/args.mjs +14 -3
  13. package/src/cli/commands/catalog/visible-commands.mjs +5 -0
  14. package/src/cli/commands/help-command.mjs +1 -7
  15. package/src/cli/commands/registry/slash-command-registry.mjs +293 -0
  16. package/src/cli/input/autocomplete.mjs +2 -25
  17. package/src/cli/repl-loop.mjs +24 -41
  18. package/src/cli/slash-commands.mjs +19 -185
  19. package/src/cli/startup/app-runtime.mjs +201 -0
  20. package/src/cli/startup/configured-command.mjs +9 -0
  21. package/src/cli/startup/early-command.mjs +29 -0
  22. package/src/cli/turn/turn-input-preparer.mjs +41 -0
  23. package/src/main.mjs +47 -242
  24. package/src/memory/markdown/memory-id.mjs +36 -0
  25. package/src/memory/markdown-store.mjs +17 -6
  26. package/src/memory/markdown-tools.mjs +3 -2
  27. package/src/web-ui/command.mjs +112 -0
  28. package/src/web-ui/dist/assets/index-BUmhnID4.css +1 -0
  29. package/src/web-ui/dist/assets/index-CtuqTjcB.js +1845 -0
  30. package/src/web-ui/dist/index.html +13 -0
  31. package/src/web-ui/index.html +12 -0
  32. package/src/web-ui/runtime-host.mjs +185 -0
  33. package/src/web-ui/server.mjs +139 -0
  34. package/src/web-ui/session-manager.mjs +109 -0
  35. package/src/web-ui/src/App.tsx +7 -0
  36. package/src/web-ui/src/components/AppShell.tsx +47 -0
  37. package/src/web-ui/src/components/Composer.tsx +47 -0
  38. package/src/web-ui/src/components/FileExplorer.tsx +46 -0
  39. package/src/web-ui/src/components/RightSidebar.tsx +70 -0
  40. package/src/web-ui/src/components/SessionTimeline.tsx +31 -0
  41. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +109 -0
  42. package/src/web-ui/src/components/timeline/TimelineList.tsx +14 -0
  43. package/src/web-ui/src/fileTreeAdapter.ts +51 -0
  44. package/src/web-ui/src/main.tsx +11 -0
  45. package/src/web-ui/src/mockData.ts +87 -0
  46. package/src/web-ui/src/model.ts +62 -0
  47. package/src/web-ui/src/runtime/client.ts +74 -0
  48. package/src/web-ui/src/runtime/runtimeTimeline.ts +88 -0
  49. package/src/web-ui/src/runtime/useWebRuntime.ts +132 -0
  50. package/src/web-ui/src/styles/shell.css +156 -0
  51. package/src/web-ui/src/styles/tokens.css +116 -0
  52. package/src/web-ui/src/timelineAdapter.ts +43 -0
  53. package/src/web-ui/src/vite-env.d.ts +1 -0
  54. package/src/web-ui/tsconfig.json +20 -0
  55. package/src/web-ui/vite.config.mjs +11 -0
@@ -0,0 +1,70 @@
1
+ import { useState } from "react";
2
+ import type { ActivityEvent, SessionSummary } from "../model";
3
+ import type { FsEntry } from "../runtime/client";
4
+
5
+ type RightSidebarProps = {
6
+ sessions: SessionSummary[];
7
+ activity: ActivityEvent[];
8
+ fsEntries: FsEntry[];
9
+ fsPath: string | null;
10
+ running: boolean;
11
+ onOpenSession: (sessionId: string) => Promise<void>;
12
+ onCreateSession: (workspacePath: string) => Promise<void>;
13
+ onBrowseRoots: () => Promise<void>;
14
+ onBrowsePath: (path: string) => Promise<void>;
15
+ };
16
+
17
+ export function RightSidebar({ sessions, activity, fsEntries, fsPath, running, onOpenSession, onCreateSession, onBrowseRoots, onBrowsePath }: RightSidebarProps) {
18
+ const [workspacePath, setWorkspacePath] = useState("");
19
+ const canCreate = workspacePath.trim().length > 0 && !running;
20
+
21
+ async function createFromPath(path = workspacePath) {
22
+ const nextPath = path.trim();
23
+ if (!nextPath || running) return;
24
+ await onCreateSession(nextPath);
25
+ setWorkspacePath("");
26
+ }
27
+
28
+ return (
29
+ <aside className="panel right-panel" aria-label="Sessions">
30
+ <div className="right-header">会话</div>
31
+ <div className="right-body">
32
+ <div className="workspace-picker" aria-label="Workspace picker">
33
+ <label htmlFor="workspace-path">Workspace</label>
34
+ <div className="workspace-input-row">
35
+ <input
36
+ id="workspace-path"
37
+ value={workspacePath}
38
+ onChange={(event) => setWorkspacePath(event.target.value)}
39
+ placeholder="Paste or browse a folder path"
40
+ />
41
+ <button type="button" disabled={!canCreate} onClick={() => createFromPath()}>Open</button>
42
+ </div>
43
+ <div className="workspace-path">{fsPath ?? "Roots"}</div>
44
+ <button type="button" className="fs-row" onClick={onBrowseRoots}>↖ Roots</button>
45
+ {fsEntries.map((entry) => (
46
+ <div key={entry.path} className="fs-entry-row">
47
+ <button type="button" onClick={() => onBrowsePath(entry.path)}>{entry.name}</button>
48
+ <button type="button" onClick={() => createFromPath(entry.path)}>Open</button>
49
+ </div>
50
+ ))}
51
+ </div>
52
+
53
+ <div className="right-divider">Sessions</div>
54
+ {sessions.map((session) => (
55
+ <button key={session.id} className={session.active ? "session-row active" : "session-row"} type="button" onClick={() => onOpenSession(session.id)}>
56
+ <span>{session.title}</span>
57
+ <time>{session.workspacePath ?? session.time}</time>
58
+ </button>
59
+ ))}
60
+ <div className="right-divider">Activity</div>
61
+ {activity.map((event) => (
62
+ <button key={event.id} className="activity-row" type="button">
63
+ <span>{event.action}</span>
64
+ <time>{event.time}</time>
65
+ </button>
66
+ ))}
67
+ </div>
68
+ </aside>
69
+ );
70
+ }
@@ -0,0 +1,31 @@
1
+ import { normalizeTimelineEvents } from "../timelineAdapter";
2
+ import type { WebUiModel } from "../model";
3
+ import { TimelineList } from "./timeline/TimelineList";
4
+
5
+ export type SessionTimelineProps = {
6
+ timeline: WebUiModel["timeline"];
7
+ connected: boolean;
8
+ error: string | null;
9
+ };
10
+
11
+ export function SessionTimeline({ timeline, connected, error }: SessionTimelineProps) {
12
+ const items = normalizeTimelineEvents(timeline.events);
13
+
14
+ return (
15
+ <main className="timeline" aria-label="Agent timeline">
16
+ <div className="main-header">
17
+ <span>Session</span>
18
+ <span className={connected ? "runtime-pill connected" : "runtime-pill"}>
19
+ {connected ? "runner" : "mock"}
20
+ </span>
21
+ </div>
22
+ <div className="timeline-scroll">
23
+ <div className="session-title">
24
+ <h1>{timeline.title}</h1>
25
+ <span>{error ?? timeline.meta}</span>
26
+ </div>
27
+ <TimelineList items={items} />
28
+ </div>
29
+ </main>
30
+ );
31
+ }
@@ -0,0 +1,109 @@
1
+ import type { TimelineItem } from "../../model";
2
+
3
+ type TimelineBlocksProps = {
4
+ item: TimelineItem;
5
+ };
6
+
7
+ export function TimelineBlocks({ item }: TimelineBlocksProps) {
8
+ if (item.kind === "message") return <MessageBlock item={item} />;
9
+
10
+ return (
11
+ <article className="message-row assistant-turn">
12
+ <div className="agent-dot march">M</div>
13
+ <div className="message-body"><AuxBlock item={item} /></div>
14
+ </article>
15
+ );
16
+ }
17
+
18
+ function MessageBlock({ item }: { item: Extract<TimelineItem, { kind: "message" }> }) {
19
+ return (
20
+ <article className={`message-row ${item.actor === "user" ? "user-turn" : "assistant-turn"}`}>
21
+ <div className={item.actor === "user" ? "agent-dot" : "agent-dot march"}>
22
+ {item.actor === "user" ? "U" : "M"}
23
+ </div>
24
+ <div className="message-body">
25
+ <p>{item.text}</p>
26
+ {item.time ? <time>{item.time}</time> : null}
27
+ </div>
28
+ </article>
29
+ );
30
+ }
31
+
32
+ function AuxBlock({ item }: { item: Exclude<TimelineItem, { kind: "message" }> }) {
33
+ switch (item.kind) {
34
+ case "thought":
35
+ return <ThoughtBlock item={item} />;
36
+ case "tool":
37
+ return <ToolBlock item={item} />;
38
+ case "diff":
39
+ return <DiffBlock item={item} />;
40
+ case "terminal":
41
+ return <TerminalBlock item={item} />;
42
+ case "permission":
43
+ return <PermissionBlock item={item} />;
44
+ case "error":
45
+ return <ErrorBlock item={item} />;
46
+ }
47
+ }
48
+
49
+ function ThoughtBlock({ item }: { item: Extract<TimelineItem, { kind: "thought" }> }) {
50
+ return (
51
+ <details className="timeline-aux thought-block" open={item.status === "open"}>
52
+ <summary><span>thinking</span><strong>{item.title}</strong></summary>
53
+ <p>{item.text}</p>
54
+ </details>
55
+ );
56
+ }
57
+
58
+ function ToolBlock({ item }: { item: Extract<TimelineItem, { kind: "tool" }> }) {
59
+ return (
60
+ <details className="timeline-aux tool-block" open={item.status !== "done"}>
61
+ <summary>
62
+ <span>{item.tool}</span>
63
+ <strong>{item.target}</strong>
64
+ <em>{item.status}</em>
65
+ </summary>
66
+ {item.summary ? <p>{item.summary}</p> : null}
67
+ </details>
68
+ );
69
+ }
70
+
71
+ function DiffBlock({ item }: { item: Extract<TimelineItem, { kind: "diff" }> }) {
72
+ return (
73
+ <div className="timeline-aux diff-block">
74
+ <div className="aux-title"><span>diff</span><strong>{item.path}</strong></div>
75
+ <div className="diff-inline">
76
+ {item.lines.map((line) => (
77
+ <div key={`${line.kind}:${line.text}`} className={`diff-line ${line.kind}`}>{line.text}</div>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ function TerminalBlock({ item }: { item: Extract<TimelineItem, { kind: "terminal" }> }) {
85
+ return (
86
+ <details className="timeline-aux terminal-block" open={item.status !== "done"}>
87
+ <summary><span>terminal</span><strong>{item.command}</strong><em>{item.status}</em></summary>
88
+ <pre>{item.output}</pre>
89
+ </details>
90
+ );
91
+ }
92
+
93
+ function PermissionBlock({ item }: { item: Extract<TimelineItem, { kind: "permission" }> }) {
94
+ return (
95
+ <div className={`timeline-aux permission-block ${item.status}`}>
96
+ <div className="aux-title"><span>permission</span><strong>{item.title}</strong><em>{item.status}</em></div>
97
+ <p>{item.detail}</p>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ function ErrorBlock({ item }: { item: Extract<TimelineItem, { kind: "error" }> }) {
103
+ return (
104
+ <div className="timeline-aux error-block">
105
+ <div className="aux-title"><span>error</span><strong>{item.message}</strong></div>
106
+ {item.detail ? <p>{item.detail}</p> : null}
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,14 @@
1
+ import type { TimelineItem } from "../../model";
2
+ import { TimelineBlocks } from "./TimelineBlocks";
3
+
4
+ export type TimelineListProps = {
5
+ items: TimelineItem[];
6
+ };
7
+
8
+ export function TimelineList({ items }: TimelineListProps) {
9
+ return (
10
+ <div className="timeline-list" aria-label="Session events">
11
+ {items.map((item) => <TimelineBlocks key={item.id} item={item} />)}
12
+ </div>
13
+ );
14
+ }
@@ -0,0 +1,51 @@
1
+ import type { GitStatusEntry } from "@pierre/trees";
2
+ import type { FileNode } from "./model";
3
+
4
+ export type ProjectFileTreeInput = {
5
+ boundPaths: Set<string>;
6
+ expandedPaths: string[];
7
+ gitStatus: GitStatusEntry[];
8
+ paths: string[];
9
+ selectedPaths: string[];
10
+ };
11
+
12
+ export function createProjectFileTreeInput(root: FileNode): ProjectFileTreeInput {
13
+ const input: ProjectFileTreeInput = {
14
+ boundPaths: new Set<string>(),
15
+ expandedPaths: [],
16
+ gitStatus: [],
17
+ paths: [],
18
+ selectedPaths: [],
19
+ };
20
+
21
+ visitNode(root, "", input);
22
+
23
+ return input;
24
+ }
25
+
26
+ function visitNode(node: FileNode, parentPath: string, input: ProjectFileTreeInput) {
27
+ const path = joinPath(parentPath, node.name);
28
+
29
+ input.paths.push(node.kind === "folder" ? `${path}/` : path);
30
+
31
+ if (node.kind === "folder" && node.children?.length) {
32
+ input.expandedPaths.push(path);
33
+ }
34
+ if (node.selected || node.active) {
35
+ input.selectedPaths.push(path);
36
+ }
37
+ if (node.bound) {
38
+ input.boundPaths.add(path);
39
+ }
40
+ if (node.gitStatus) {
41
+ input.gitStatus.push({ path, status: node.gitStatus });
42
+ }
43
+
44
+ for (const child of node.children ?? []) {
45
+ visitNode(child, path, input);
46
+ }
47
+ }
48
+
49
+ function joinPath(parentPath: string, name: string) {
50
+ return parentPath ? `${parentPath}/${name}` : name;
51
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles/tokens.css";
5
+ import "./styles/shell.css";
6
+
7
+ createRoot(document.getElementById("root")!).render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>,
11
+ );
@@ -0,0 +1,87 @@
1
+ import type { WebUiModel } from "./model";
2
+
3
+ export const mockWebUiModel: WebUiModel = {
4
+ activeSessionId: "web-shell",
5
+ workspace: {
6
+ id: "root",
7
+ name: "march-cli-standalone",
8
+ kind: "folder",
9
+ selected: true,
10
+ bound: true,
11
+ children: [
12
+ {
13
+ id: "src",
14
+ name: "src",
15
+ kind: "folder",
16
+ children: [
17
+ {
18
+ id: "web-ui",
19
+ name: "web-ui",
20
+ kind: "folder",
21
+ children: [
22
+ { id: "app", name: "App.tsx", kind: "file", active: true, bound: true, gitStatus: "modified" },
23
+ { id: "model", name: "model.ts", kind: "file" },
24
+ { id: "styles", name: "styles.css", kind: "file" },
25
+ ],
26
+ },
27
+ ],
28
+ },
29
+ { id: "test", name: "test", kind: "folder" },
30
+ { id: "agents", name: "AGENTS.md", kind: "file" },
31
+ { id: "package", name: "package.json", kind: "file", gitStatus: "modified" },
32
+ ],
33
+ },
34
+ timeline: {
35
+ title: "Web shell",
36
+ meta: "mock adapter · fast",
37
+ events: [
38
+ { id: "u1", type: "user_message", text: "参考 MindFS,做正式 Web UI。", time: "09:41" },
39
+ { id: "m1", type: "assistant_message", text: "先组件化,runtime 保持不变。", time: "09:41" },
40
+ {
41
+ id: "th1",
42
+ type: "assistant_thought",
43
+ title: "Planning",
44
+ text: "把 runtime 事件先整理成 timeline item,再交给 UI 渲染。",
45
+ status: "closed",
46
+ },
47
+ { id: "t1", type: "tool_call", tool: "read", target: "workspace", status: "running" },
48
+ { id: "tr1", type: "tool_result", tool: "read", summary: "4 files inspected", status: "done" },
49
+ {
50
+ id: "d1",
51
+ type: "file_diff",
52
+ path: "src/web-ui/src/model.ts",
53
+ lines: [
54
+ { kind: "add", text: "+ Timeline adapter model" },
55
+ { kind: "keep", text: " runtime adapter pending" },
56
+ ],
57
+ },
58
+ {
59
+ id: "term1",
60
+ type: "terminal_output",
61
+ command: "npm run test:fast",
62
+ output: "PASS web-ui.smoke.mjs",
63
+ status: "done",
64
+ },
65
+ {
66
+ id: "perm1",
67
+ type: "permission_request",
68
+ title: "Write files",
69
+ detail: "Edit local workspace source files",
70
+ status: "approved",
71
+ },
72
+ ],
73
+ },
74
+ sessions: [
75
+ { id: "web-shell", title: "Web shell", time: "now", active: true },
76
+ { id: "memory", title: "Memory", time: "1d" },
77
+ ],
78
+ activity: [
79
+ { id: "read", action: "read", time: "now" },
80
+ { id: "edit", action: "edit", time: "2m" },
81
+ { id: "test", action: "test", time: "5m" },
82
+ ],
83
+ composer: {
84
+ mode: "Chat",
85
+ placeholder: "Message March…",
86
+ },
87
+ };
@@ -0,0 +1,62 @@
1
+ export type FileNode = {
2
+ id: string;
3
+ name: string;
4
+ kind: "file" | "folder";
5
+ selected?: boolean;
6
+ active?: boolean;
7
+ bound?: boolean;
8
+ gitStatus?: "added" | "deleted" | "ignored" | "modified" | "renamed" | "untracked";
9
+ children?: FileNode[];
10
+ };
11
+
12
+ export type MarchTimelineEvent =
13
+ | { id: string; type: "user_message"; text: string; time?: string }
14
+ | { id: string; type: "assistant_message"; text: string; time?: string }
15
+ | { id: string; type: "assistant_thought"; title: string; text: string; status: "open" | "closed" }
16
+ | { id: string; type: "tool_call"; tool: string; target: string; status: "running" | "done" | "failed" }
17
+ | { id: string; type: "tool_result"; tool: string; summary: string; status: "done" | "failed" }
18
+ | { id: string; type: "file_diff"; path: string; lines: Array<{ kind: "add" | "remove" | "keep"; text: string }> }
19
+ | { id: string; type: "terminal_output"; command: string; output: string; status: "running" | "done" | "failed" }
20
+ | { id: string; type: "permission_request"; title: string; detail: string; status: "pending" | "approved" | "denied" }
21
+ | { id: string; type: "error"; message: string; detail?: string };
22
+
23
+ export type TimelineItem =
24
+ | { id: string; kind: "message"; actor: "user" | "march"; text: string; time?: string }
25
+ | { id: string; kind: "thought"; title: string; text: string; status: "open" | "closed" }
26
+ | { id: string; kind: "tool"; tool: string; target: string; status: "running" | "done" | "failed"; summary?: string }
27
+ | { id: string; kind: "diff"; path: string; lines: Array<{ kind: "add" | "remove" | "keep"; text: string }> }
28
+ | { id: string; kind: "terminal"; command: string; output: string; status: "running" | "done" | "failed" }
29
+ | { id: string; kind: "permission"; title: string; detail: string; status: "pending" | "approved" | "denied" }
30
+ | { id: string; kind: "error"; message: string; detail?: string };
31
+
32
+ export type SessionSummary = {
33
+ id: string;
34
+ title: string;
35
+ workspacePath?: string;
36
+ time: string;
37
+ active?: boolean;
38
+ };
39
+
40
+ export type ActivityEvent = {
41
+ id: string;
42
+ action: string;
43
+ time: string;
44
+ };
45
+
46
+ export type ComposerState = {
47
+ mode: string;
48
+ placeholder: string;
49
+ };
50
+
51
+ export type WebUiModel = {
52
+ activeSessionId?: string | null;
53
+ workspace: FileNode;
54
+ timeline: {
55
+ title: string;
56
+ meta: string;
57
+ events: MarchTimelineEvent[];
58
+ };
59
+ sessions: SessionSummary[];
60
+ activity: ActivityEvent[];
61
+ composer: ComposerState;
62
+ };
@@ -0,0 +1,74 @@
1
+ import type { SessionSummary, WebUiModel } from "../model";
2
+
3
+ export type RuntimeUiEvent =
4
+ | { type: "web_user_message"; text: string }
5
+ | { type: "turn_start" }
6
+ | { type: "turn_end" }
7
+ | { type: "assistant_reply_end" }
8
+ | { type: "text_delta"; delta: string }
9
+ | { type: "thinking_start" }
10
+ | { type: "thinking_delta"; delta: string }
11
+ | { type: "thinking_end"; tokens?: number }
12
+ | { type: "tool_start"; name: string; args?: unknown }
13
+ | { type: "tool_end"; name: string; isError?: boolean; result?: unknown }
14
+ | { type: "edit_diff"; path: string; diffLines?: Array<{ type?: string; text?: string }> }
15
+ | { type: "permission_request"; toolName: string; category?: string; params?: unknown }
16
+ | { type: "status"; text: string }
17
+ | { type: "retry_start"; errorMessage?: string }
18
+ | { type: "retry_end"; success?: boolean; finalError?: string };
19
+
20
+ export type FsEntry = { name: string; path: string; kind: "root" | "directory" };
21
+
22
+ export async function fetchRuntimeSnapshot(sessionId?: string | null): Promise<WebUiModel> {
23
+ const response = await fetch(apiPath("/api/snapshot", { sessionId }));
24
+ if (!response.ok) throw new Error(await response.text());
25
+ return response.json();
26
+ }
27
+
28
+ export async function createRuntimeSession(workspacePath: string): Promise<{ session: SessionSummary; snapshot: WebUiModel }> {
29
+ const response = await fetch("/api/sessions", {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json" },
32
+ body: JSON.stringify({ workspacePath }),
33
+ });
34
+ if (!response.ok) throw new Error(await response.text());
35
+ return response.json();
36
+ }
37
+
38
+ export async function fetchFsRoots(): Promise<FsEntry[]> {
39
+ const response = await fetch("/api/fs/roots");
40
+ if (!response.ok) throw new Error(await response.text());
41
+ return (await response.json()).roots;
42
+ }
43
+
44
+ export async function fetchFsList(path: string): Promise<FsEntry[]> {
45
+ const response = await fetch(apiPath("/api/fs/list", { path }));
46
+ if (!response.ok) throw new Error(await response.text());
47
+ return (await response.json()).entries;
48
+ }
49
+
50
+ export async function submitRuntimeTurn(sessionId: string, prompt: string) {
51
+ const response = await fetch("/api/turn", {
52
+ method: "POST",
53
+ headers: { "content-type": "application/json" },
54
+ body: JSON.stringify({ sessionId, prompt }),
55
+ });
56
+ if (!response.ok) throw new Error(await response.text());
57
+ return response.json();
58
+ }
59
+
60
+ export function connectRuntimeEvents(sessionId: string, onEvent: (event: RuntimeUiEvent) => void, onError: () => void) {
61
+ const source = new EventSource(apiPath("/api/events", { sessionId }));
62
+ source.addEventListener("runtime", (message) => {
63
+ onEvent(JSON.parse((message as MessageEvent).data) as RuntimeUiEvent);
64
+ });
65
+ source.onerror = onError;
66
+ return () => source.close();
67
+ }
68
+
69
+ function apiPath(path: string, params: Record<string, string | null | undefined>) {
70
+ const query = new URLSearchParams();
71
+ for (const [key, value] of Object.entries(params)) if (value) query.set(key, value);
72
+ const suffix = query.toString();
73
+ return suffix ? `${path}?${suffix}` : path;
74
+ }
@@ -0,0 +1,88 @@
1
+ import type { MarchTimelineEvent } from "../model";
2
+ import type { RuntimeUiEvent } from "./client";
3
+
4
+ export function applyRuntimeEvent(events: MarchTimelineEvent[], event: RuntimeUiEvent): MarchTimelineEvent[] {
5
+ const next = [...events];
6
+ const id = `${event.type}:${next.length}:${Date.now()}`;
7
+ switch (event.type) {
8
+ case "web_user_message":
9
+ next.push({ id, type: "user_message", text: event.text, time: nowTime() });
10
+ return next;
11
+ case "text_delta":
12
+ return appendAssistantDelta(next, event.delta, id);
13
+ case "thinking_start":
14
+ next.push({ id, type: "assistant_thought", title: "Thinking", text: "", status: "open" });
15
+ return next;
16
+ case "thinking_delta":
17
+ return appendThoughtDelta(next, event.delta, id);
18
+ case "thinking_end":
19
+ return closeThought(next);
20
+ case "tool_start":
21
+ next.push({ id, type: "tool_call", tool: event.name, target: formatArgs(event.args), status: "running" });
22
+ return next;
23
+ case "tool_end":
24
+ next.push({ id, type: "tool_result", tool: event.name, summary: formatResult(event.result), status: event.isError ? "failed" : "done" });
25
+ return next;
26
+ case "edit_diff":
27
+ next.push({ id, type: "file_diff", path: event.path, lines: toDiffLines(event.diffLines) });
28
+ return next;
29
+ case "permission_request":
30
+ next.push({ id, type: "permission_request", title: event.toolName, detail: event.category ?? "permission", status: "pending" });
31
+ return next;
32
+ case "status":
33
+ next.push({ id, type: "terminal_output", command: "status", output: event.text, status: "done" });
34
+ return next;
35
+ case "retry_start":
36
+ next.push({ id, type: "error", message: "Retrying", detail: event.errorMessage });
37
+ return next;
38
+ case "retry_end":
39
+ if (!event.success && event.finalError) next.push({ id, type: "error", message: "Retry failed", detail: event.finalError });
40
+ return next;
41
+ default:
42
+ return next;
43
+ }
44
+ }
45
+
46
+ function appendAssistantDelta(events: MarchTimelineEvent[], delta: string, id: string) {
47
+ const last = events.at(-1);
48
+ if (last?.type === "assistant_message") last.text += delta;
49
+ else events.push({ id, type: "assistant_message", text: delta, time: nowTime() });
50
+ return events;
51
+ }
52
+
53
+ function appendThoughtDelta(events: MarchTimelineEvent[], delta: string, id: string) {
54
+ const last = events.at(-1);
55
+ if (last?.type === "assistant_thought" && last.status === "open") last.text += delta;
56
+ else events.push({ id, type: "assistant_thought", title: "Thinking", text: delta, status: "open" });
57
+ return events;
58
+ }
59
+
60
+ function closeThought(events: MarchTimelineEvent[]) {
61
+ const last = events.at(-1);
62
+ if (last?.type === "assistant_thought") last.status = "closed";
63
+ return events;
64
+ }
65
+
66
+ function toDiffLines(lines: Array<{ type?: string; text?: string }> = []) {
67
+ return lines.map((line) => ({ kind: toDiffKind(line.type), text: line.text ?? "" }));
68
+ }
69
+
70
+ function toDiffKind(type?: string): "add" | "remove" | "keep" {
71
+ if (type === "add") return "add";
72
+ if (type === "del") return "remove";
73
+ return "keep";
74
+ }
75
+
76
+ function formatArgs(args: unknown) {
77
+ if (args === undefined || args === null) return "running";
78
+ return JSON.stringify(args).slice(0, 160);
79
+ }
80
+
81
+ function formatResult(result: unknown) {
82
+ if (result === undefined || result === null) return "done";
83
+ return typeof result === "string" ? result.slice(0, 240) : JSON.stringify(result).slice(0, 240);
84
+ }
85
+
86
+ function nowTime() {
87
+ return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date());
88
+ }