march-cli 0.1.39 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/args.mjs +1 -0
- package/src/cli/input/keybindings.mjs +2 -0
- package/src/cli/repl-loop.mjs +4 -0
- package/src/cli/startup/app-runtime.mjs +7 -8
- package/src/cli/tui/output/timeline-block-restore.mjs +45 -0
- package/src/cli/tui/output-buffer.mjs +5 -0
- package/src/cli/tui/tui-input-controller.mjs +16 -0
- package/src/cli/ui.mjs +6 -2
- package/src/cli/workspace/command.mjs +44 -0
- package/src/cli/workspace/output-router.mjs +40 -33
- package/src/cli/workspace/tui-timeline-projection.mjs +179 -0
- package/src/cli/workspace/tui-timeline.mjs +247 -0
- package/src/config/config-json.mjs +20 -0
- package/src/provider/command.mjs +5 -1
- package/src/provider/remove-command.mjs +129 -0
- package/src/session/control/controller-lease.mjs +149 -0
- package/src/session/state/march-session-state.mjs +4 -14
- package/src/session/state/march-session-ui-state.mjs +48 -19
- package/src/workspace/session-restore.mjs +22 -0
- package/src/workspace/supervisor.mjs +150 -36
- package/src/workspace/view-runtime.mjs +40 -0
package/package.json
CHANGED
package/src/cli/args.mjs
CHANGED
|
@@ -69,6 +69,7 @@ Usage:
|
|
|
69
69
|
march [options] (starts REPL)
|
|
70
70
|
march login [provider] Login to an OAuth provider
|
|
71
71
|
march provider --config Configure provider credentials
|
|
72
|
+
march provider remove Remove a configured provider interactively
|
|
72
73
|
march provider share [id] Share a provider profile
|
|
73
74
|
march provider accept <token>
|
|
74
75
|
march web [path] Start the local Web UI session manager
|
|
@@ -9,6 +9,7 @@ export const DEFAULT_KEYBINDINGS = Object.freeze({
|
|
|
9
9
|
thinkingSelector: "Ctrl+T",
|
|
10
10
|
modelSelector: "Ctrl+L",
|
|
11
11
|
externalEditor: "Ctrl+G",
|
|
12
|
+
clearInput: "Ctrl+U",
|
|
12
13
|
toggleToolOutput: "Ctrl+O",
|
|
13
14
|
toggleShellDrawer: "Alt+S",
|
|
14
15
|
nextShell: "Alt+N",
|
|
@@ -27,6 +28,7 @@ export const KEYBINDING_ACTIONS = Object.freeze({
|
|
|
27
28
|
thinkingSelector: "Open thinking selector",
|
|
28
29
|
modelSelector: "Open model selector",
|
|
29
30
|
externalEditor: "Open external editor ($VISUAL or $EDITOR)",
|
|
31
|
+
clearInput: "Clear current input draft",
|
|
30
32
|
toggleToolOutput: "Toggle tool output collapsed/expanded",
|
|
31
33
|
toggleShellDrawer: "Toggle right-side shell pane",
|
|
32
34
|
nextShell: "Select next shell in pane",
|
package/src/cli/repl-loop.mjs
CHANGED
|
@@ -104,6 +104,10 @@ export async function runInteractiveRepl({
|
|
|
104
104
|
trimmed = templateResult.prompt;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
if (turnActive.viewOnly) {
|
|
108
|
+
ui.writeln("This session is view-only. Use /session and Take over control before sending prompts.");
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
107
111
|
if (turnActive.turnTask) {
|
|
108
112
|
ui.writeln("This session is still running. Use /session to start or inspect another session.");
|
|
109
113
|
continue;
|
|
@@ -94,7 +94,7 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
|
|
|
94
94
|
ui,
|
|
95
95
|
activeProjectId: currentProjectInfo.projectId,
|
|
96
96
|
activeSessionId: sessionState.sessionId,
|
|
97
|
-
|
|
97
|
+
onPersistRenderTimeline: persistRenderTimeline,
|
|
98
98
|
});
|
|
99
99
|
const runtimeUi = outputRouter.createProjectUi(currentProjectInfo.projectId, () => sessionState.sessionId);
|
|
100
100
|
let turnRunning = false;
|
|
@@ -176,7 +176,10 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
|
|
|
176
176
|
onNotificationActivation,
|
|
177
177
|
});
|
|
178
178
|
},
|
|
179
|
-
onActivate: ({ projectId, sessionId }) =>
|
|
179
|
+
onActivate: ({ projectId, sessionId, runtime }) => {
|
|
180
|
+
if (runtime?.projectMarchDir) projectMarchDirs.set(projectId, runtime.projectMarchDir);
|
|
181
|
+
outputRouter.setActiveSession(projectId, sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDirs.get(projectId), sessionId) });
|
|
182
|
+
},
|
|
180
183
|
});
|
|
181
184
|
runner = workspaceSupervisor.runner;
|
|
182
185
|
|
|
@@ -213,8 +216,8 @@ export async function createCliAppRuntime({ args, config, cwd, argv, stateRoot }
|
|
|
213
216
|
outputRouter.setActiveSession(currentProjectInfo.projectId, sessionState.sessionId, { renderTimeline: loadStoredRenderTimeline(projectMarchDir, sessionState.sessionId) });
|
|
214
217
|
refreshStatusBar();
|
|
215
218
|
|
|
216
|
-
function persistRenderTimeline({ projectId, sessionId, events
|
|
217
|
-
if (!sessionId
|
|
219
|
+
function persistRenderTimeline({ projectId, sessionId, events }) {
|
|
220
|
+
if (!sessionId) return;
|
|
218
221
|
const routeProjectMarchDir = projectMarchDirs.get(projectId);
|
|
219
222
|
if (!routeProjectMarchDir) return;
|
|
220
223
|
try {
|
|
@@ -259,10 +262,6 @@ function loadStoredRenderTimeline(projectMarchDir, sessionId) {
|
|
|
259
262
|
}
|
|
260
263
|
}
|
|
261
264
|
|
|
262
|
-
function shouldPersistRenderEvent(method) {
|
|
263
|
-
return method === "turnEnd" || method === "assistantReplyEnd" || method === "toolEnd" || method === "writeln" || method === "clearOutput";
|
|
264
|
-
}
|
|
265
|
-
|
|
266
265
|
async function handleNotificationActivation({ activation, stateRoot, workspaceSupervisor, ui }) {
|
|
267
266
|
if (activation?.type !== "workspace-session" || !activation.projectId) return;
|
|
268
267
|
if (!workspaceSupervisor) throw new Error("workspace supervisor is not ready");
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createToolCardBlock, writeToolEnd } from "../tool-rendering.mjs";
|
|
2
|
+
import { formatRecallLines } from "../recall-rendering.mjs";
|
|
3
|
+
|
|
4
|
+
export function restoreTimelineBlocksToOutputBuffer(output, blocks) {
|
|
5
|
+
output.clear();
|
|
6
|
+
for (const block of Array.isArray(blocks) ? blocks : []) appendTimelineBlock(output, block);
|
|
7
|
+
output.invalidate?.();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function appendTimelineBlock(output, block) {
|
|
11
|
+
if (!block || typeof block !== "object") return;
|
|
12
|
+
switch (block.type) {
|
|
13
|
+
case "assistant":
|
|
14
|
+
if (block.content) output.addBlock({ type: "markdown", text: String(block.content), sealed: Boolean(block.closed), cache: new Map() });
|
|
15
|
+
break;
|
|
16
|
+
case "thinking":
|
|
17
|
+
output.addBlock({ type: "thinking", tokens: block.tokens ?? 0, content: splitLines(block.content) });
|
|
18
|
+
break;
|
|
19
|
+
case "tool": {
|
|
20
|
+
const card = createToolCardBlock({ name: block.name, args: block.args });
|
|
21
|
+
output.addBlock(card);
|
|
22
|
+
if (block.closed) writeToolEnd({ output, name: block.name, isError: block.isError, result: block.result, toolBlock: card });
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
case "output":
|
|
26
|
+
if (block.newline) output.writeln(String(block.content ?? ""));
|
|
27
|
+
else output.write(String(block.content ?? ""));
|
|
28
|
+
break;
|
|
29
|
+
case "status":
|
|
30
|
+
output.addBlock({ type: "status", lines: [String(block.content ?? "")] });
|
|
31
|
+
break;
|
|
32
|
+
case "recall":
|
|
33
|
+
output.addBlock({ type: "plain", lines: formatRecallLines(block.hints ?? []) });
|
|
34
|
+
break;
|
|
35
|
+
case "editDiff":
|
|
36
|
+
output.addBlock({ type: "diff", path: block.path, diffLines: block.diffLines ?? [] });
|
|
37
|
+
break;
|
|
38
|
+
default:
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function splitLines(value) {
|
|
44
|
+
return String(value ?? "").split("\n");
|
|
45
|
+
}
|
|
@@ -2,6 +2,7 @@ import { brightBlack, dim } from "./ui-theme.mjs";
|
|
|
2
2
|
import { renderToolCardBlock } from "./output/tool-card-renderer.mjs";
|
|
3
3
|
import { renderMarkdown, renderStreamingMarkdown } from "./markdown-renderer.mjs";
|
|
4
4
|
import { renderEditDiffBlock } from "./tui-diff-rendering.mjs";
|
|
5
|
+
import { restoreTimelineBlocksToOutputBuffer } from "./output/timeline-block-restore.mjs";
|
|
5
6
|
import { OutputScrollState } from "./output/scroll-state.mjs";
|
|
6
7
|
import { appendTextLines, wrapLine } from "./output/text-line-renderer.mjs";
|
|
7
8
|
import { appendSelectableEntries, copySourceTextForRange, sliceEntriesWithTail } from "./output/selectable-copy.mjs";
|
|
@@ -89,6 +90,10 @@ export class OutputBuffer {
|
|
|
89
90
|
this._baseEntriesCache = new Map();
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
restoreTimelineBlocks(blocks) {
|
|
94
|
+
restoreTimelineBlocksToOutputBuffer(this, blocks);
|
|
95
|
+
}
|
|
96
|
+
|
|
92
97
|
write(text) { this._writeText(text, false); }
|
|
93
98
|
writeMarkdown(text) { this._writeText(text, true); }
|
|
94
99
|
|
|
@@ -40,6 +40,14 @@ export function createTuiInputController({ editor, requestRender, historyStore =
|
|
|
40
40
|
requestRender();
|
|
41
41
|
},
|
|
42
42
|
|
|
43
|
+
clearInput() {
|
|
44
|
+
attachmentTokens.clear();
|
|
45
|
+
editor.lastAction = null;
|
|
46
|
+
editor.historyIndex = -1;
|
|
47
|
+
setEditorText("");
|
|
48
|
+
requestRender();
|
|
49
|
+
},
|
|
50
|
+
|
|
43
51
|
insertAttachmentAtCursor({ marker, label }) {
|
|
44
52
|
const token = uniqueAttachmentToken(label || "[image]", attachmentTokens);
|
|
45
53
|
attachmentTokens.set(token, marker);
|
|
@@ -59,4 +67,12 @@ export function createTuiInputController({ editor, requestRender, historyStore =
|
|
|
59
67
|
historyStore?.save?.(editor.history);
|
|
60
68
|
} catch {}
|
|
61
69
|
}
|
|
70
|
+
|
|
71
|
+
function setEditorText(text) {
|
|
72
|
+
if (typeof editor.setTextInternal === "function") {
|
|
73
|
+
editor.setTextInternal(text);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
editor.setText?.(text);
|
|
77
|
+
}
|
|
62
78
|
}
|
package/src/cli/ui.mjs
CHANGED
|
@@ -87,6 +87,7 @@ export function createTuiUI({
|
|
|
87
87
|
thinkingSelector: () => onCtrlTHandler?.(),
|
|
88
88
|
modelSelector: () => onCtrlLHandler?.(),
|
|
89
89
|
externalEditor: () => openExternalEditor(),
|
|
90
|
+
clearInput: () => inputController.clearInput(),
|
|
90
91
|
toggleToolOutput: () => toggleToolOutput(),
|
|
91
92
|
toggleShellDrawer: () => shellDrawerControls.toggle(),
|
|
92
93
|
nextShell: () => shellDrawerControls.selectNext(),
|
|
@@ -216,10 +217,13 @@ export function createTuiUI({
|
|
|
216
217
|
},
|
|
217
218
|
|
|
218
219
|
clearOutput: () => {
|
|
219
|
-
ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); requestRender();
|
|
220
|
+
ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); activeToolBlocks.length = 0; output.clear(); requestRender();
|
|
221
|
+
},
|
|
222
|
+
restoreTimelineBlocks: (blocks) => {
|
|
223
|
+
ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); activeToolBlocks.length = 0; output.restoreTimelineBlocks(blocks); requestRender();
|
|
220
224
|
},
|
|
221
225
|
restoreTranscript: (turns) => {
|
|
222
|
-
ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); output.clear(); writeTranscriptToOutput(output, turns); requestRender();
|
|
226
|
+
ensureStarted(); flushStreamDeltas(); spinnerStatus.stop(); retryStatus.stop(); activeToolBlocks.length = 0; output.clear(); writeTranscriptToOutput(output, turns); requestRender();
|
|
223
227
|
},
|
|
224
228
|
|
|
225
229
|
setStatusBar: (text) => {
|
|
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { brightBlack } from "../tui/ui-theme.mjs";
|
|
4
4
|
import { registerProject, listRegisteredProjects } from "../../workspace/project-registry.mjs";
|
|
5
5
|
import { buildWorkspaceSessionSelectItems, listWorkspaceSessions, workspaceSessionSearchText } from "../../workspace/session-index.mjs";
|
|
6
|
+
import { SessionControllerLeaseConflictError } from "../../session/control/controller-lease.mjs";
|
|
6
7
|
|
|
7
8
|
export const WORKSPACE_SLASH_COMMANDS = [
|
|
8
9
|
{
|
|
@@ -93,6 +94,9 @@ export async function handleSessionCommand({ stateRoot, currentProjectId, runner
|
|
|
93
94
|
ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
|
|
94
95
|
return { handled: true, refreshContextTokens: true, activeChanged: true };
|
|
95
96
|
} catch (err) {
|
|
97
|
+
if (err instanceof SessionControllerLeaseConflictError) {
|
|
98
|
+
return await handleSessionControllerConflict({ err, item, workspaceSupervisor, workspaceOutputRouter, ui });
|
|
99
|
+
}
|
|
96
100
|
ui.writeln(`Error: ${err.message}`);
|
|
97
101
|
return { handled: true };
|
|
98
102
|
}
|
|
@@ -101,6 +105,46 @@ export async function handleSessionCommand({ stateRoot, currentProjectId, runner
|
|
|
101
105
|
return { handled: true };
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
async function handleSessionControllerConflict({ err, item, workspaceSupervisor, workspaceOutputRouter, ui }) {
|
|
109
|
+
ui.writeln(err.message);
|
|
110
|
+
if (!ui.selectList) return { handled: true };
|
|
111
|
+
const choice = await ui.selectList({
|
|
112
|
+
items: [
|
|
113
|
+
{ value: "view-only", label: "View only", description: "inspect this session without taking control" },
|
|
114
|
+
{ value: "take-over", label: "Take over control", description: "steal the controller lease for this session" },
|
|
115
|
+
{ value: "cancel", label: "Cancel", description: "leave current controller unchanged" },
|
|
116
|
+
],
|
|
117
|
+
selectedIndex: 0,
|
|
118
|
+
width: 70,
|
|
119
|
+
suppressInitialConfirm: true,
|
|
120
|
+
});
|
|
121
|
+
if (choice?.value === "view-only") {
|
|
122
|
+
try {
|
|
123
|
+
await workspaceSupervisor.viewWorkspaceSession({ project: item.project, session: item.session });
|
|
124
|
+
ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
|
|
125
|
+
ui.writeln(`Viewing session: ${item.project.displayName} / ${item.session.name || item.session.id}`);
|
|
126
|
+
ui.writeln("View-only mode: prompts are disabled until you take over control.");
|
|
127
|
+
return { handled: true, refreshContextTokens: true, activeChanged: true };
|
|
128
|
+
} catch (viewErr) {
|
|
129
|
+
ui.writeln(`Error: ${viewErr.message}`);
|
|
130
|
+
return { handled: true };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (choice?.value !== "take-over") {
|
|
134
|
+
ui.writeln("Session unchanged.");
|
|
135
|
+
return { handled: true };
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
await workspaceSupervisor.activateWorkspaceSession({ project: item.project, session: item.session, force: true });
|
|
139
|
+
ctxRenderActiveSession({ workspaceOutputRouter, projectId: item.project.projectId, sessionId: item.session.id });
|
|
140
|
+
ui.writeln(`Took over session: ${item.project.displayName} / ${item.session.name || item.session.id}`);
|
|
141
|
+
return { handled: true, refreshContextTokens: true, activeChanged: true };
|
|
142
|
+
} catch (takeoverErr) {
|
|
143
|
+
ui.writeln(`Error: ${takeoverErr.message}`);
|
|
144
|
+
return { handled: true };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
104
148
|
function annotateWorkspaceItems(items, runtimeSummaries) {
|
|
105
149
|
if (!runtimeSummaries.length) return items;
|
|
106
150
|
const running = new Set(runtimeSummaries.filter((runtime) => runtime.running).map((runtime) => `${runtime.projectId}:${runtime.sessionId}`));
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { createTuiTimelineRegistry } from "./tui-timeline.mjs";
|
|
2
|
+
|
|
3
|
+
const PERSIST_FLUSH_METHODS = new Set(["turnEnd", "assistantReplyEnd", "toolEnd"]);
|
|
4
|
+
|
|
1
5
|
const RENDER_METHODS = new Set([
|
|
2
6
|
"turnStart",
|
|
3
7
|
"turnEnd",
|
|
@@ -19,11 +23,18 @@ const RENDER_METHODS = new Set([
|
|
|
19
23
|
"clearOutput",
|
|
20
24
|
]);
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
93
|
+
const key = routeKey(projectId, sessionId);
|
|
94
|
+
timelineRegistry.replaceEvents(key, events);
|
|
79
95
|
},
|
|
80
96
|
getRenderEventCount(projectId, sessionId = null) {
|
|
81
|
-
return
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
100
|
-
onRenderTimelineChange?.({ ...parseRouteKey(key), events: [], event: { method, args } });
|
|
118
|
+
timelineRegistry.clear(key);
|
|
101
119
|
return;
|
|
102
120
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
if (
|
|
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
|
+
}
|