@multiclawcli/dashboard 0.9.0 → 0.10.0
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/dist/tui/App.d.ts +17 -1
- package/dist/tui/atomic.d.ts +10 -0
- package/dist/tui/components/overlays.d.ts +19 -0
- package/dist/tui/format.d.ts +8 -0
- package/dist/tui/history.d.ts +17 -16
- package/dist/tui/index.d.ts +4 -1
- package/dist/tui/session.d.ts +30 -33
- package/dist/tui/sessions.d.ts +48 -0
- package/dist/tui/state.d.ts +21 -0
- package/dist/tui.js +447 -77
- package/package.json +2 -2
package/dist/tui/App.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { EventBus, MultiClawConfig } from "@multiclawcli/core";
|
|
2
2
|
import { AgentSessionManager } from "@multiclawcli/core";
|
|
3
|
+
/**
|
|
4
|
+
* How the console should choose its session on launch.
|
|
5
|
+
*
|
|
6
|
+
* Absent means the default: continue the most recent session, or start one when
|
|
7
|
+
* the workspace has none. `new` always starts fresh. `resume` opens the named
|
|
8
|
+
* session, or — with no id — the most recent one with the picker already open,
|
|
9
|
+
* so `multiclaw -r` lands on the list the way `claude -r` does.
|
|
10
|
+
*/
|
|
11
|
+
export type InitialSession = {
|
|
12
|
+
kind: "new";
|
|
13
|
+
} | {
|
|
14
|
+
kind: "resume";
|
|
15
|
+
id?: string;
|
|
16
|
+
};
|
|
3
17
|
export interface AppProps {
|
|
4
18
|
config: MultiClawConfig;
|
|
5
19
|
/**
|
|
@@ -11,5 +25,7 @@ export interface AppProps {
|
|
|
11
25
|
sessions?: AgentSessionManager;
|
|
12
26
|
/** Gives the terminal host an awaitable cleanup barrier, including render failures. */
|
|
13
27
|
onCleanupReady?: (cleanup: () => Promise<void>) => void;
|
|
28
|
+
/** How to choose the session on launch; defaults to continuing the latest. */
|
|
29
|
+
initialSession?: InitialSession;
|
|
14
30
|
}
|
|
15
|
-
export declare function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }: AppProps): import("react").JSX.Element;
|
|
31
|
+
export declare function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady, initialSession, }: AppProps): import("react").JSX.Element;
|
package/dist/tui/atomic.d.ts
CHANGED
|
@@ -19,3 +19,13 @@ export declare function writeJsonFile(target: string, json: string): Promise<voi
|
|
|
19
19
|
* the temporary file behind and the previous — older — file in place.
|
|
20
20
|
*/
|
|
21
21
|
export declare function pendingWrite(target: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Append newline-terminated lines to `target`, serialized per path on the same
|
|
24
|
+
* chain as {@link writeJsonFile}.
|
|
25
|
+
*
|
|
26
|
+
* Unlike the rewrite path this does not go through a temp file: an append is the
|
|
27
|
+
* cheap O(1) write a JSONL log wants, and a crash mid-append can only tear the
|
|
28
|
+
* *last* line, which every line-oriented reader here already skips. Sharing the
|
|
29
|
+
* `writes` chain keeps a `pendingWrite` on the same path able to await it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function appendLines(target: string, lines: readonly string[]): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionMeta } from "../sessions";
|
|
1
2
|
import { type TurnView } from "../state";
|
|
2
3
|
interface HelpGroup {
|
|
3
4
|
title: string;
|
|
@@ -61,4 +62,22 @@ export declare function HistoryOverlay({ turns, selected, width, maxRows, }: {
|
|
|
61
62
|
/** Rows available in the body. The list is windowed against this. */
|
|
62
63
|
maxRows: number;
|
|
63
64
|
}): import("react").JSX.Element;
|
|
65
|
+
/**
|
|
66
|
+
* The session picker: every session in the workspace, newest activity first.
|
|
67
|
+
*
|
|
68
|
+
* The level above {@link HistoryOverlay}. Where the history list browses the
|
|
69
|
+
* turns *within* a session, this switches between whole sessions — the thing a
|
|
70
|
+
* restart, a `/new`, or opening the console again all create. The one the
|
|
71
|
+
* console is attached to is marked, because "which am I on" is the first
|
|
72
|
+
* question the list has to answer.
|
|
73
|
+
*/
|
|
74
|
+
export declare function SessionsOverlay({ sessions, selected, currentId, width, maxRows, now, }: {
|
|
75
|
+
sessions: SessionMeta[];
|
|
76
|
+
selected: number;
|
|
77
|
+
currentId: string | undefined;
|
|
78
|
+
width: number;
|
|
79
|
+
/** Rows available in the body. The list is windowed against this. */
|
|
80
|
+
maxRows: number;
|
|
81
|
+
now: number;
|
|
82
|
+
}): import("react").JSX.Element;
|
|
64
83
|
export {};
|
package/dist/tui/format.d.ts
CHANGED
|
@@ -43,6 +43,14 @@ export declare function wrapLine(line: string, width: number): string[];
|
|
|
43
43
|
* scan a stack of durations only if they share a shape.
|
|
44
44
|
*/
|
|
45
45
|
export declare function formatDuration(ms: number): string;
|
|
46
|
+
/**
|
|
47
|
+
* A coarse "how long ago", for the session list.
|
|
48
|
+
*
|
|
49
|
+
* Deliberately low-resolution — the picker wants "which one did I touch last",
|
|
50
|
+
* not a precise clock. Future timestamps (a clock that moved backwards) read as
|
|
51
|
+
* "just now" rather than a negative age.
|
|
52
|
+
*/
|
|
53
|
+
export declare function formatAgo(timestamp: number, now: number): string;
|
|
46
54
|
/** Stage markers read as an ordered sequence at a glance; past twenty, fall back. */
|
|
47
55
|
export declare function circled(n: number): string;
|
|
48
56
|
/** Pad to a fixed column so sibling rows line up. */
|
package/dist/tui/history.d.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { type TurnView } from "./state";
|
|
2
|
-
/**
|
|
3
|
-
export declare const
|
|
4
|
-
/** The finished-turn record the console keeps across sessions. */
|
|
5
|
-
export declare const HISTORY_FILE = "history.json";
|
|
2
|
+
/** The finished-turn log a session keeps, one JSON turn per line. */
|
|
3
|
+
export declare const TURNS_FILE = "turns.jsonl";
|
|
6
4
|
/** Where exported turn markdown files are written by default. */
|
|
7
5
|
export declare const EXPORT_DIR = "exports";
|
|
8
|
-
/** Where
|
|
9
|
-
export declare function
|
|
6
|
+
/** Where a session keeps its finished turns. */
|
|
7
|
+
export declare function turnsPath(workDir: string, sessionId: string): string;
|
|
10
8
|
/**
|
|
11
|
-
* Read
|
|
9
|
+
* Read a session's turn history.
|
|
12
10
|
*
|
|
13
11
|
* Synchronous on purpose: it runs once, inside the reducer's lazy initialiser,
|
|
14
12
|
* before the first frame. An async load would race the user's first keystroke
|
|
@@ -16,18 +14,21 @@ export declare function historyPath(workDir: string): string;
|
|
|
16
14
|
*
|
|
17
15
|
* Never throws. A missing directory, malformed JSON, or a value of the wrong
|
|
18
16
|
* shape all mean "no history": the console can always start from empty, and
|
|
19
|
-
* crashing on startup because a side file got corrupted would be far worse.
|
|
17
|
+
* crashing on startup because a side file got corrupted would be far worse. The
|
|
18
|
+
* log is line-oriented, so a single torn line (a crash mid-append) is skipped
|
|
19
|
+
* rather than costing the whole session's history.
|
|
20
20
|
*/
|
|
21
|
-
export declare function
|
|
21
|
+
export declare function loadTurns(workDir: string, sessionId: string): TurnView[];
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
23
|
+
* Append finished turns to a session's log, one JSON object per line.
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* Append-only and serialized per path (see {@link appendLines}): each settled
|
|
26
|
+
* turn is an O(1) write rather than a rewrite of the whole history, and a crash
|
|
27
|
+
* mid-append can only tear the last line, which {@link loadTurns} skips. Callers
|
|
28
|
+
* pass only the turns not yet persisted, tracked by the highest turn number
|
|
29
|
+
* already written.
|
|
29
30
|
*/
|
|
30
|
-
export declare function
|
|
31
|
-
export declare function exportPath(workDir: string, turn: number): string;
|
|
31
|
+
export declare function appendTurns(workDir: string, sessionId: string, turns: readonly TurnView[]): Promise<void>;
|
|
32
|
+
export declare function exportPath(workDir: string, sessionId: string, turn: number): string;
|
|
32
33
|
export declare function exportTurn(turn: TurnView, filePath: string): Promise<void>;
|
|
33
34
|
export declare function formatTurnMarkdown(turn: TurnView): string;
|
package/dist/tui/index.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type EventBus, type MultiClawConfig } from "@multiclawcli/core";
|
|
2
|
-
|
|
2
|
+
import { type InitialSession } from "./App";
|
|
3
|
+
export type { AppProps, InitialSession } from "./App";
|
|
3
4
|
export * from "./layout";
|
|
4
5
|
export * from "./state";
|
|
5
6
|
export interface TuiOptions {
|
|
6
7
|
config: MultiClawConfig;
|
|
7
8
|
/** Attach per-turn reporters (e.g. a file log writer) here. */
|
|
8
9
|
onTurnStart?: (eventBus: EventBus, logDir: string) => void | (() => void | Promise<void>);
|
|
10
|
+
/** How to choose the session on launch; defaults to continuing the latest. */
|
|
11
|
+
initialSession?: InitialSession;
|
|
9
12
|
}
|
|
10
13
|
/**
|
|
11
14
|
* Render the interactive team console.
|
package/dist/tui/session.d.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { type AgentView, type TuiStageInfo, type TuiState, type TurnView } from
|
|
|
5
5
|
* Live agent sessions are child processes, and a pending approval is an RPC in
|
|
6
6
|
* flight to one of them — neither survives the process that owns them. So this
|
|
7
7
|
* file deliberately does not try to *continue* a turn. It records the boundary
|
|
8
|
-
* instead: which turn was running, what the user had typed but not run,
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* instead: which turn was running, what the user had typed but not run, and how
|
|
9
|
+
* many decisions were waiting. A restart can then say what happened, show the
|
|
10
|
+
* interrupted turn as a turn, and hand the queued input back — without
|
|
11
|
+
* pretending a dead agent is still working.
|
|
12
12
|
*/
|
|
13
13
|
export declare const SESSION_FILE = "session.json";
|
|
14
14
|
/** Bumped when the on-disk shape changes in a way an older console cannot read. */
|
|
@@ -48,29 +48,21 @@ export interface JournalTurn {
|
|
|
48
48
|
}
|
|
49
49
|
export interface SessionJournal {
|
|
50
50
|
version: number;
|
|
51
|
-
/**
|
|
52
|
-
* Turn number the current task began at.
|
|
53
|
-
*
|
|
54
|
-
* `/task` closes the previous task's context, and that boundary has to outlive
|
|
55
|
-
* the process: without it a restart would hand the leader every turn ever run
|
|
56
|
-
* in the workspace, including the ones the user explicitly closed.
|
|
57
|
-
*/
|
|
58
|
-
taskStart?: number;
|
|
59
51
|
/** Follow-ups typed but never run, in the order they were typed. */
|
|
60
52
|
queue?: string[];
|
|
61
53
|
inFlight?: JournalTurn;
|
|
62
54
|
}
|
|
63
|
-
/** Where
|
|
64
|
-
export declare function sessionPath(workDir: string): string;
|
|
55
|
+
/** Where a session keeps its journal. */
|
|
56
|
+
export declare function sessionPath(workDir: string, sessionId: string): string;
|
|
65
57
|
/**
|
|
66
|
-
* Read
|
|
58
|
+
* Read a session's journal.
|
|
67
59
|
*
|
|
68
|
-
* Never throws, for the same reason `
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
60
|
+
* Never throws, for the same reason `loadTurns` does not: a corrupted side file
|
|
61
|
+
* means "no journal", and a console that refuses to start over it would be far
|
|
62
|
+
* worse than one that starts with an empty past. A journal written by a newer
|
|
63
|
+
* console (unknown `version`) is ignored rather than guessed at.
|
|
72
64
|
*/
|
|
73
|
-
export declare function loadJournal(workDir: string): SessionJournal | undefined;
|
|
65
|
+
export declare function loadJournal(workDir: string, sessionId: string): SessionJournal | undefined;
|
|
74
66
|
/**
|
|
75
67
|
* Write the workspace's session journal.
|
|
76
68
|
*
|
|
@@ -78,12 +70,12 @@ export declare function loadJournal(workDir: string): SessionJournal | undefined
|
|
|
78
70
|
* to survive a process that dies mid-write, so the write itself must never be
|
|
79
71
|
* the thing that leaves a torn file behind.
|
|
80
72
|
*
|
|
81
|
-
* An empty journal does not create the file. A
|
|
82
|
-
* without doing anything should not leave
|
|
83
|
-
*
|
|
84
|
-
*
|
|
73
|
+
* An empty journal does not create the file. A session that was opened and quit
|
|
74
|
+
* without doing anything should not leave a journal behind — but an *existing*
|
|
75
|
+
* journal is still rewritten when it empties, because "nothing is in flight" is
|
|
76
|
+
* exactly what the next start needs to read.
|
|
85
77
|
*/
|
|
86
|
-
export declare function saveJournal(workDir: string, journal: SessionJournal): Promise<void>;
|
|
78
|
+
export declare function saveJournal(workDir: string, sessionId: string, journal: SessionJournal): Promise<void>;
|
|
87
79
|
/**
|
|
88
80
|
* The journal entry for a turn that is running right now.
|
|
89
81
|
*
|
|
@@ -115,20 +107,25 @@ export declare function interruptedTurn(entry: JournalTurn): TurnView;
|
|
|
115
107
|
export interface RestoredConsole {
|
|
116
108
|
history: TurnView[];
|
|
117
109
|
queue: string[];
|
|
118
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Highest turn number already on disk in `turns.jsonl` — the watermark the
|
|
112
|
+
* console appends past. Excludes a journal-recovered interrupted turn, which
|
|
113
|
+
* lives only in the journal until the next save writes it to the log.
|
|
114
|
+
*/
|
|
115
|
+
persistedTurn: number;
|
|
119
116
|
/** The last turn ended by asking the user something, so the console reopens ready for it. */
|
|
120
117
|
awaitingAnswer: boolean;
|
|
121
118
|
/** One line for the notice bar, when the previous session left work behind. */
|
|
122
119
|
notice?: string;
|
|
123
120
|
}
|
|
124
121
|
/**
|
|
125
|
-
* Everything the console should open with, read once at startup.
|
|
122
|
+
* Everything the console should open a session with, read once at startup.
|
|
126
123
|
*
|
|
127
124
|
* The interrupted turn is appended to the restored history rather than replacing
|
|
128
|
-
* it: the turns before it still happened. It is skipped when
|
|
129
|
-
* holds that turn number, because a restored turn is written
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
125
|
+
* it: the turns before it still happened. It is skipped when the log already
|
|
126
|
+
* holds that turn number, because a restored turn is written to the log on the
|
|
127
|
+
* very next save — a second restart must not add it twice. A journal *behind*
|
|
128
|
+
* the last recorded turn is stale (the log was written by a later session) and
|
|
129
|
+
* is dropped for the same reason.
|
|
133
130
|
*/
|
|
134
|
-
export declare function restoreConsole(workDir: string): RestoredConsole;
|
|
131
|
+
export declare function restoreConsole(workDir: string, sessionId: string): RestoredConsole;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions: the unit of history and resumption.
|
|
3
|
+
*
|
|
4
|
+
* A session is one continuous line of work — a sequence of turns, each fanning
|
|
5
|
+
* out to the same team of agents. It is what Claude Code / Codex call a session:
|
|
6
|
+
* an independent, listable, resumable, deletable record. Everything a session
|
|
7
|
+
* owns lives under its own directory (`turns.jsonl`, `session.json`, exports),
|
|
8
|
+
* and a small `index.json` holds just the summaries the picker needs so it never
|
|
9
|
+
* has to open a session to list one.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SESSIONS_DIR = "sessions";
|
|
12
|
+
/** The summary index, read by the picker; one entry per session. */
|
|
13
|
+
export declare const INDEX_FILE = "index.json";
|
|
14
|
+
/** How long a session title may grow before it is truncated for the index. */
|
|
15
|
+
export declare const MAX_TITLE_CHARS = 80;
|
|
16
|
+
export interface SessionMeta {
|
|
17
|
+
id: string;
|
|
18
|
+
/** First turn's requirement, truncated. Empty until the session runs a turn. */
|
|
19
|
+
title: string;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
lastActiveAt: number;
|
|
22
|
+
/** Highest turn number the session has recorded. */
|
|
23
|
+
turnCount: number;
|
|
24
|
+
}
|
|
25
|
+
export declare function sessionsRoot(workDir: string): string;
|
|
26
|
+
export declare function sessionDir(workDir: string, id: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Read the session index, newest-first by last activity.
|
|
29
|
+
*
|
|
30
|
+
* Never throws, like `loadTurns`: a missing or corrupt index means "no sessions
|
|
31
|
+
* the picker can show", which is always a safe place to start from. A single bad
|
|
32
|
+
* entry is dropped rather than losing the whole list.
|
|
33
|
+
*/
|
|
34
|
+
export declare function listSessions(workDir: string): SessionMeta[];
|
|
35
|
+
/** The most recently active session, or undefined when there is none. */
|
|
36
|
+
export declare function latestSession(workDir: string): SessionMeta | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Register a new, empty session and return its metadata.
|
|
39
|
+
*
|
|
40
|
+
* The directory is not created here — `appendTurns` and `saveJournal` both
|
|
41
|
+
* `mkdir` lazily, so a session that is created and never run leaves nothing but
|
|
42
|
+
* an index entry behind.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createSession(workDir: string): SessionMeta;
|
|
45
|
+
/** Remove a session's directory and its index entry. Idempotent. */
|
|
46
|
+
export declare function deleteSession(workDir: string, id: string): void;
|
|
47
|
+
/** Update a session's summary in the index. `lastActiveAt` defaults to now. */
|
|
48
|
+
export declare function touchSession(workDir: string, id: string, patch: Partial<Pick<SessionMeta, "title" | "turnCount" | "lastActiveAt">>): void;
|
package/dist/tui/state.d.ts
CHANGED
|
@@ -210,6 +210,27 @@ export type TuiAction = {
|
|
|
210
210
|
/** Flip whether tool detail is shown in the stream. */
|
|
211
211
|
| {
|
|
212
212
|
type: "verbose:toggle";
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Start a fresh session in place: empty history, turn counter back to zero,
|
|
216
|
+
* roster reseeded. Carries the seeded agents and leader ids the same way
|
|
217
|
+
* `turn:start` does, so no module-level object is shared into live state.
|
|
218
|
+
*/
|
|
219
|
+
| {
|
|
220
|
+
type: "session:reset";
|
|
221
|
+
agents: Record<string, AgentView>;
|
|
222
|
+
leaderIds: string[];
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Switch to an existing session: like `session:reset`, but seeded with that
|
|
226
|
+
* session's finished turns so its history is on screen and the turn counter
|
|
227
|
+
* continues from where it left off.
|
|
228
|
+
*/
|
|
229
|
+
| {
|
|
230
|
+
type: "session:load";
|
|
231
|
+
agents: Record<string, AgentView>;
|
|
232
|
+
leaderIds: string[];
|
|
233
|
+
history: TurnView[];
|
|
213
234
|
};
|
|
214
235
|
export declare function stripAnsi(text: string): string;
|
|
215
236
|
/** Append a chunk, keeping only the last {@link MAX_LOG_CHARS} characters. */
|
package/dist/tui.js
CHANGED
|
@@ -4,7 +4,7 @@ import { render } from "ink";
|
|
|
4
4
|
import { createElement } from "react";
|
|
5
5
|
|
|
6
6
|
// src/tui/App.tsx
|
|
7
|
-
import * as
|
|
7
|
+
import * as path5 from "path";
|
|
8
8
|
import {
|
|
9
9
|
AgentSessionManager,
|
|
10
10
|
describeResumePlan,
|
|
@@ -38,6 +38,22 @@ function pendingWrite(target) {
|
|
|
38
38
|
return (writes.get(target) ?? Promise.resolve()).catch(() => {
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
+
function appendLines(target, lines) {
|
|
42
|
+
if (lines.length === 0) return pendingWrite(target);
|
|
43
|
+
const payload = `${lines.join("\n")}
|
|
44
|
+
`;
|
|
45
|
+
const write = (writes.get(target) ?? Promise.resolve()).catch(() => {
|
|
46
|
+
}).then(async () => {
|
|
47
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
48
|
+
await fs.promises.appendFile(target, payload, "utf-8");
|
|
49
|
+
});
|
|
50
|
+
writes.set(target, write);
|
|
51
|
+
void write.finally(() => {
|
|
52
|
+
if (writes.get(target) === write) writes.delete(target);
|
|
53
|
+
}).catch(() => {
|
|
54
|
+
});
|
|
55
|
+
return write;
|
|
56
|
+
}
|
|
41
57
|
|
|
42
58
|
// src/tui/commands.ts
|
|
43
59
|
var COMMANDS = [
|
|
@@ -55,6 +71,11 @@ var COMMANDS = [
|
|
|
55
71
|
description: "Browse finished turns, or continue one",
|
|
56
72
|
availability: "always"
|
|
57
73
|
},
|
|
74
|
+
{
|
|
75
|
+
name: "sessions",
|
|
76
|
+
description: "Switch between, or delete, saved sessions",
|
|
77
|
+
availability: "always"
|
|
78
|
+
},
|
|
58
79
|
{
|
|
59
80
|
name: "verbose",
|
|
60
81
|
description: "Toggle tool detail in the stream",
|
|
@@ -101,9 +122,9 @@ var COMMANDS = [
|
|
|
101
122
|
availability: "running"
|
|
102
123
|
},
|
|
103
124
|
{
|
|
104
|
-
name: "
|
|
125
|
+
name: "new",
|
|
105
126
|
args: "<requirement>",
|
|
106
|
-
description: "Start a new
|
|
127
|
+
description: "Start a new session with fresh agent sessions",
|
|
107
128
|
availability: "idle"
|
|
108
129
|
},
|
|
109
130
|
{
|
|
@@ -326,6 +347,15 @@ function formatDuration(ms) {
|
|
|
326
347
|
const hours = Math.floor(minutes / 60);
|
|
327
348
|
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
328
349
|
}
|
|
350
|
+
function formatAgo(timestamp, now) {
|
|
351
|
+
const seconds = Math.max(0, Math.floor((now - timestamp) / 1e3));
|
|
352
|
+
if (seconds < 60) return "just now";
|
|
353
|
+
const minutes = Math.floor(seconds / 60);
|
|
354
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
355
|
+
const hours = Math.floor(minutes / 60);
|
|
356
|
+
if (hours < 24) return `${hours}h ago`;
|
|
357
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
358
|
+
}
|
|
329
359
|
var CIRCLED = "\u2460\u2461\u2462\u2463\u2464\u2465\u2466\u2467\u2468\u2469\u246A\u246B\u246C\u246D\u246E\u246F\u2470\u2471\u2472\u2473";
|
|
330
360
|
function circled(n) {
|
|
331
361
|
return CIRCLED[n - 1] ?? `(${n})`;
|
|
@@ -1045,8 +1075,42 @@ ${action.text}
|
|
|
1045
1075
|
return state.view ? { ...state, view: void 0 } : state;
|
|
1046
1076
|
case "verbose:toggle":
|
|
1047
1077
|
return { ...state, verbose: !state.verbose };
|
|
1078
|
+
case "session:reset":
|
|
1079
|
+
return seededState(state, action.agents, action.leaderIds, []);
|
|
1080
|
+
case "session:load":
|
|
1081
|
+
return seededState(state, action.agents, action.leaderIds, action.history);
|
|
1048
1082
|
}
|
|
1049
1083
|
}
|
|
1084
|
+
function seededState(state, agents, leaderIds, history) {
|
|
1085
|
+
const ids = Object.keys(agents);
|
|
1086
|
+
return {
|
|
1087
|
+
phase: "idle",
|
|
1088
|
+
name: state.name,
|
|
1089
|
+
turn: history[history.length - 1]?.turn ?? 0,
|
|
1090
|
+
requirement: void 0,
|
|
1091
|
+
effectiveRequirement: void 0,
|
|
1092
|
+
startedAt: void 0,
|
|
1093
|
+
endedAt: void 0,
|
|
1094
|
+
cancelled: void 0,
|
|
1095
|
+
interrupted: void 0,
|
|
1096
|
+
success: void 0,
|
|
1097
|
+
acceptance: void 0,
|
|
1098
|
+
totalDuration: void 0,
|
|
1099
|
+
totalCostUsd: void 0,
|
|
1100
|
+
reason: void 0,
|
|
1101
|
+
warning: void 0,
|
|
1102
|
+
reply: void 0,
|
|
1103
|
+
stages: [],
|
|
1104
|
+
leaderIds,
|
|
1105
|
+
agents,
|
|
1106
|
+
focusId: ids[0],
|
|
1107
|
+
autoFollow: true,
|
|
1108
|
+
scroll: 0,
|
|
1109
|
+
history,
|
|
1110
|
+
view: void 0,
|
|
1111
|
+
verbose: state.verbose
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1050
1114
|
function stageStatus(stage, agents) {
|
|
1051
1115
|
if (stage.outcome !== void 0) return stage.outcome;
|
|
1052
1116
|
const statuses = stage.agents.map((a) => agents[a.id]?.status ?? "pending");
|
|
@@ -1497,6 +1561,54 @@ function HistoryOverlay({
|
|
|
1497
1561
|
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: `\u2191\u2193 select \xB7 enter open \xB7 c continue \xB7 esc close${range}` }) })
|
|
1498
1562
|
] });
|
|
1499
1563
|
}
|
|
1564
|
+
function SessionsOverlay({
|
|
1565
|
+
sessions,
|
|
1566
|
+
selected,
|
|
1567
|
+
currentId,
|
|
1568
|
+
width,
|
|
1569
|
+
maxRows,
|
|
1570
|
+
now
|
|
1571
|
+
}) {
|
|
1572
|
+
const capacity = historyCapacity(maxRows);
|
|
1573
|
+
const start = listWindowStart(selected, sessions.length, capacity);
|
|
1574
|
+
const visible = sessions.slice(start, start + capacity);
|
|
1575
|
+
const range = sessions.length > capacity ? ` \xB7 showing ${start + 1}\u2013${start + visible.length} of ${sessions.length}` : "";
|
|
1576
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", flexShrink: 0, paddingLeft: 2, paddingTop: 1, width, children: [
|
|
1577
|
+
/* @__PURE__ */ jsxs2(Box2, { justifyContent: "space-between", children: [
|
|
1578
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "SESSIONS" }),
|
|
1579
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
1580
|
+
sessions.length,
|
|
1581
|
+
" total",
|
|
1582
|
+
range
|
|
1583
|
+
] })
|
|
1584
|
+
] }),
|
|
1585
|
+
sessions.length === 0 ? /* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "No sessions yet." }) }) : /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", marginTop: 1, children: visible.map((session, offset) => {
|
|
1586
|
+
const index = start + offset;
|
|
1587
|
+
const focused = index === selected;
|
|
1588
|
+
const current = session.id === currentId;
|
|
1589
|
+
const count = session.turnCount;
|
|
1590
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", marginTop: index === 0 ? 0 : 1, children: [
|
|
1591
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
1592
|
+
/* @__PURE__ */ jsx2(Text2, { color: focused ? COLOR.accent : void 0, children: focused ? `${GLYPH.caret} ` : " " }),
|
|
1593
|
+
/* @__PURE__ */ jsxs2(Text2, { bold: focused, color: focused ? COLOR.accent : void 0, children: [
|
|
1594
|
+
current ? "\u25CF " : "",
|
|
1595
|
+
truncate(session.title || "(untitled session)", Math.max(8, width - 30))
|
|
1596
|
+
] })
|
|
1597
|
+
] }),
|
|
1598
|
+
/* @__PURE__ */ jsxs2(Text2, { dimColor: true, children: [
|
|
1599
|
+
" ",
|
|
1600
|
+
count,
|
|
1601
|
+
" turn",
|
|
1602
|
+
count === 1 ? "" : "s",
|
|
1603
|
+
" \xB7 ",
|
|
1604
|
+
formatAgo(session.lastActiveAt, now),
|
|
1605
|
+
current ? " \xB7 current" : ""
|
|
1606
|
+
] })
|
|
1607
|
+
] }, session.id);
|
|
1608
|
+
}) }),
|
|
1609
|
+
/* @__PURE__ */ jsx2(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" }) })
|
|
1610
|
+
] });
|
|
1611
|
+
}
|
|
1500
1612
|
|
|
1501
1613
|
// src/tui/components/interaction.tsx
|
|
1502
1614
|
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
@@ -1916,13 +2028,93 @@ function CommandMenu({ rows, selected }) {
|
|
|
1916
2028
|
}
|
|
1917
2029
|
|
|
1918
2030
|
// src/tui/history.ts
|
|
2031
|
+
import * as fs3 from "fs";
|
|
2032
|
+
import * as path3 from "path";
|
|
2033
|
+
|
|
2034
|
+
// src/tui/sessions.ts
|
|
2035
|
+
import { randomUUID } from "crypto";
|
|
1919
2036
|
import * as fs2 from "fs";
|
|
1920
2037
|
import * as path2 from "path";
|
|
1921
|
-
var
|
|
1922
|
-
var
|
|
2038
|
+
var SESSIONS_DIR = "sessions";
|
|
2039
|
+
var INDEX_FILE = "index.json";
|
|
2040
|
+
var MAX_TITLE_CHARS = 80;
|
|
2041
|
+
function sessionsRoot(workDir) {
|
|
2042
|
+
return path2.join(workDir, ".multiclaw", SESSIONS_DIR);
|
|
2043
|
+
}
|
|
2044
|
+
function sessionDir(workDir, id) {
|
|
2045
|
+
return path2.join(sessionsRoot(workDir), id);
|
|
2046
|
+
}
|
|
2047
|
+
function indexPath(workDir) {
|
|
2048
|
+
return path2.join(sessionsRoot(workDir), INDEX_FILE);
|
|
2049
|
+
}
|
|
2050
|
+
function isSessionMeta(value) {
|
|
2051
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2052
|
+
const meta = value;
|
|
2053
|
+
return typeof meta.id === "string" && typeof meta.title === "string" && typeof meta.createdAt === "number" && typeof meta.lastActiveAt === "number" && typeof meta.turnCount === "number";
|
|
2054
|
+
}
|
|
2055
|
+
function listSessions(workDir) {
|
|
2056
|
+
try {
|
|
2057
|
+
const parsed = JSON.parse(fs2.readFileSync(indexPath(workDir), "utf-8"));
|
|
2058
|
+
if (!Array.isArray(parsed)) return [];
|
|
2059
|
+
return parsed.filter(isSessionMeta).sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
2060
|
+
} catch {
|
|
2061
|
+
return [];
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
function latestSession(workDir) {
|
|
2065
|
+
return listSessions(workDir)[0];
|
|
2066
|
+
}
|
|
2067
|
+
function mutateIndex(workDir, fn) {
|
|
2068
|
+
const next = fn(readIndex(workDir));
|
|
2069
|
+
const target = indexPath(workDir);
|
|
2070
|
+
fs2.mkdirSync(path2.dirname(target), { recursive: true });
|
|
2071
|
+
const tmp = `${target}.tmp`;
|
|
2072
|
+
fs2.writeFileSync(tmp, JSON.stringify(next), "utf-8");
|
|
2073
|
+
fs2.renameSync(tmp, target);
|
|
2074
|
+
}
|
|
2075
|
+
function readIndex(workDir) {
|
|
2076
|
+
try {
|
|
2077
|
+
const parsed = JSON.parse(fs2.readFileSync(indexPath(workDir), "utf-8"));
|
|
2078
|
+
return Array.isArray(parsed) ? parsed.filter(isSessionMeta) : [];
|
|
2079
|
+
} catch {
|
|
2080
|
+
return [];
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
function createSession(workDir) {
|
|
2084
|
+
const now = Date.now();
|
|
2085
|
+
const meta = {
|
|
2086
|
+
id: randomUUID(),
|
|
2087
|
+
title: "",
|
|
2088
|
+
createdAt: now,
|
|
2089
|
+
lastActiveAt: now,
|
|
2090
|
+
turnCount: 0
|
|
2091
|
+
};
|
|
2092
|
+
mutateIndex(workDir, (metas) => [meta, ...metas]);
|
|
2093
|
+
return meta;
|
|
2094
|
+
}
|
|
2095
|
+
function deleteSession(workDir, id) {
|
|
2096
|
+
fs2.rmSync(sessionDir(workDir, id), { recursive: true, force: true });
|
|
2097
|
+
mutateIndex(workDir, (metas) => metas.filter((meta) => meta.id !== id));
|
|
2098
|
+
}
|
|
2099
|
+
function touchSession(workDir, id, patch) {
|
|
2100
|
+
mutateIndex(
|
|
2101
|
+
workDir,
|
|
2102
|
+
(metas) => metas.map(
|
|
2103
|
+
(meta) => meta.id === id ? {
|
|
2104
|
+
...meta,
|
|
2105
|
+
...patch.title !== void 0 ? { title: patch.title.slice(0, MAX_TITLE_CHARS) } : {},
|
|
2106
|
+
...patch.turnCount !== void 0 ? { turnCount: patch.turnCount } : {},
|
|
2107
|
+
lastActiveAt: patch.lastActiveAt ?? Date.now()
|
|
2108
|
+
} : meta
|
|
2109
|
+
)
|
|
2110
|
+
);
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// src/tui/history.ts
|
|
2114
|
+
var TURNS_FILE = "turns.jsonl";
|
|
1923
2115
|
var EXPORT_DIR = "exports";
|
|
1924
|
-
function
|
|
1925
|
-
return
|
|
2116
|
+
function turnsPath(workDir, sessionId) {
|
|
2117
|
+
return path3.join(sessionDir(workDir, sessionId), TURNS_FILE);
|
|
1926
2118
|
}
|
|
1927
2119
|
function isObject(value) {
|
|
1928
2120
|
return typeof value === "object" && value !== null;
|
|
@@ -1952,25 +2144,36 @@ function isTurnView(value) {
|
|
|
1952
2144
|
))
|
|
1953
2145
|
) && Array.isArray(turn.leaderIds);
|
|
1954
2146
|
}
|
|
1955
|
-
function
|
|
2147
|
+
function loadTurns(workDir, sessionId) {
|
|
1956
2148
|
try {
|
|
1957
|
-
const raw =
|
|
1958
|
-
const
|
|
1959
|
-
|
|
1960
|
-
|
|
2149
|
+
const raw = fs3.readFileSync(turnsPath(workDir, sessionId), "utf-8");
|
|
2150
|
+
const turns = [];
|
|
2151
|
+
for (const line of raw.split("\n")) {
|
|
2152
|
+
if (line.trim() === "") continue;
|
|
2153
|
+
try {
|
|
2154
|
+
const parsed = JSON.parse(line);
|
|
2155
|
+
if (isTurnView(parsed)) turns.push(parsed);
|
|
2156
|
+
} catch {
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
return turns.slice(-MAX_HISTORY);
|
|
1961
2160
|
} catch {
|
|
1962
2161
|
return [];
|
|
1963
2162
|
}
|
|
1964
2163
|
}
|
|
1965
|
-
function
|
|
1966
|
-
|
|
2164
|
+
function appendTurns(workDir, sessionId, turns) {
|
|
2165
|
+
if (turns.length === 0) return Promise.resolve();
|
|
2166
|
+
return appendLines(
|
|
2167
|
+
turnsPath(workDir, sessionId),
|
|
2168
|
+
turns.map((turn) => JSON.stringify(turn))
|
|
2169
|
+
);
|
|
1967
2170
|
}
|
|
1968
|
-
function exportPath(workDir, turn) {
|
|
1969
|
-
return
|
|
2171
|
+
function exportPath(workDir, sessionId, turn) {
|
|
2172
|
+
return path3.join(sessionDir(workDir, sessionId), EXPORT_DIR, `turn-${turn}.md`);
|
|
1970
2173
|
}
|
|
1971
2174
|
async function exportTurn(turn, filePath) {
|
|
1972
|
-
await
|
|
1973
|
-
await
|
|
2175
|
+
await fs3.promises.mkdir(path3.dirname(filePath), { recursive: true });
|
|
2176
|
+
await fs3.promises.writeFile(filePath, formatTurnMarkdown(turn), "utf-8");
|
|
1974
2177
|
}
|
|
1975
2178
|
function formatTurnMarkdown(turn) {
|
|
1976
2179
|
const lines = [`# Turn ${turn.turn}`, ""];
|
|
@@ -2012,13 +2215,13 @@ function formatTurnMarkdown(turn) {
|
|
|
2012
2215
|
}
|
|
2013
2216
|
|
|
2014
2217
|
// src/tui/session.ts
|
|
2015
|
-
import * as
|
|
2016
|
-
import * as
|
|
2218
|
+
import * as fs4 from "fs";
|
|
2219
|
+
import * as path4 from "path";
|
|
2017
2220
|
var SESSION_FILE = "session.json";
|
|
2018
2221
|
var SESSION_VERSION = 1;
|
|
2019
2222
|
var MAX_JOURNAL_LOG_CHARS = 1200;
|
|
2020
|
-
function sessionPath(workDir) {
|
|
2021
|
-
return
|
|
2223
|
+
function sessionPath(workDir, sessionId) {
|
|
2224
|
+
return path4.join(sessionDir(workDir, sessionId), SESSION_FILE);
|
|
2022
2225
|
}
|
|
2023
2226
|
function isObject2(value) {
|
|
2024
2227
|
return typeof value === "object" && value !== null;
|
|
@@ -2040,14 +2243,13 @@ function isJournalTurn(value) {
|
|
|
2040
2243
|
if (!isObject2(value)) return false;
|
|
2041
2244
|
return Number.isInteger(value.turn) && typeof value.requirement === "string" && typeof value.savedAt === "number" && Number.isFinite(value.savedAt) && (value.startedAt === void 0 || typeof value.startedAt === "number") && (value.effectiveRequirement === void 0 || typeof value.effectiveRequirement === "string") && (value.reason === void 0 || typeof value.reason === "string") && (value.warning === void 0 || typeof value.warning === "string") && (value.focusId === void 0 || typeof value.focusId === "string") && isObject2(value.agents) && Object.values(value.agents).every(isAgentView) && Array.isArray(value.leaderIds) && value.leaderIds.every((id) => typeof id === "string") && Array.isArray(value.stages) && value.stages.every(isObject2) && (value.pending === void 0 || isObject2(value.pending) && Number.isInteger(value.pending.permissions) && Number.isInteger(value.pending.questions));
|
|
2042
2245
|
}
|
|
2043
|
-
function loadJournal(workDir) {
|
|
2246
|
+
function loadJournal(workDir, sessionId) {
|
|
2044
2247
|
try {
|
|
2045
|
-
const parsed = JSON.parse(
|
|
2248
|
+
const parsed = JSON.parse(fs4.readFileSync(sessionPath(workDir, sessionId), "utf-8"));
|
|
2046
2249
|
if (!isObject2(parsed) || parsed.version !== SESSION_VERSION) return void 0;
|
|
2047
2250
|
const queue = Array.isArray(parsed.queue) ? parsed.queue.filter((item) => typeof item === "string" && item !== "") : [];
|
|
2048
2251
|
return {
|
|
2049
2252
|
version: SESSION_VERSION,
|
|
2050
|
-
...Number.isInteger(parsed.taskStart) && parsed.taskStart >= 0 ? { taskStart: parsed.taskStart } : {},
|
|
2051
2253
|
...queue.length > 0 ? { queue } : {},
|
|
2052
2254
|
...isJournalTurn(parsed.inFlight) ? { inFlight: parsed.inFlight } : {}
|
|
2053
2255
|
};
|
|
@@ -2056,11 +2258,11 @@ function loadJournal(workDir) {
|
|
|
2056
2258
|
}
|
|
2057
2259
|
}
|
|
2058
2260
|
function isEmpty(journal) {
|
|
2059
|
-
return journal.inFlight === void 0 && journal.queue === void 0
|
|
2261
|
+
return journal.inFlight === void 0 && journal.queue === void 0;
|
|
2060
2262
|
}
|
|
2061
|
-
function saveJournal(workDir, journal) {
|
|
2062
|
-
const target = sessionPath(workDir);
|
|
2063
|
-
if (isEmpty(journal) && !
|
|
2263
|
+
function saveJournal(workDir, sessionId, journal) {
|
|
2264
|
+
const target = sessionPath(workDir, sessionId);
|
|
2265
|
+
if (isEmpty(journal) && !fs4.existsSync(target)) return Promise.resolve();
|
|
2064
2266
|
return writeJsonFile(target, JSON.stringify(journal));
|
|
2065
2267
|
}
|
|
2066
2268
|
function tail(text, max) {
|
|
@@ -2114,11 +2316,12 @@ function interruptedTurn(entry) {
|
|
|
2114
2316
|
function plural(count, word) {
|
|
2115
2317
|
return count === 1 ? word : `${word}s`;
|
|
2116
2318
|
}
|
|
2117
|
-
function restoreConsole(workDir) {
|
|
2118
|
-
const history =
|
|
2119
|
-
const journal = loadJournal(workDir);
|
|
2319
|
+
function restoreConsole(workDir, sessionId) {
|
|
2320
|
+
const history = loadTurns(workDir, sessionId);
|
|
2321
|
+
const journal = loadJournal(workDir, sessionId);
|
|
2120
2322
|
const inFlight = journal?.inFlight;
|
|
2121
2323
|
const last = history[history.length - 1];
|
|
2324
|
+
const persistedTurn = last?.turn ?? 0;
|
|
2122
2325
|
const parts = [];
|
|
2123
2326
|
if (inFlight && (!last || inFlight.turn > last.turn)) {
|
|
2124
2327
|
history.push(interruptedTurn(inFlight));
|
|
@@ -2138,7 +2341,7 @@ function restoreConsole(workDir) {
|
|
|
2138
2341
|
return {
|
|
2139
2342
|
history: restored,
|
|
2140
2343
|
queue,
|
|
2141
|
-
|
|
2344
|
+
persistedTurn,
|
|
2142
2345
|
// Read from the *final* last turn: a recovered interrupted turn has no
|
|
2143
2346
|
// reply, so it correctly clears the "waiting for an answer" state.
|
|
2144
2347
|
awaitingAnswer: restored[restored.length - 1]?.reply?.mode === "ask",
|
|
@@ -2235,12 +2438,41 @@ function StatusLine({
|
|
|
2235
2438
|
stages > 0 ? ` \xB7 ${stages} stages` : ""
|
|
2236
2439
|
] });
|
|
2237
2440
|
}
|
|
2238
|
-
function App({
|
|
2441
|
+
function App({
|
|
2442
|
+
config,
|
|
2443
|
+
onTurnStart,
|
|
2444
|
+
sessions: providedSessions,
|
|
2445
|
+
onCleanupReady,
|
|
2446
|
+
initialSession
|
|
2447
|
+
}) {
|
|
2239
2448
|
const { exit } = useApp();
|
|
2240
2449
|
const { columns, rows } = useWindowSize();
|
|
2450
|
+
const sessionIdRef = useRef(null);
|
|
2451
|
+
const launchNoticeRef = useRef(void 0);
|
|
2452
|
+
const openPickerOnMountRef = useRef(false);
|
|
2453
|
+
if (!sessionIdRef.current) {
|
|
2454
|
+
const latest = () => latestSession(config.workDir)?.id ?? createSession(config.workDir).id;
|
|
2455
|
+
if (initialSession?.kind === "new") {
|
|
2456
|
+
sessionIdRef.current = createSession(config.workDir).id;
|
|
2457
|
+
} else if (initialSession?.kind === "resume" && initialSession.id) {
|
|
2458
|
+
const target = initialSession.id;
|
|
2459
|
+
if (listSessions(config.workDir).some((s) => s.id === target)) {
|
|
2460
|
+
sessionIdRef.current = target;
|
|
2461
|
+
} else {
|
|
2462
|
+
sessionIdRef.current = latest();
|
|
2463
|
+
launchNoticeRef.current = `session "${target}" not found \u2014 opened the most recent instead`;
|
|
2464
|
+
}
|
|
2465
|
+
} else {
|
|
2466
|
+
sessionIdRef.current = latest();
|
|
2467
|
+
if (initialSession?.kind === "resume") openPickerOnMountRef.current = true;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2241
2470
|
const restoredRef = useRef(null);
|
|
2242
|
-
if (!restoredRef.current)
|
|
2471
|
+
if (!restoredRef.current) {
|
|
2472
|
+
restoredRef.current = restoreConsole(config.workDir, sessionIdRef.current);
|
|
2473
|
+
}
|
|
2243
2474
|
const restored = restoredRef.current;
|
|
2475
|
+
const persistedTurnRef = useRef(restored.persistedTurn);
|
|
2244
2476
|
const [state, dispatch] = useReducer(
|
|
2245
2477
|
tuiReducer,
|
|
2246
2478
|
config,
|
|
@@ -2257,10 +2489,15 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2257
2489
|
queueRef.current = queue;
|
|
2258
2490
|
const [cancelled, setCancelled] = useState(false);
|
|
2259
2491
|
const [error, setError] = useState();
|
|
2260
|
-
const [notice, setNotice] = useState(
|
|
2492
|
+
const [notice, setNotice] = useState(
|
|
2493
|
+
launchNoticeRef.current ?? restored.notice
|
|
2494
|
+
);
|
|
2261
2495
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
2262
2496
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
2263
2497
|
const [historyCursor, setHistoryCursor] = useState(0);
|
|
2498
|
+
const [sessionsOpen, setSessionsOpen] = useState(false);
|
|
2499
|
+
const [sessionCursor, setSessionCursor] = useState(0);
|
|
2500
|
+
const [sessionList, setSessionList] = useState([]);
|
|
2264
2501
|
const [menuCursor, setMenuCursor] = useState({
|
|
2265
2502
|
query: void 0,
|
|
2266
2503
|
index: 0
|
|
@@ -2274,7 +2511,6 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2274
2511
|
const closingRef = useRef(false);
|
|
2275
2512
|
const shutdownRef = useRef(null);
|
|
2276
2513
|
const busyRef = useRef(false);
|
|
2277
|
-
const taskStartRef = useRef(restored.taskStart);
|
|
2278
2514
|
const turnSerialRef = useRef(0);
|
|
2279
2515
|
const [awaitingAnswer, setAwaitingAnswer] = useState(restored.awaitingAnswer);
|
|
2280
2516
|
const [queuePaused, setQueuePaused] = useState(restored.queue.length > 0);
|
|
@@ -2375,16 +2611,21 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2375
2611
|
const timer = setInterval(() => setCaretOn((on) => !on), BLINK_INTERVAL_MS);
|
|
2376
2612
|
return () => clearInterval(timer);
|
|
2377
2613
|
}, [running, line.text]);
|
|
2378
|
-
const lastSaved = useRef(void 0);
|
|
2379
2614
|
useEffect(() => {
|
|
2380
|
-
const
|
|
2381
|
-
if (
|
|
2382
|
-
const
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2615
|
+
const sessionId = sessionIdRef.current;
|
|
2616
|
+
if (!sessionId) return;
|
|
2617
|
+
const turns = recordedTurns(stateRef.current);
|
|
2618
|
+
const fresh = turns.filter((turn) => turn.turn > persistedTurnRef.current);
|
|
2619
|
+
if (fresh.length === 0) return;
|
|
2620
|
+
persistedTurnRef.current = fresh[fresh.length - 1].turn;
|
|
2621
|
+
void appendTurns(config.workDir, sessionId, fresh).catch(
|
|
2386
2622
|
(err) => setNotice(`history not saved: ${err.message}`)
|
|
2387
2623
|
);
|
|
2624
|
+
const title = turns.find((turn) => turn.requirement)?.requirement;
|
|
2625
|
+
touchSession(config.workDir, sessionId, {
|
|
2626
|
+
turnCount: fresh[fresh.length - 1].turn,
|
|
2627
|
+
...title ? { title } : {}
|
|
2628
|
+
});
|
|
2388
2629
|
}, [state.history, state.phase]);
|
|
2389
2630
|
const persistSession = useCallback(() => {
|
|
2390
2631
|
if (closingRef.current) return;
|
|
@@ -2394,15 +2635,16 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2394
2635
|
if (entry.kind === "permission") waiting.permissions += 1;
|
|
2395
2636
|
else waiting.questions += 1;
|
|
2396
2637
|
}
|
|
2638
|
+
const sessionId = sessionIdRef.current;
|
|
2639
|
+
if (!sessionId) return;
|
|
2397
2640
|
const journal = {
|
|
2398
2641
|
version: SESSION_VERSION,
|
|
2399
|
-
taskStart: taskStartRef.current,
|
|
2400
2642
|
...queueRef.current.length > 0 ? { queue: [...queueRef.current] } : {},
|
|
2401
2643
|
// Only a turn that is actually running is in flight. Writing one for an
|
|
2402
2644
|
// idle console would invent work the next session would then "recover".
|
|
2403
2645
|
...current.phase === "running" && current.requirement !== void 0 ? { inFlight: journalTurn(current, waiting) } : {}
|
|
2404
2646
|
};
|
|
2405
|
-
void saveJournal(config.workDir, journal).catch((err) => {
|
|
2647
|
+
void saveJournal(config.workDir, sessionId, journal).catch((err) => {
|
|
2406
2648
|
if (mountedRef.current) setNotice(`session not saved: ${err.message}`);
|
|
2407
2649
|
});
|
|
2408
2650
|
}, [config.workDir]);
|
|
@@ -2424,10 +2666,10 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2424
2666
|
if (closingRef.current || busyRef.current) return;
|
|
2425
2667
|
busyRef.current = true;
|
|
2426
2668
|
setRunning(true);
|
|
2427
|
-
const continuation = opts?.
|
|
2669
|
+
const continuation = opts?.newSession ? null : continuationRef.current;
|
|
2428
2670
|
continuationRef.current = null;
|
|
2429
2671
|
const resume = opts?.resume ?? continuationResume(continuation, sessionsRef.current);
|
|
2430
|
-
if (opts?.
|
|
2672
|
+
if (opts?.newSession) {
|
|
2431
2673
|
try {
|
|
2432
2674
|
await sessionsRef.current?.reset();
|
|
2433
2675
|
if (closingRef.current) {
|
|
@@ -2435,7 +2677,14 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2435
2677
|
setRunning(false);
|
|
2436
2678
|
return;
|
|
2437
2679
|
}
|
|
2438
|
-
|
|
2680
|
+
const created = createSession(config.workDir);
|
|
2681
|
+
sessionIdRef.current = created.id;
|
|
2682
|
+
persistedTurnRef.current = 0;
|
|
2683
|
+
dispatch({
|
|
2684
|
+
type: "session:reset",
|
|
2685
|
+
agents: seedAgents(config),
|
|
2686
|
+
leaderIds: leaderAgentIds(config)
|
|
2687
|
+
});
|
|
2439
2688
|
} catch (err) {
|
|
2440
2689
|
busyRef.current = false;
|
|
2441
2690
|
setRunning(false);
|
|
@@ -2453,11 +2702,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2453
2702
|
setError(void 0);
|
|
2454
2703
|
setCancelled(false);
|
|
2455
2704
|
setNotice(void 0);
|
|
2456
|
-
const earlierTurns = conversationTurns(
|
|
2457
|
-
...stateRef.current,
|
|
2458
|
-
history: stateRef.current.history.filter((turn) => turn.turn > taskStartRef.current),
|
|
2459
|
-
...stateRef.current.turn <= taskStartRef.current ? { phase: "idle" } : {}
|
|
2460
|
-
});
|
|
2705
|
+
const earlierTurns = opts?.newSession ? [] : conversationTurns(stateRef.current);
|
|
2461
2706
|
dispatch({
|
|
2462
2707
|
type: "turn:start",
|
|
2463
2708
|
requirement,
|
|
@@ -2466,7 +2711,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2466
2711
|
startedAt: Date.now()
|
|
2467
2712
|
});
|
|
2468
2713
|
const stamp = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${++turnSerialRef.current}`;
|
|
2469
|
-
const logDir =
|
|
2714
|
+
const logDir = path5.join(config.workDir, ".multiclaw", "runs", `run-${stamp}`, "logs");
|
|
2470
2715
|
const runConfig = {
|
|
2471
2716
|
...config,
|
|
2472
2717
|
context: { ...config.context, requirement },
|
|
@@ -2549,16 +2794,53 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2549
2794
|
setQueue(rest);
|
|
2550
2795
|
void startTurn(next);
|
|
2551
2796
|
}, [running, queue, queuePaused, awaitingAnswer, startTurn]);
|
|
2797
|
+
const switchSession = useCallback(
|
|
2798
|
+
async (id) => {
|
|
2799
|
+
if (id === sessionIdRef.current || busyRef.current || closingRef.current) return;
|
|
2800
|
+
try {
|
|
2801
|
+
await sessionsRef.current?.reset();
|
|
2802
|
+
} catch (err) {
|
|
2803
|
+
setError(`session switch failed: ${err.message}`);
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
if (closingRef.current) return;
|
|
2807
|
+
const restored2 = restoreConsole(config.workDir, id);
|
|
2808
|
+
sessionIdRef.current = id;
|
|
2809
|
+
persistedTurnRef.current = restored2.persistedTurn;
|
|
2810
|
+
const resumable = [...restored2.history].reverse().find((turn) => Object.keys(turnSessions(turn)).length > 0);
|
|
2811
|
+
continuationRef.current = resumable ? { turn: resumable.turn, sessions: turnSessions(resumable) } : null;
|
|
2812
|
+
dispatch({
|
|
2813
|
+
type: "session:load",
|
|
2814
|
+
agents: seedAgents(config),
|
|
2815
|
+
leaderIds: leaderAgentIds(config),
|
|
2816
|
+
history: restored2.history
|
|
2817
|
+
});
|
|
2818
|
+
setQueue(restored2.queue);
|
|
2819
|
+
queuePausedRef.current = restored2.queue.length > 0;
|
|
2820
|
+
setQueuePaused(restored2.queue.length > 0);
|
|
2821
|
+
awaitingAnswerRef.current = restored2.awaitingAnswer;
|
|
2822
|
+
setAwaitingAnswer(restored2.awaitingAnswer);
|
|
2823
|
+
setError(void 0);
|
|
2824
|
+
setCancelled(false);
|
|
2825
|
+
setNotice(
|
|
2826
|
+
restored2.notice ?? (resumable ? `switched session \xB7 continue with your next instruction` : `switched session \xB7 ${restored2.history.length} turn${restored2.history.length === 1 ? "" : "s"}`)
|
|
2827
|
+
);
|
|
2828
|
+
},
|
|
2829
|
+
[config]
|
|
2830
|
+
);
|
|
2552
2831
|
const cleanup = useCallback(async () => {
|
|
2553
2832
|
closingRef.current = true;
|
|
2554
2833
|
abortRef.current?.abort();
|
|
2555
2834
|
clearInteractions();
|
|
2556
2835
|
await activeTurnRef.current;
|
|
2557
2836
|
await sessionsRef.current?.close();
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2837
|
+
const sessionId = sessionIdRef.current;
|
|
2838
|
+
if (sessionId) {
|
|
2839
|
+
await Promise.all([
|
|
2840
|
+
pendingWrite(turnsPath(config.workDir, sessionId)),
|
|
2841
|
+
pendingWrite(sessionPath(config.workDir, sessionId))
|
|
2842
|
+
]);
|
|
2843
|
+
}
|
|
2562
2844
|
if (reporterErrorRef.current) throw reporterErrorRef.current;
|
|
2563
2845
|
}, [clearInteractions, config.workDir]);
|
|
2564
2846
|
useEffect(() => {
|
|
@@ -2570,6 +2852,15 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2570
2852
|
});
|
|
2571
2853
|
};
|
|
2572
2854
|
}, [cleanup, onCleanupReady]);
|
|
2855
|
+
useEffect(() => {
|
|
2856
|
+
if (!openPickerOnMountRef.current) return;
|
|
2857
|
+
openPickerOnMountRef.current = false;
|
|
2858
|
+
const list = listSessions(config.workDir);
|
|
2859
|
+
setSessionList(list);
|
|
2860
|
+
const current = list.findIndex((session) => session.id === sessionIdRef.current);
|
|
2861
|
+
setSessionCursor(current === -1 ? 0 : current);
|
|
2862
|
+
setSessionsOpen(true);
|
|
2863
|
+
}, []);
|
|
2573
2864
|
const shutdown = useCallback(() => {
|
|
2574
2865
|
if (shutdownRef.current) return shutdownRef.current;
|
|
2575
2866
|
closingRef.current = true;
|
|
@@ -2582,13 +2873,20 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2582
2873
|
shutdownRef.current = (async () => {
|
|
2583
2874
|
try {
|
|
2584
2875
|
await cleanup();
|
|
2585
|
-
const
|
|
2586
|
-
if (
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2876
|
+
const sessionId = sessionIdRef.current;
|
|
2877
|
+
if (sessionId) {
|
|
2878
|
+
const fresh = recordedTurns(stateRef.current).filter(
|
|
2879
|
+
(turn) => turn.turn > persistedTurnRef.current
|
|
2880
|
+
);
|
|
2881
|
+
if (fresh.length) {
|
|
2882
|
+
persistedTurnRef.current = fresh[fresh.length - 1].turn;
|
|
2883
|
+
await appendTurns(config.workDir, sessionId, fresh);
|
|
2884
|
+
}
|
|
2885
|
+
await saveJournal(config.workDir, sessionId, {
|
|
2886
|
+
version: SESSION_VERSION,
|
|
2887
|
+
...leftover.length > 0 ? { queue: leftover } : {}
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2592
2890
|
exit();
|
|
2593
2891
|
} catch (err) {
|
|
2594
2892
|
setError(`shutdown failed: ${err.message}`);
|
|
@@ -2617,8 +2915,8 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2617
2915
|
const paletteTotal = allRows.length;
|
|
2618
2916
|
const menuIndex = menuCursor.query === paletteKey ? menuCursor.index : 0;
|
|
2619
2917
|
const selected = paletteTotal === 0 ? 0 : Math.min(menuIndex, paletteTotal - 1);
|
|
2620
|
-
const menuOpen = !helpOpen && !historyOpen && query !== void 0 && matches.length > 0;
|
|
2621
|
-
const argOpen = !helpOpen && !historyOpen && arg !== void 0 && argRows.length > 0;
|
|
2918
|
+
const menuOpen = !helpOpen && !historyOpen && !sessionsOpen && query !== void 0 && matches.length > 0;
|
|
2919
|
+
const argOpen = !helpOpen && !historyOpen && !sessionsOpen && arg !== void 0 && argRows.length > 0;
|
|
2622
2920
|
const paletteOpen = menuOpen || argOpen;
|
|
2623
2921
|
const front = interactions[0];
|
|
2624
2922
|
const menu = front?.kind === "permission" && permissionMenu.id === front.shortId ? permissionMenu : { id: front?.shortId ?? "", cursor: void 0, released: false };
|
|
@@ -2639,7 +2937,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2639
2937
|
columns,
|
|
2640
2938
|
rows,
|
|
2641
2939
|
turn: state.turn,
|
|
2642
|
-
overlay: helpOpen || historyOpen,
|
|
2940
|
+
overlay: helpOpen || historyOpen || sessionsOpen,
|
|
2643
2941
|
menuRows: paletteOpen ? paletteTotal : 0,
|
|
2644
2942
|
queueRows: queue.length,
|
|
2645
2943
|
interactionRows: interactionRows(interactions, Math.max(MIN_COLUMNS, columns)),
|
|
@@ -2785,16 +3083,16 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2785
3083
|
setNotice(`selected ${clean(option.name)} for ${entry.shortId}`);
|
|
2786
3084
|
return;
|
|
2787
3085
|
}
|
|
2788
|
-
case "
|
|
3086
|
+
case "new": {
|
|
2789
3087
|
const text = args.join(" ");
|
|
2790
3088
|
if (!text) {
|
|
2791
|
-
setNotice("usage: /
|
|
3089
|
+
setNotice("usage: /new <requirement> \u2014 starts a new session with fresh agent sessions");
|
|
2792
3090
|
return;
|
|
2793
3091
|
}
|
|
2794
3092
|
setQueue([]);
|
|
2795
3093
|
queuePausedRef.current = false;
|
|
2796
3094
|
setQueuePaused(false);
|
|
2797
|
-
void startTurn(text, {
|
|
3095
|
+
void startTurn(text, { newSession: true });
|
|
2798
3096
|
return;
|
|
2799
3097
|
}
|
|
2800
3098
|
case "answer": {
|
|
@@ -2892,6 +3190,15 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2892
3190
|
setHistoryCursor(0);
|
|
2893
3191
|
setHistoryOpen(true);
|
|
2894
3192
|
return;
|
|
3193
|
+
case "sessions": {
|
|
3194
|
+
const list = listSessions(config.workDir);
|
|
3195
|
+
setSessionList(list);
|
|
3196
|
+
const current = list.findIndex((session) => session.id === sessionIdRef.current);
|
|
3197
|
+
setSessionCursor(current === -1 ? 0 : current);
|
|
3198
|
+
setNotice(void 0);
|
|
3199
|
+
setSessionsOpen(true);
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
2895
3202
|
case "verbose":
|
|
2896
3203
|
dispatch({ type: "verbose:toggle" });
|
|
2897
3204
|
setNotice(state.verbose ? "verbose off" : "verbose on \u2014 showing tool detail");
|
|
@@ -2969,7 +3276,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
2969
3276
|
setNotice(target.error);
|
|
2970
3277
|
return;
|
|
2971
3278
|
}
|
|
2972
|
-
const file = args[1] ?
|
|
3279
|
+
const file = args[1] ? path5.resolve(config.workDir, args[1]) : exportPath(config.workDir, sessionIdRef.current ?? "", target.record.turn);
|
|
2973
3280
|
void exportTurn(target.record, file).then(() => setNotice(`exported turn ${target.record.turn} to ${file}`)).catch((err) => setNotice(`export failed: ${err.message}`));
|
|
2974
3281
|
return;
|
|
2975
3282
|
}
|
|
@@ -3090,6 +3397,8 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
3090
3397
|
setHelpOpen(false);
|
|
3091
3398
|
} else if (historyOpen) {
|
|
3092
3399
|
setHistoryOpen(false);
|
|
3400
|
+
} else if (sessionsOpen) {
|
|
3401
|
+
setSessionsOpen(false);
|
|
3093
3402
|
} else if (abortRef.current) {
|
|
3094
3403
|
abortRef.current.abort();
|
|
3095
3404
|
clearInteractions();
|
|
@@ -3144,6 +3453,50 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
3144
3453
|
}
|
|
3145
3454
|
return;
|
|
3146
3455
|
}
|
|
3456
|
+
if (sessionsOpen) {
|
|
3457
|
+
if (key.escape) {
|
|
3458
|
+
setSessionsOpen(false);
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
if (sessionList.length === 0) return;
|
|
3462
|
+
const index = Math.min(sessionCursor, sessionList.length - 1);
|
|
3463
|
+
if (key.upArrow) {
|
|
3464
|
+
setSessionCursor((index - 1 + sessionList.length) % sessionList.length);
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
if (key.downArrow) {
|
|
3468
|
+
setSessionCursor((index + 1) % sessionList.length);
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
if (key.return) {
|
|
3472
|
+
const target = sessionList[index];
|
|
3473
|
+
setSessionsOpen(false);
|
|
3474
|
+
if (target.id === sessionIdRef.current) {
|
|
3475
|
+
setNotice("already on this session");
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
if (running || busyRef.current) {
|
|
3479
|
+
setNotice("finish or cancel the current turn before switching sessions");
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
void switchSession(target.id);
|
|
3483
|
+
return;
|
|
3484
|
+
}
|
|
3485
|
+
if (char === "d") {
|
|
3486
|
+
const target = sessionList[index];
|
|
3487
|
+
if (target.id === sessionIdRef.current) {
|
|
3488
|
+
setNotice("can't delete the current session");
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
deleteSession(config.workDir, target.id);
|
|
3492
|
+
const next = listSessions(config.workDir);
|
|
3493
|
+
setSessionList(next);
|
|
3494
|
+
setSessionCursor(Math.min(index, Math.max(0, next.length - 1)));
|
|
3495
|
+
setNotice(`deleted session \xB7 ${next.length} left`);
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
return;
|
|
3499
|
+
}
|
|
3147
3500
|
if (key.ctrl && char === "d") {
|
|
3148
3501
|
if (!line.text) void shutdown();
|
|
3149
3502
|
return;
|
|
@@ -3390,7 +3743,7 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
3390
3743
|
const handlerRef = useRef(handleKey);
|
|
3391
3744
|
handlerRef.current = handleKey;
|
|
3392
3745
|
useInput((char, key) => handlerRef.current(char, key));
|
|
3393
|
-
const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 /modify <instruction> \xB7 /cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
|
|
3746
|
+
const keys = helpOpen ? "esc close" : historyOpen ? "\u2191\u2193 select \xB7 enter open \xB7 esc close" : sessionsOpen ? "\u2191\u2193 select \xB7 enter open \xB7 d delete \xB7 esc close" : argOpen ? `\u2191\u2193 select \xB7 tab complete \xB7 enter ${arg?.command.name} \xB7 esc close` : menuOpen ? "\u2191\u2193 select \xB7 tab complete \xB7 enter run \xB7 esc close" : running ? checkpointResolveRef.current ? `/continue \xB7 /modify <instruction> \xB7 /cancel` : interactions.length > 0 ? layout.interactionRows === 0 ? (
|
|
3394
3747
|
// Too short to dock the bar, so the commands are the only way in.
|
|
3395
3748
|
"decide with /approve 1 \xB7 /deny \xB7 /respond"
|
|
3396
3749
|
) : interactions[0].kind === "question" ? "tab field \xB7 \u2191\u2193 pick \xB7 enter next \xB7 /dismiss" : menu.released ? "/approve 1 and /deny still reach it" : "\u2191\u2193 or 1-9 choose \xB7 enter confirm \xB7 esc to type" : "enter queues \xB7 ctrl+c cancel" : state.view ? "esc live \xB7 tab agent \xB7 ? help" : state.reply ? "enter sends \xB7 / commands \xB7 ? help" : "tab switch \xB7 / commands \xB7 ? help";
|
|
@@ -3428,6 +3781,16 @@ function App({ config, onTurnStart, sessions: providedSessions, onCleanupReady }
|
|
|
3428
3781
|
width: layout.columns,
|
|
3429
3782
|
maxRows: layout.bodyHeight
|
|
3430
3783
|
}
|
|
3784
|
+
) : sessionsOpen ? /* @__PURE__ */ jsx6(
|
|
3785
|
+
SessionsOverlay,
|
|
3786
|
+
{
|
|
3787
|
+
sessions: sessionList,
|
|
3788
|
+
selected: Math.min(sessionCursor, Math.max(0, sessionList.length - 1)),
|
|
3789
|
+
currentId: sessionIdRef.current ?? void 0,
|
|
3790
|
+
width: layout.columns,
|
|
3791
|
+
maxRows: layout.bodyHeight,
|
|
3792
|
+
now
|
|
3793
|
+
}
|
|
3431
3794
|
) : layout.welcome ? /* @__PURE__ */ jsx6(WelcomePanel, { turn: state, width: layout.columns }) : error ? (
|
|
3432
3795
|
// The error wins over a reply: a run that stopped with an error is the
|
|
3433
3796
|
// most urgent thing on screen, and a stale answer underneath it would
|
|
@@ -3537,9 +3900,16 @@ async function startTui(options) {
|
|
|
3537
3900
|
cleanup = callback;
|
|
3538
3901
|
};
|
|
3539
3902
|
try {
|
|
3540
|
-
const app = render(
|
|
3541
|
-
|
|
3542
|
-
|
|
3903
|
+
const app = render(
|
|
3904
|
+
createElement(App, {
|
|
3905
|
+
config: options.config,
|
|
3906
|
+
onTurnStart: options.onTurnStart,
|
|
3907
|
+
initialSession: options.initialSession,
|
|
3908
|
+
sessions,
|
|
3909
|
+
onCleanupReady
|
|
3910
|
+
}),
|
|
3911
|
+
{ exitOnCtrlC: false }
|
|
3912
|
+
);
|
|
3543
3913
|
await app.waitUntilExit();
|
|
3544
3914
|
} finally {
|
|
3545
3915
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiclawcli/dashboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tui": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@multiclawcli/core": "0.
|
|
28
|
+
"@multiclawcli/core": "0.10.0",
|
|
29
29
|
"ink": "^8.0.0",
|
|
30
30
|
"react": "^19.3.1",
|
|
31
31
|
"string-width": "^8.3.0"
|