march-cli 0.1.34 → 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 (52) 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/web-ui/command.mjs +112 -0
  25. package/src/web-ui/dist/assets/index-BUmhnID4.css +1 -0
  26. package/src/web-ui/dist/assets/index-CtuqTjcB.js +1845 -0
  27. package/src/web-ui/dist/index.html +13 -0
  28. package/src/web-ui/index.html +12 -0
  29. package/src/web-ui/runtime-host.mjs +185 -0
  30. package/src/web-ui/server.mjs +139 -0
  31. package/src/web-ui/session-manager.mjs +109 -0
  32. package/src/web-ui/src/App.tsx +7 -0
  33. package/src/web-ui/src/components/AppShell.tsx +47 -0
  34. package/src/web-ui/src/components/Composer.tsx +47 -0
  35. package/src/web-ui/src/components/FileExplorer.tsx +46 -0
  36. package/src/web-ui/src/components/RightSidebar.tsx +70 -0
  37. package/src/web-ui/src/components/SessionTimeline.tsx +31 -0
  38. package/src/web-ui/src/components/timeline/TimelineBlocks.tsx +109 -0
  39. package/src/web-ui/src/components/timeline/TimelineList.tsx +14 -0
  40. package/src/web-ui/src/fileTreeAdapter.ts +51 -0
  41. package/src/web-ui/src/main.tsx +11 -0
  42. package/src/web-ui/src/mockData.ts +87 -0
  43. package/src/web-ui/src/model.ts +62 -0
  44. package/src/web-ui/src/runtime/client.ts +74 -0
  45. package/src/web-ui/src/runtime/runtimeTimeline.ts +88 -0
  46. package/src/web-ui/src/runtime/useWebRuntime.ts +132 -0
  47. package/src/web-ui/src/styles/shell.css +156 -0
  48. package/src/web-ui/src/styles/tokens.css +116 -0
  49. package/src/web-ui/src/timelineAdapter.ts +43 -0
  50. package/src/web-ui/src/vite-env.d.ts +1 -0
  51. package/src/web-ui/tsconfig.json +20 -0
  52. package/src/web-ui/vite.config.mjs +11 -0
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>March Web</title>
7
+ <script type="module" crossorigin src="/assets/index-CtuqTjcB.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-BUmhnID4.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>March Web</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,185 @@
1
+ import { homedir } from "node:os";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
4
+ import { createMarchAuthStorage } from "../auth/storage.mjs";
5
+ import { createRuntimeRunner } from "../cli/startup/create-runtime-runner.mjs";
6
+ import { createPermissionController, MODE } from "../cli/permissions.mjs";
7
+ import { createCliShellRuntime } from "../shell/cli-runtime.mjs";
8
+ import { MarkdownMemoryStore } from "../memory/markdown-store.mjs";
9
+ import { createMarkdownMemoryTools } from "../memory/markdown-tools.mjs";
10
+ import { resolveMemoryRoot } from "../memory/root.mjs";
11
+ import { defaultProfilePaths, ensureProfileFiles } from "../context/profiles.mjs";
12
+ import { loadOrCreateProjectId } from "../cli/startup/startup-session.mjs";
13
+ import { createWebToolsFromConfig } from "../web/tools.mjs";
14
+ import { createLogger, installProcessLogHandlers } from "../debug/logger.mjs";
15
+ import { createModelContextDumper } from "../debug/model-context-dumper.mjs";
16
+ import { createDesktopTurnNotifier } from "../notification/desktop-notifier.mjs";
17
+ import { discoverProjectExtensionPaths } from "../extensions/discovery.mjs";
18
+ import { loadProjectLifecycleHookManifests } from "../extensions/lifecycle-manifest.mjs";
19
+ import { normalizeRemoteMemorySources } from "../memory/remote/config.mjs";
20
+ import { prepareTurnInput } from "../cli/turn/turn-input-preparer.mjs";
21
+
22
+ const MAX_WORKSPACE_DEPTH = 3;
23
+ const MAX_WORKSPACE_ENTRIES = 200;
24
+
25
+ export async function createWebRuntimeHost({ args, config, cwd, stateRoot, useRuntimeProcess = true } = {}) {
26
+ stateRoot ??= join(homedir(), ".march");
27
+ if (!existsSync(stateRoot)) mkdirSync(stateRoot, { recursive: true });
28
+ const logger = createLogger({ logDir: join(stateRoot, "logs") });
29
+ installProcessLogHandlers(logger);
30
+ const provider = args.provider ?? config.provider ?? null;
31
+ const model = args.model ?? config.model ?? null;
32
+ const authConfig = createMarchAuthStorage({ provider: provider ?? "deepseek", providers: config.providers, cwd });
33
+ if (!authConfig.hasAuth) throw new Error("No providers configured. Run: march provider --config");
34
+
35
+ const projectMarchDir = resolve(cwd, ".march");
36
+ if (!existsSync(projectMarchDir)) mkdirSync(projectMarchDir, { recursive: true });
37
+ const memoryRoot = resolveMemoryRoot(config.memoryRoot, stateRoot);
38
+ const profilePaths = defaultProfilePaths();
39
+ ensureProfileFiles(profilePaths);
40
+
41
+ const memoryStore = new MarkdownMemoryStore({ root: memoryRoot });
42
+ const remoteMemorySources = normalizeRemoteMemorySources(config);
43
+ const memoryTools = createMarkdownMemoryTools(memoryStore, { remoteSources: remoteMemorySources });
44
+ const shellRuntime = args.shellRuntime ? createCliShellRuntime({ cwd }) : null;
45
+ const extensionPaths = discoverProjectExtensionPaths(cwd);
46
+ const lifecycleManifests = loadProjectLifecycleHookManifests(cwd);
47
+ const contextDumpRoot = resolve(projectMarchDir, "context-dumps", Date.now().toString(36));
48
+ const modelContextDumper = createModelContextDumper({ enabled: args.dumpContext, rootDir: contextDumpRoot });
49
+ const permissionController = createPermissionController({ mode: args.permissionMode ?? MODE.BYPASS });
50
+ const turnNotifier = createDesktopTurnNotifier({ enabled: Boolean(config.notifications?.turnEnd), config: config.notifications });
51
+ const ui = createHeadlessWebUi();
52
+ const currentProject = basename(cwd);
53
+ const namespace = loadOrCreateProjectId(projectMarchDir);
54
+ const runnerOptions = {
55
+ cwd,
56
+ modelId: model,
57
+ provider,
58
+ serviceTier: config.serviceTier ?? null,
59
+ providers: config.providers,
60
+ config,
61
+ stateRoot,
62
+ memoryRoot,
63
+ profilePaths,
64
+ namespace,
65
+ projectMarchDir,
66
+ extensionPaths,
67
+ permissionMode: args.permissionMode,
68
+ shellRuntime: Boolean(shellRuntime),
69
+ lifecycleHooks: lifecycleManifests.hooks,
70
+ lifecycleDiagnostics: lifecycleManifests.diagnostics,
71
+ modelContextDumper: { enabled: args.dumpContext, rootDir: contextDumpRoot },
72
+ remoteMemorySources,
73
+ };
74
+ const runner = await createRuntimeRunner({
75
+ useRuntimeProcess,
76
+ runnerOptions,
77
+ ui,
78
+ memoryStore,
79
+ memoryTools,
80
+ shellRuntime,
81
+ webTools: createWebToolsFromConfig(config),
82
+ usePiSessions: true,
83
+ usePiRuntimeHost: true,
84
+ authStorage: authConfig.authStorage,
85
+ permissionController,
86
+ modelContextDumper,
87
+ turnNotifier,
88
+ logger,
89
+ });
90
+ let turnRunning = false;
91
+
92
+ return {
93
+ runner,
94
+ memoryStore,
95
+ logger,
96
+ currentProject,
97
+ snapshot: () => createWebSnapshot({ cwd, runner, currentProject }),
98
+ subscribe: (listener) => runner.runtimeUiEvents.on(listener),
99
+ async runTurn(prompt) {
100
+ if (turnRunning) throw new Error("A turn is already running");
101
+ turnRunning = true;
102
+ const input = prepareTurnInput({ prompt, runner, memoryStore, currentProject });
103
+ runner.runtimeUiEvents.emit({ type: "web_user_message", text: input.userMessage });
104
+ try {
105
+ return await runner.runTurn(input.fullPrompt, input.userMessage, input.runOptions);
106
+ } finally {
107
+ turnRunning = false;
108
+ memoryStore.endTurn();
109
+ }
110
+ },
111
+ abort: () => runner.abort(),
112
+ async dispose() {
113
+ await runner.dispose?.();
114
+ memoryStore.close?.();
115
+ },
116
+ };
117
+ }
118
+
119
+ export function createHeadlessWebUi() {
120
+ return {
121
+ readline: () => Promise.resolve(null), write: () => {}, writeln: () => {},
122
+ thinkingStart: () => {}, thinkingDelta: () => {}, thinkingEnd: () => {},
123
+ thinkingBlock: () => {}, toggleLastThinking: () => false,
124
+ toolStart: () => {}, toolEnd: () => {}, textDelta: () => {},
125
+ assistantReplyEnd: () => {}, status: () => {}, recall: () => {},
126
+ clearOutput: () => {}, restoreTranscript: () => {}, setStatusBar: () => {},
127
+ turnStart: () => {}, turnEnd: () => {}, retryStart: () => {}, retryEnd: () => {},
128
+ editDiff: () => {}, requestPermission: async () => true,
129
+ setEscapeHandler: () => {}, setCtrlCHandler: () => {}, setShiftTabHandler: () => {},
130
+ setCtrlTHandler: () => {}, setCtrlLHandler: () => {}, setPasteImageHandler: () => {},
131
+ getInputText: () => "", insertTextAtCursor: () => {}, openExternalEditor: () => {},
132
+ toggleToolOutput: () => false, requestExit: () => {}, close: () => {},
133
+ };
134
+ }
135
+
136
+ export function createWebSnapshot({ cwd, runner, currentProject = basename(cwd) }) {
137
+ const model = runner.getCurrentModel?.();
138
+ return {
139
+ workspace: readWorkspaceTree(cwd),
140
+ timeline: { title: currentProject, meta: runtimeMeta(model), events: [] },
141
+ sessions: [{ id: "current", title: runner.engine?.sessionName ?? currentProject, time: "now", active: true }],
142
+ activity: [{ id: "runtime", action: "runner connected", time: "now" }],
143
+ composer: { mode: "Chat", placeholder: "Message March…" },
144
+ };
145
+ }
146
+
147
+ function runtimeMeta(model) {
148
+ return [model?.provider, model?.id].filter(Boolean).join(" · ") || "runner connected";
149
+ }
150
+
151
+ function readWorkspaceTree(rootPath) {
152
+ let count = 0;
153
+ const rootName = basename(rootPath) || rootPath;
154
+ return readNode(rootPath, rootName, 0, true);
155
+
156
+ function readNode(path, name, depth, selected = false) {
157
+ const stat = safeStat(path);
158
+ const kind = stat?.isDirectory() ? "folder" : "file";
159
+ const node = { id: path, name, kind, selected };
160
+ if (kind !== "folder" || depth >= MAX_WORKSPACE_DEPTH || count >= MAX_WORKSPACE_ENTRIES) return node;
161
+ const children = safeReadDir(path)
162
+ .filter((entry) => !entry.name.startsWith(".git") && entry.name !== "node_modules")
163
+ .sort(compareEntries)
164
+ .slice(0, 80)
165
+ .map((entry) => {
166
+ count += 1;
167
+ return readNode(join(path, entry.name), entry.name, depth + 1);
168
+ });
169
+ if (children.length > 0) node.children = children;
170
+ return node;
171
+ }
172
+ }
173
+
174
+ function safeStat(path) {
175
+ try { return statSync(path); } catch { return null; }
176
+ }
177
+
178
+ function safeReadDir(path) {
179
+ try { return readdirSync(path, { withFileTypes: true }); } catch { return []; }
180
+ }
181
+
182
+ function compareEntries(a, b) {
183
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
184
+ return a.name.localeCompare(b.name);
185
+ }
@@ -0,0 +1,139 @@
1
+ import { createReadStream, existsSync, statSync } from "node:fs";
2
+ import { createServer } from "node:http";
3
+ import { extname, join, normalize, sep } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const STATIC_ROOT = fileURLToPath(new URL("./dist/", import.meta.url));
7
+ const DEFAULT_PORT = 4174;
8
+
9
+ const rootPrefix = (root) => `${normalize(root).replace(/[\\/]$/, "")}${sep}`;
10
+
11
+ const mimeTypes = new Map([
12
+ [".html", "text/html; charset=utf-8"],
13
+ [".css", "text/css; charset=utf-8"],
14
+ [".js", "text/javascript; charset=utf-8"],
15
+ [".svg", "image/svg+xml"],
16
+ ]);
17
+
18
+ export function createWebUiServer({ root = STATIC_ROOT, runtime = null } = {}) {
19
+ return createServer(async (req, res) => {
20
+ if (runtime && req.url?.startsWith("/api/")) {
21
+ await handleApiRequest(req, res, runtime);
22
+ return;
23
+ }
24
+ serveStaticRequest(req, res, root);
25
+ });
26
+ }
27
+
28
+ export async function handleApiRequest(req, res, runtime) {
29
+ const { pathname } = new URL(req.url ?? "/", "http://localhost");
30
+ try {
31
+ if (req.method === "GET" && pathname === "/api/snapshot") return sendJson(res, runtime.snapshot(getSessionId(req)));
32
+ if (req.method === "GET" && pathname === "/api/events") return streamRuntimeEvents(req, res, runtime);
33
+ if (req.method === "GET" && pathname === "/api/sessions") return sendJson(res, { sessions: runtime.listSessions() });
34
+ if (req.method === "POST" && pathname === "/api/sessions") return sendJson(res, await createRuntimeSession(req, runtime));
35
+ if (req.method === "GET" && pathname === "/api/fs/roots") return sendJson(res, { roots: runtime.fsRoots() });
36
+ if (req.method === "GET" && pathname === "/api/fs/list") return sendJson(res, { entries: runtime.fsList(getPathParam(req)) });
37
+ if (req.method === "POST" && pathname === "/api/turn") return sendJson(res, await runRuntimeTurn(req, runtime));
38
+ if (req.method === "POST" && pathname === "/api/abort") return sendJson(res, { ok: true, result: runtime.abort(getSessionId(req)) });
39
+ sendJson(res, { error: "Not found" }, 404);
40
+ } catch (err) {
41
+ sendJson(res, { error: err?.message ?? String(err) }, 500);
42
+ }
43
+ }
44
+
45
+ export function resolveStaticPath(root, requestUrl) {
46
+ const url = new URL(requestUrl, "http://localhost");
47
+ const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
48
+ const decoded = decodeURIComponent(pathname);
49
+ const candidate = normalize(join(root, decoded));
50
+ return candidate.startsWith(rootPrefix(root)) ? candidate : null;
51
+ }
52
+
53
+ function serveStaticRequest(req, res, root) {
54
+ const filePath = resolveStaticPath(root, req.url ?? "/");
55
+ if (!filePath) {
56
+ res.writeHead(403);
57
+ res.end("Forbidden");
58
+ return;
59
+ }
60
+
61
+ if (!existsSync(filePath) || !statSync(filePath).isFile()) {
62
+ res.writeHead(404);
63
+ res.end("Not found");
64
+ return;
65
+ }
66
+
67
+ const type = mimeTypes.get(extname(filePath)) ?? "application/octet-stream";
68
+ res.writeHead(200, { "content-type": type });
69
+ createReadStream(filePath).pipe(res);
70
+ }
71
+
72
+ async function createRuntimeSession(req, runtime) {
73
+ const body = await readJsonBody(req);
74
+ const session = await runtime.createSession(body.workspacePath);
75
+ return { ok: true, session, snapshot: runtime.snapshot(session.id) };
76
+ }
77
+
78
+ async function runRuntimeTurn(req, runtime) {
79
+ const body = await readJsonBody(req);
80
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
81
+ if (!prompt) throw new Error("Missing prompt");
82
+ const result = await runtime.runTurn(body.sessionId, prompt);
83
+ return { ok: true, draft: result?.draft ?? "" };
84
+ }
85
+
86
+ function streamRuntimeEvents(req, res, runtime) {
87
+ res.writeHead(200, {
88
+ "content-type": "text/event-stream; charset=utf-8",
89
+ "cache-control": "no-cache, no-transform",
90
+ connection: "keep-alive",
91
+ });
92
+ writeSse(res, "ready", { ok: true });
93
+ const unsubscribe = runtime.subscribe(getSessionId(req), (event) => writeSse(res, "runtime", event));
94
+ res.on("close", unsubscribe);
95
+ }
96
+
97
+ function getSessionId(req) {
98
+ const url = new URL(req.url ?? "/", "http://localhost");
99
+ return url.searchParams.get("sessionId");
100
+ }
101
+
102
+ function getPathParam(req) {
103
+ const url = new URL(req.url ?? "/", "http://localhost");
104
+ const path = url.searchParams.get("path");
105
+ if (!path) throw new Error("Missing path");
106
+ return path;
107
+ }
108
+
109
+ function writeSse(res, event, data) {
110
+ res.write(`event: ${event}\n`);
111
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
112
+ }
113
+
114
+ function sendJson(res, data, status = 200) {
115
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
116
+ res.end(JSON.stringify(data));
117
+ }
118
+
119
+ function readJsonBody(req) {
120
+ return new Promise((resolve, reject) => {
121
+ let raw = "";
122
+ req.setEncoding("utf8");
123
+ req.on("data", (chunk) => {
124
+ raw += chunk;
125
+ if (raw.length > 1_000_000) reject(new Error("Request body too large"));
126
+ });
127
+ req.on("end", () => {
128
+ try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error("Invalid JSON body")); }
129
+ });
130
+ req.on("error", reject);
131
+ });
132
+ }
133
+
134
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
135
+ const port = Number.parseInt(process.env.MARCH_WEB_PORT ?? "", 10) || DEFAULT_PORT;
136
+ createWebUiServer().listen(port, "127.0.0.1", () => {
137
+ console.log(`March Web preview running at http://127.0.0.1:${port}`);
138
+ });
139
+ }
@@ -0,0 +1,109 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, resolve } from "node:path";
4
+ import { createWebRuntimeHost } from "./runtime-host.mjs";
5
+
6
+ export function createWebSessionManager({ args, config, launchCwd, stateRoot, useRuntimeProcess = true } = {}) {
7
+ const sessions = new Map();
8
+ const activities = [];
9
+ let activeSessionId = null;
10
+ let nextSessionNumber = 1;
11
+
12
+ async function createSession(workspacePath) {
13
+ const workspace = resolveWorkspace(workspacePath, launchCwd);
14
+ const id = `session-${Date.now().toString(36)}-${nextSessionNumber++}`;
15
+ const runtime = await createWebRuntimeHost({ args, config, cwd: workspace, stateRoot, useRuntimeProcess });
16
+ const session = { id, workspace, title: basename(workspace) || workspace, runtime, createdAt: Date.now() };
17
+ sessions.set(id, session);
18
+ activeSessionId = id;
19
+ activities.unshift({ id: `activity:${id}`, action: `opened ${session.title}`, time: "now" });
20
+ return toSessionSummary(session, true);
21
+ }
22
+
23
+ const listSessions = () => Array.from(sessions.values()).map((session) => toSessionSummary(session, session.id === activeSessionId));
24
+
25
+ return {
26
+ async createSession(workspacePath) { return createSession(workspacePath); },
27
+ listSessions,
28
+ snapshot(sessionId = activeSessionId) {
29
+ const session = getOptionalSession(sessions, sessionId);
30
+ if (!session) return createEmptySnapshot({ sessions, activeSessionId, activities });
31
+ const model = session.runtime.snapshot();
32
+ return { ...model, sessions: listSessions(), activity: activities, activeSessionId: session.id };
33
+ },
34
+ subscribe(sessionId, listener) { return getSession(sessions, sessionId).runtime.subscribe(listener); },
35
+ runTurn(sessionId, prompt) { return getSession(sessions, sessionId).runtime.runTurn(prompt); },
36
+ abort(sessionId) { return getSession(sessions, sessionId).runtime.abort?.(); },
37
+ fsRoots() { return listFsRoots(launchCwd); },
38
+ fsList(path) { return listFsDirectory(path); },
39
+ async dispose() {
40
+ await Promise.all(Array.from(sessions.values()).map((session) => session.runtime.dispose?.()));
41
+ sessions.clear();
42
+ },
43
+ };
44
+ }
45
+
46
+ export function resolveWorkspace(requested, launchCwd = process.cwd()) {
47
+ if (!requested || typeof requested !== "string") throw new Error("Missing workspace path");
48
+ const workspace = resolve(launchCwd, requested);
49
+ if (!isDirectory(workspace)) throw new Error(`Workspace does not exist or is not a directory: ${workspace}`);
50
+ return workspace;
51
+ }
52
+
53
+ function getSession(sessions, id) {
54
+ const session = getOptionalSession(sessions, id);
55
+ if (!session) throw new Error("Session not found");
56
+ return session;
57
+ }
58
+
59
+ function getOptionalSession(sessions, id) {
60
+ if (!id) return null;
61
+ return sessions.get(id) ?? null;
62
+ }
63
+
64
+ function toSessionSummary(session, active) {
65
+ return { id: session.id, title: session.title, workspacePath: session.workspace, time: "now", active };
66
+ }
67
+
68
+ function createEmptySnapshot({ sessions, activeSessionId, activities }) {
69
+ return {
70
+ workspace: { id: "no-workspace", name: "Choose workspace", kind: "folder", selected: true },
71
+ timeline: { title: "No session selected", meta: "Create a session and bind a workspace", events: [] },
72
+ sessions: Array.from(sessions.values()).map((session) => toSessionSummary(session, session.id === activeSessionId)),
73
+ activity: activities,
74
+ activeSessionId: null,
75
+ composer: { mode: "No session", placeholder: "Choose a workspace to start…" },
76
+ };
77
+ }
78
+
79
+ function listFsRoots(launchCwd) {
80
+ const roots = uniquePaths([launchCwd, homedir(), ...windowsDriveRoots()]);
81
+ return roots.filter(isDirectory).map((path) => ({ name: path, path, kind: "root" }));
82
+ }
83
+
84
+ function windowsDriveRoots() {
85
+ if (process.platform !== "win32") return ["/"];
86
+ return Array.from({ length: 26 }, (_, index) => `${String.fromCharCode(65 + index)}:\\`);
87
+ }
88
+
89
+ function listFsDirectory(path) {
90
+ const root = resolve(path);
91
+ if (!isDirectory(root)) throw new Error(`Directory does not exist: ${root}`);
92
+ return safeReadDir(root)
93
+ .filter((entry) => entry.isDirectory())
94
+ .sort((a, b) => a.name.localeCompare(b.name))
95
+ .slice(0, 500)
96
+ .map((entry) => ({ name: entry.name, path: resolve(root, entry.name), kind: "directory" }));
97
+ }
98
+
99
+ function uniquePaths(paths) {
100
+ return Array.from(new Set(paths.filter(Boolean).map((path) => resolve(path))));
101
+ }
102
+
103
+ function isDirectory(path) {
104
+ try { return statSync(path).isDirectory(); } catch { return false; }
105
+ }
106
+
107
+ function safeReadDir(path) {
108
+ try { return readdirSync(path, { withFileTypes: true }); } catch { return []; }
109
+ }
@@ -0,0 +1,7 @@
1
+ import { AppShell } from "./components/AppShell";
2
+ import { useWebRuntime } from "./runtime/useWebRuntime";
3
+
4
+ export function App() {
5
+ const runtime = useWebRuntime();
6
+ return <AppShell runtime={runtime} />;
7
+ }
@@ -0,0 +1,47 @@
1
+ import { useState } from "react";
2
+ import type { WebRuntimeState } from "../runtime/useWebRuntime";
3
+ import { Composer } from "./Composer";
4
+ import { FileExplorer } from "./FileExplorer";
5
+ import { RightSidebar } from "./RightSidebar";
6
+ import { SessionTimeline } from "./SessionTimeline";
7
+
8
+ export type AppShellProps = {
9
+ runtime: WebRuntimeState;
10
+ };
11
+
12
+ export function AppShell({ runtime }: AppShellProps) {
13
+ const [leftOpen, setLeftOpen] = useState(false);
14
+ const [rightOpen, setRightOpen] = useState(false);
15
+ const { model } = runtime;
16
+ const closePanels = () => {
17
+ setLeftOpen(false);
18
+ setRightOpen(false);
19
+ };
20
+
21
+ return (
22
+ <div className="app-shell" data-left-open={leftOpen} data-right-open={rightOpen}>
23
+ <div className="overlay" onClick={closePanels} />
24
+ <FileExplorer root={model.workspace} />
25
+ <SessionTimeline timeline={model.timeline} connected={runtime.connected} error={runtime.error} />
26
+ <RightSidebar
27
+ sessions={model.sessions}
28
+ activity={model.activity}
29
+ fsEntries={runtime.fsEntries}
30
+ fsPath={runtime.fsPath}
31
+ running={runtime.running}
32
+ onOpenSession={runtime.openSession}
33
+ onCreateSession={runtime.createSession}
34
+ onBrowseRoots={runtime.browseRoots}
35
+ onBrowsePath={runtime.browsePath}
36
+ />
37
+ <Composer
38
+ composer={model.composer}
39
+ running={runtime.running}
40
+ disabled={!model.activeSessionId}
41
+ onSubmit={runtime.submitPrompt}
42
+ onOpenLeft={() => setLeftOpen(true)}
43
+ onOpenRight={() => setRightOpen(true)}
44
+ />
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,47 @@
1
+ import { useState } from "react";
2
+ import type { FormEvent } from "react";
3
+ import type { ComposerState } from "../model";
4
+
5
+ export type ComposerProps = {
6
+ composer: ComposerState;
7
+ running: boolean;
8
+ disabled?: boolean;
9
+ onSubmit: (prompt: string) => Promise<void>;
10
+ onOpenLeft: () => void;
11
+ onOpenRight: () => void;
12
+ };
13
+
14
+ export function Composer({ composer, running, disabled = false, onSubmit, onOpenLeft, onOpenRight }: ComposerProps) {
15
+ const [prompt, setPrompt] = useState("");
16
+ const canSubmit = prompt.trim().length > 0 && !running && !disabled;
17
+
18
+ async function handleSubmit(event: FormEvent<HTMLFormElement>) {
19
+ event.preventDefault();
20
+ if (!canSubmit) return;
21
+ const nextPrompt = prompt.trim();
22
+ setPrompt("");
23
+ await onSubmit(nextPrompt);
24
+ }
25
+
26
+ return (
27
+ <form className="composer" aria-label="Message composer" onSubmit={handleSubmit}>
28
+ <button type="button" className="mobile-toggle" onClick={onOpenLeft} aria-label="Open files">▦</button>
29
+ <div className="composer-box">
30
+ <textarea
31
+ rows={1}
32
+ value={prompt}
33
+ onChange={(event) => setPrompt(event.target.value)}
34
+ placeholder={composer.placeholder}
35
+ disabled={disabled}
36
+ />
37
+ <div className="composer-actions">
38
+ <button type="button" className="session-ring" aria-label="Session" />
39
+ <button type="button" className="chip-button">{running ? "Running" : composer.mode}</button>
40
+ <button type="button" className="icon-action" aria-label="Attach">+</button>
41
+ <button type="submit" className="send-icon" aria-label="Send" disabled={!canSubmit}>↑</button>
42
+ </div>
43
+ </div>
44
+ <button type="button" className="mobile-toggle" onClick={onOpenRight} aria-label="Open sessions">☰</button>
45
+ </form>
46
+ );
47
+ }
@@ -0,0 +1,46 @@
1
+ import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react";
2
+ import type { FileTreeRowDecorationContext } from "@pierre/trees";
3
+ import { useMemo } from "react";
4
+ import { createProjectFileTreeInput } from "../fileTreeAdapter";
5
+ import type { FileNode } from "../model";
6
+
7
+ type FileExplorerProps = {
8
+ root: FileNode;
9
+ };
10
+
11
+ export function FileExplorer({ root }: FileExplorerProps) {
12
+ const treeInput = useMemo(() => createProjectFileTreeInput(root), [root]);
13
+ const { model } = useFileTree({
14
+ density: "compact",
15
+ flattenEmptyDirectories: false,
16
+ gitStatus: treeInput.gitStatus,
17
+ icons: { set: "minimal", colored: false },
18
+ id: "march-project-tree",
19
+ initialExpandedPaths: treeInput.expandedPaths,
20
+ initialSelectedPaths: treeInput.selectedPaths,
21
+ paths: treeInput.paths,
22
+ renderRowDecoration: (context: FileTreeRowDecorationContext) => {
23
+ if (!treeInput.boundPaths.has(context.item.path)) {
24
+ return null;
25
+ }
26
+ return { text: "◆", title: "Bound to session" };
27
+ },
28
+ search: true,
29
+ });
30
+
31
+ return (
32
+ <aside className="panel left-panel" aria-label="Projects">
33
+ <div className="projects-header">
34
+ <h3>Projects</h3>
35
+ <button className="menu-button" type="button" aria-label="Open project menu">
36
+ <span />
37
+ <span />
38
+ <span />
39
+ </button>
40
+ </div>
41
+ <div className="projects-body">
42
+ <PierreFileTree className="project-tree-host" model={model} aria-label="Project files" />
43
+ </div>
44
+ </aside>
45
+ );
46
+ }