pilotswarm 0.5.0 → 0.5.1

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.
@@ -1868,6 +1868,177 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
1868
1868
  return renderedLines;
1869
1869
  }
1870
1870
 
1871
+ // Activity-log event types that are not surfaced as live "recent action" lines
1872
+ // in the chat pane: bookkeeping, high-frequency streaming noise, and the turn
1873
+ // bracket markers (which don't read well as prose).
1874
+ const LIVE_ACTIVITY_SKIP = new Set([
1875
+ "assistant.usage",
1876
+ "session.info",
1877
+ "session.idle",
1878
+ "session.usage_info",
1879
+ "pending_messages.modified",
1880
+ "pending_messages.cancelled",
1881
+ "abort",
1882
+ "assistant.turn_start",
1883
+ "assistant.turn_end",
1884
+ "assistant.streaming_progress",
1885
+ "session.turn_completed",
1886
+ "tool.execution_partial_result",
1887
+ "tool.execution_progress",
1888
+ ]);
1889
+
1890
+ // Consecutive events of these types collapse into a single line (streaming
1891
+ // reasoning/intent fires many events per turn).
1892
+ const LIVE_ACTIVITY_COLLAPSE = new Set(["assistant.reasoning", "assistant.intent"]);
1893
+
1894
+ // Friendly verbs for the live activity lines, keyed by event type. An empty
1895
+ // string means "show the detail as-is" (e.g. tool calls, where the tool name is
1896
+ // the action). Types absent from the map fall back to their cleaned detail.
1897
+ const LIVE_ACTIVITY_VERBS = {
1898
+ "tool.execution_start": "",
1899
+ "tool.execution_complete": "",
1900
+ "assistant.reasoning": "Thinking",
1901
+ "assistant.intent": "Planning",
1902
+ "session.agent_spawned": "Spawning sub-agent",
1903
+ "session.wait_started": "Waiting",
1904
+ "session.input_required_started": "Awaiting input",
1905
+ "session.command_received": "Command",
1906
+ "session.command_completed": "Command",
1907
+ "session.compaction_start": "Compacting context",
1908
+ "session.compaction_complete": "Context compacted",
1909
+ "session.dehydrated": "Dehydrating",
1910
+ "session.hydrated": "Rehydrated",
1911
+ "session.rehydrated": "Rehydrated",
1912
+ "session.lossy_handoff": "Handed off to a new worker",
1913
+ "session.error": "Error",
1914
+ "session.cron_started": "Scheduled",
1915
+ "session.cron_scheduled": "Scheduled",
1916
+ "session.cron_fired": "Woke on schedule",
1917
+ "session.cron_at_scheduled": "Scheduled",
1918
+ "session.cron_at_started": "Scheduled",
1919
+ "session.cron_at_fired": "Woke on schedule",
1920
+ "system.message": "",
1921
+ };
1922
+
1923
+ // Turn a raw activity item into a friendly one-line action label: strip the
1924
+ // timestamp and [bracket] tag, then prefix a human verb where one applies.
1925
+ function friendlyLiveActivity(item, maxLen = 96) {
1926
+ const raw = String(item?.text || "")
1927
+ .replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
1928
+ .replace(/^\[[^\]]+\]\s*/, "")
1929
+ .replace(/\s+/g, " ")
1930
+ .trim();
1931
+ const verb = LIVE_ACTIVITY_VERBS[item?.eventType];
1932
+ let text;
1933
+ if (verb === undefined || verb === "") {
1934
+ text = raw;
1935
+ } else if (raw) {
1936
+ text = `${verb} \u2014 ${raw}`;
1937
+ } else {
1938
+ text = verb;
1939
+ }
1940
+ text = text || "Working";
1941
+ return text.length > maxLen ? `${text.slice(0, maxLen - 1)}\u2026` : text;
1942
+ }
1943
+
1944
+ // Format an elapsed duration for the live activity card header (e.g. "12s",
1945
+ // "1m 05s", "1h 02m").
1946
+ function formatElapsed(ms) {
1947
+ const totalSec = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
1948
+ if (totalSec < 60) return `${totalSec}s`;
1949
+ const minutes = Math.floor(totalSec / 60);
1950
+ const seconds = totalSec % 60;
1951
+ if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1952
+ const hours = Math.floor(minutes / 60);
1953
+ const mins = minutes % 60;
1954
+ return `${hours}h ${String(mins).padStart(2, "0")}m`;
1955
+ }
1956
+
1957
+ // Claude-style live activity: while a turn is running, surface the last few
1958
+ // actions as a short text scrollback. Assistant messages can land before the
1959
+ // turn is actually done (streamed/tool-interleaved replies), so the card is
1960
+ // governed by session status rather than by the newest transcript role.
1961
+ // `options.spinnerFrame` animates the active (latest) line's marker;
1962
+ // `options.maxActions` caps the line count.
1963
+ export function selectLiveActivityLines(state, options = {}) {
1964
+ if (state?.ui?.chatViewMode === "summary") return [];
1965
+ const session = selectActiveSession(state);
1966
+ if (!session || session.isGroup) return [];
1967
+ const status = String(session?.status || "").toLowerCase();
1968
+ if (status !== "running") return [];
1969
+ const history = state.history?.bySessionId?.get(session.sessionId) || null;
1970
+ // Use the latest user message as the elapsed-time anchor when available,
1971
+ // but do not hide the card just because assistant text has already landed;
1972
+ // the session may still be running tools or follow-up model work.
1973
+ const chat = Array.isArray(history?.chat) ? history.chat : [];
1974
+ let turnStartAt = 0;
1975
+ for (let i = chat.length - 1; i >= 0; i -= 1) {
1976
+ const role = chat[i]?.role;
1977
+ if (role === "user") {
1978
+ turnStartAt = Number(chat[i]?.createdAt || 0);
1979
+ break;
1980
+ }
1981
+ }
1982
+ const activity = Array.isArray(history?.activity) ? history.activity : [];
1983
+ const maxActions = Number.isFinite(options.maxActions) ? options.maxActions : 3;
1984
+ const recent = [];
1985
+ let acceptedType = null;
1986
+ let oldestGatheredAt = 0;
1987
+ for (let i = activity.length - 1; i >= 0 && recent.length < maxActions; i -= 1) {
1988
+ const item = activity[i];
1989
+ if (!item || LIVE_ACTIVITY_SKIP.has(item.eventType)) continue;
1990
+ if (item.eventType === acceptedType && LIVE_ACTIVITY_COLLAPSE.has(item.eventType)) continue;
1991
+ const detail = friendlyLiveActivity(item);
1992
+ if (!detail) continue;
1993
+ recent.unshift(detail);
1994
+ acceptedType = item.eventType;
1995
+ const at = Number(item.createdAt || 0);
1996
+ if (at > 0) oldestGatheredAt = oldestGatheredAt ? Math.min(oldestGatheredAt, at) : at;
1997
+ }
1998
+ if (recent.length === 0) return [];
1999
+ // Render as a bordered card: the portal turns box-drawing lines whose top
2000
+ // row has >2 runs into a `ps-chat-card`; the terminal shows the box art.
2001
+ // Header carries the animated spinner + "Working"; body lists the recent
2002
+ // actions, most recent highlighted.
2003
+ const borderColor = "cyan";
2004
+ const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
2005
+ const nowMs = Number.isFinite(options.now) ? options.now : Date.now();
2006
+ const startAt = turnStartAt || oldestGatheredAt;
2007
+ const elapsedLabel = startAt > 0 ? formatElapsed(nowMs - startAt) : "";
2008
+ const titleRuns = [
2009
+ { text: ` ${spinner} Working `, color: borderColor, bold: true },
2010
+ ...(elapsedLabel ? [{ text: `\u00b7 ${elapsedLabel} `, color: "gray" }] : []),
2011
+ ];
2012
+ const titleWidth = titleRuns.reduce((sum, run) => sum + String(run.text || "").length, 0);
2013
+ const maxWidth = Math.max(24, Math.min(96, Math.floor(Number(options.maxWidth) || 64)));
2014
+ const widestBody = recent.reduce((max, detail) => Math.max(max, detail.length + 2), 0);
2015
+ const cardWidth = Math.max(20, Math.min(maxWidth, Math.max(titleWidth + 3, widestBody + 4)));
2016
+ const contentWidth = Math.max(1, cardWidth - 4);
2017
+ const bodyLines = recent.map((detail, index) => {
2018
+ const isLatest = index === recent.length - 1;
2019
+ return fitRuns([
2020
+ { text: isLatest ? "\u25cf " : "\u00b7 ", color: isLatest ? "cyan" : "gray", bold: isLatest },
2021
+ { text: detail, color: isLatest ? "cyan" : "gray" },
2022
+ ], contentWidth);
2023
+ });
2024
+ const topFill = Math.max(0, cardWidth - titleWidth - 3);
2025
+ const card = [[
2026
+ { text: "\u250c\u2500", color: borderColor },
2027
+ ...titleRuns,
2028
+ { text: `${"\u2500".repeat(topFill)}\u2510`, color: borderColor },
2029
+ ]];
2030
+ for (const runs of bodyLines) {
2031
+ card.push([
2032
+ { text: "\u2502 ", color: borderColor },
2033
+ ...padRunsToDisplayWidth(runs, contentWidth),
2034
+ { text: " \u2502", color: borderColor },
2035
+ ]);
2036
+ }
2037
+ card.push([{ text: `\u2514${"\u2500".repeat(Math.max(1, cardWidth - 2))}\u2518`, color: borderColor }]);
2038
+ card.push([{ text: "", color: null }]);
2039
+ return card;
2040
+ }
2041
+
1871
2042
  export function selectChatLines(state, maxWidth = 80, options = {}) {
1872
2043
  const messages = selectActiveChat(state);
1873
2044
  if (!messages || messages.length === 0) {
@@ -2020,10 +2191,19 @@ export function selectChatPaneChrome(state, options = {}) {
2020
2191
  ? null
2021
2192
  : buildLiveProgressState(session, history, history?.chat || [], outboxItems);
2022
2193
 
2194
+ // Border top-right: status · model · context (mirrors the session-row meta),
2195
+ // with the live turn-progress label appended while a turn is running.
2196
+ const mode = state.connection?.mode || "local";
2197
+ const metaRuns = buildSelectedSessionMetaRuns(session, mode);
2198
+ const progressRuns = buildChatProgressTitleRuns(progress);
2199
+ const titleRight = progressRuns
2200
+ ? [...metaRuns, ...(metaRuns.length ? [{ text: " · ", color: "gray" }] : []), ...progressRuns]
2201
+ : metaRuns;
2202
+
2023
2203
  return {
2024
2204
  color: session.isSystem ? "yellow" : "cyan",
2025
2205
  title,
2026
- titleRight: buildChatProgressTitleRuns(progress),
2206
+ titleRight: titleRight.length ? titleRight : null,
2027
2207
  animateTitleRight: Boolean(progress),
2028
2208
  };
2029
2209
  }
@@ -2759,22 +2939,24 @@ export function selectStatusBar(state) {
2759
2939
  const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
2760
2940
  const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
2761
2941
  const hints = {
2762
- [FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · ${fullscreenHint} · {/} session pane · [/] side pane · T themes · a linked items · drag copy · tab next pane · p prompt`,
2763
- [FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · {/} session pane · [/] side pane · T themes · a linked items · drag copy · tab next pane · p prompt`,
2942
+ [FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
2943
+ [FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
2764
2944
  [FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
2765
- ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/] side pane · T themes · a linked items · drag copy · tab next pane`
2945
+ ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane`
2766
2946
  : state.ui.inspectorTab === "stats"
2767
- ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f cycle session/fleet/users · d done · ${fullscreenHint} · left/right tab · [/] side pane · T themes · m next tab · tab next pane`
2947
+ ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f cycle session/fleet/users · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · m next tab · tab next pane`
2768
2948
  : state.ui.inspectorTab === "files"
2769
2949
  ? state.files?.fullscreen
2770
- ? "a download · x delete · u/ctrl-a upload · o open · f filter · j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · v/esc close fullscreen · left/right tab · {/} session pane · [/] side pane · T themes · tab next pane"
2771
- : "j/k files · a download · x delete · u/ctrl-a upload · o open · f filter · ctrl-u/ctrl-d page preview · g/G preview top/bottom · d done · v fullscreen · left/right tab · {/} session pane · [/] side pane · T themes · tab next pane"
2950
+ ? "a download · x delete · u/ctrl-a upload · o open · f filter · j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · v/esc close fullscreen · left/right tab · [/] resize pane · {/} columns · T themes · ? help · tab next pane"
2951
+ : "j/k files · a download · x delete · u/ctrl-a upload · o open · f filter · ctrl-u/ctrl-d page preview · g/G preview top/bottom · d done · v fullscreen · left/right tab · [/] resize pane · {/} columns · T themes · ? help · tab next pane"
2772
2952
  : state.ui.inspectorTab === "history"
2773
- ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f format · r refresh · a save artifact · d done · ${fullscreenHint} · left/right tab · [/] side pane · T themes · m next tab · tab next pane`
2774
- : `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · left/right tab · [/] side pane · T themes · h/l focus · a linked items · drag copy · m next tab · tab next pane`,
2775
- [FOCUS_REGIONS.ACTIVITY]: `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · {/} session pane · [/] side pane · T themes · a linked items · drag copy · h left · tab next pane`,
2953
+ ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · f format · r refresh · a save artifact · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · m next tab · tab next pane`
2954
+ : state.ui.inspectorTab === "sequence"
2955
+ ? `j/k turn · enter expand · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · m next tab · tab next pane`
2956
+ : `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · h/l focus · a linked items · drag copy · m next tab · tab next pane`,
2957
+ [FOCUS_REGIONS.ACTIVITY]: `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · h left · tab next pane`,
2776
2958
  [FOCUS_REGIONS.PROMPT]: hasPendingQuestion
2777
- ? `type answer · enter reply · alt-enter newline · T themes · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2959
+ ? `type answer · enter reply · alt-enter newline · T themes · ? help · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2778
2960
  : editingPendingOutbox
2779
2961
  ? selectedQueuedOutbox
2780
2962
  ? `queued prompt selected · d delete · up/down cycle queued · enter/esc new prompt · ${paneFullscreen ? "esc pane" : "esc sessions"}`
@@ -2785,12 +2967,18 @@ export function selectStatusBar(state) {
2785
2967
  ? `type message · enter queues · enter on empty sends batch · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2786
2968
  : hasOutbox
2787
2969
  ? `type message · enter queues behind durable items · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2788
- : `type message · enter send · alt-enter newline · T themes · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`,
2970
+ : `type message · enter send · alt-enter newline · T themes · ? help · arrows move · alt-left/right word · alt-delete word · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`,
2789
2971
  };
2790
2972
 
2973
+ let right = hints[focus] || hints[FOCUS_REGIONS.SESSIONS];
2974
+ // Surface the Stop-turn hint at the front (so truncation never eats it)
2975
+ // exactly while a turn is running; it stays listed, grayed, in `?` help.
2976
+ if (canStopSessionTurn(selectActiveSession(state))) {
2977
+ right = `ctrl-x stop · ${right}`;
2978
+ }
2791
2979
  return {
2792
2980
  left: state.ui.statusText,
2793
- right: hints[focus] || hints[FOCUS_REGIONS.SESSIONS],
2981
+ right,
2794
2982
  };
2795
2983
  }
2796
2984
 
@@ -3355,6 +3543,77 @@ function buildNodeMapHeaderLine(nodeLabels, colWidth) {
3355
3543
  return runs;
3356
3544
  }
3357
3545
 
3546
+ const SEQ_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "minimal", "none"]);
3547
+
3548
+ function shortModelForSequence(value) {
3549
+ const model = String(value || "").trim();
3550
+ if (!model) return "unknown";
3551
+ const parts = model.split(":").filter(Boolean);
3552
+ if (parts.length === 0) return "unknown";
3553
+ const last = String(parts[parts.length - 1]).toLowerCase();
3554
+ const index = parts.length > 1 && SEQ_REASONING_EFFORTS.has(last) ? parts.length - 2 : parts.length - 1;
3555
+ return parts[Math.max(0, index)] || parts[0] || "unknown";
3556
+ }
3557
+
3558
+ function formatTokenCountK(value) {
3559
+ const tokens = Number(value || 0);
3560
+ if (!Number.isFinite(tokens)) return "?K";
3561
+ const scaled = tokens / 1000;
3562
+ return `${scaled.toFixed(Math.abs(scaled) >= 10 ? 1 : 2)}K`;
3563
+ }
3564
+
3565
+ function formatSeqCount(value) {
3566
+ return Number(value || 0).toLocaleString("en-US");
3567
+ }
3568
+
3569
+ // Turn index -> turn_completed event data, mirroring the portal's per-turn
3570
+ // completion model so the TUI sequence tab can show the same detail.
3571
+ export function buildSequenceCompletionByTurn(events) {
3572
+ const map = new Map();
3573
+ for (const event of events || []) {
3574
+ if (event?.eventType !== "session.turn_completed") continue;
3575
+ const turn = Number(event?.data?.turnIndex ?? event?.data?.iteration);
3576
+ if (!Number.isFinite(turn)) continue;
3577
+ map.set(turn, event.data || {});
3578
+ }
3579
+ return map;
3580
+ }
3581
+
3582
+ // The magenta "Mod: ..." divider (+ optional detail lines) inserted under a
3583
+ // completed-turn row when the TUI sequence tab is showing turn expansion.
3584
+ function buildSequenceTurnLines(data, { expanded, selected, maxWidth }) {
3585
+ const caret = expanded ? "v" : ">";
3586
+ const model = shortModelForSequence(data?.model);
3587
+ const dur = data?.durationMs != null ? `${(Number(data.durationMs) / 1000).toFixed(1)}s` : "n/a";
3588
+ const dividerText = `${caret} Mod: ${model} tok ${formatTokenCountK(data?.tokensInput)}/${formatTokenCountK(data?.tokensOutput)} dur ${dur}`;
3589
+ const lines = [];
3590
+ if (selected) {
3591
+ lines.push(buildActiveHighlightLine(padDisplayText(dividerText, Math.max(18, maxWidth))));
3592
+ } else {
3593
+ lines.push([{ text: dividerText, color: "magenta" }]);
3594
+ }
3595
+ if (expanded) {
3596
+ const details = [
3597
+ ["model", data?.reasoningEffort ? `${data?.model || "(unknown)"}:${data.reasoningEffort}` : (data?.model || "(unknown)")],
3598
+ ["duration", data?.durationMs != null ? `${formatSeqCount(data.durationMs)} ms` : null],
3599
+ ["tokens", `${formatSeqCount(data?.tokensInput)} in / ${formatSeqCount(data?.tokensOutput)} out`],
3600
+ ["cache", `${formatSeqCount(data?.tokensCacheRead)} read / ${formatSeqCount(data?.tokensCacheWrite)} write`],
3601
+ ["tools", `${formatSeqCount(data?.toolCalls)} calls / ${formatSeqCount(data?.toolErrors)} errors`],
3602
+ ["names", Array.isArray(data?.toolNames) && data.toolNames.length ? data.toolNames.join(", ") : null],
3603
+ ["worker", data?.workerNodeId || null],
3604
+ ["result", data?.resultType || null],
3605
+ ["error", data?.errorMessage || null],
3606
+ ].filter(([, value]) => value != null && value !== "");
3607
+ for (const [label, value] of details) {
3608
+ lines.push([
3609
+ { text: ` ${label}: `, color: "gray" },
3610
+ { text: String(value), color: "white" },
3611
+ ]);
3612
+ }
3613
+ }
3614
+ return lines;
3615
+ }
3616
+
3358
3617
  function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3359
3618
  const allowWideColumns = Boolean(options?.allowWideColumns);
3360
3619
  const statsLines = buildSequenceStatsLines(state, session, maxWidth);
@@ -3396,6 +3655,29 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3396
3655
  Math.floor((availableWidth - timeWidth - 1 - gapWidth) / Math.max(1, nodeLabels.length)),
3397
3656
  );
3398
3657
 
3658
+ const expansion = options?.sequenceExpansion || null;
3659
+ const completionByTurn = expansion ? buildSequenceCompletionByTurn(history?.events || []) : null;
3660
+ const expandedTurns = expansion ? new Set((expansion.expandedTurns || []).map(Number)) : null;
3661
+ const selectedTurn = expansion && expansion.selectedTurn != null ? Number(expansion.selectedTurn) : null;
3662
+
3663
+ const lines = [];
3664
+ for (const entry of visibleEntries) {
3665
+ lines.push(buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth));
3666
+ if (expansion && entry.completedTurn != null) {
3667
+ const turn = Number(entry.completedTurn);
3668
+ const data = completionByTurn.get(turn);
3669
+ if (data) {
3670
+ for (const detailLine of buildSequenceTurnLines(data, {
3671
+ expanded: expandedTurns.has(turn),
3672
+ selected: selectedTurn === turn,
3673
+ maxWidth: availableWidth,
3674
+ })) {
3675
+ lines.push(detailLine);
3676
+ }
3677
+ }
3678
+ }
3679
+ }
3680
+
3399
3681
  return {
3400
3682
  stickyLines: [
3401
3683
  ...statsLines,
@@ -3403,7 +3685,7 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3403
3685
  buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth),
3404
3686
  buildSequenceDividerLine(nodeLabels, timeWidth, colWidth),
3405
3687
  ],
3406
- lines: visibleEntries.map((entry) => buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth)),
3688
+ lines,
3407
3689
  };
3408
3690
  }
3409
3691
 
@@ -5184,7 +5466,7 @@ export function selectInspector(state, options = {}) {
5184
5466
  switch (activeTab) {
5185
5467
  case "sequence": {
5186
5468
  const sequenceView = session
5187
- ? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns })
5469
+ ? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns, sequenceExpansion: options?.sequenceExpansion || null })
5188
5470
  : { stickyLines: [], lines: ["No session selected."] };
5189
5471
  stickyLines = sequenceView.stickyLines || [];
5190
5472
  lines = sequenceView.lines;
@@ -5369,6 +5651,100 @@ function buildThemeSwatchRuns(entries = []) {
5369
5651
  return runs;
5370
5652
  }
5371
5653
 
5654
+ const KEYBINDING_HELP = [
5655
+ { section: "Global", bindings: [
5656
+ ["Tab / Shift-Tab", "focus next / previous pane"],
5657
+ ["h l ← →", "focus left / right pane"],
5658
+ ["p", "focus the prompt"],
5659
+ ["[ ]", "shrink / grow the focused pane"],
5660
+ ["{ }", "grow left / right column"],
5661
+ ["v", "fullscreen the focused pane"],
5662
+ ["n / r", "new session / refresh"],
5663
+ ["a", "linked items — artifacts to download, links to open"],
5664
+ ["m", "cycle inspector tab"],
5665
+ ["c / d / D", "cancel / done / delete session"],
5666
+ ["ctrl-x (ctrl-esc)", "stop the current turn", { dim: true }],
5667
+ ["T / N / A", "theme / model / admin"],
5668
+ ["?", "toggle this help"],
5669
+ ["q", "quit (double-tap)"],
5670
+ ["Esc", "back / focus sessions"],
5671
+ ] },
5672
+ { section: "Sessions pane", bindings: [
5673
+ ["j k ↑ ↓", "move selection"],
5674
+ ["ctrl-u / ctrl-d", "page up / down"],
5675
+ ["ctrl-g", "move to group"],
5676
+ ["+ / -", "expand / collapse subtree"],
5677
+ ["t", "rename"],
5678
+ ["P", "pin / unpin"],
5679
+ ["V / space", "select mode / toggle selection"],
5680
+ ["f", "filter"],
5681
+ ] },
5682
+ { section: "Chat / transcript", bindings: [
5683
+ ["s", "toggle transcript / summary"],
5684
+ ["j k ↑ ↓", "scroll"],
5685
+ ["ctrl-u / ctrl-d", "page"],
5686
+ ["e", "expand older history"],
5687
+ ["g / G", "top / bottom"],
5688
+ ] },
5689
+ { section: "Inspector pane", bindings: [
5690
+ ["← → m", "previous / next / cycle tab"],
5691
+ ["j k", "scroll"],
5692
+ ["enter", "expand / collapse a turn (Sequence tab)"],
5693
+ ["logs", "t tail · f filter"],
5694
+ ["stats", "f cycle session/fleet/users"],
5695
+ ["files", "a download · x delete · u upload · o open · f filter · v full"],
5696
+ ["history", "r refresh · a export · f format"],
5697
+ ] },
5698
+ { section: "Activity pane", bindings: [
5699
+ ["j k ↑ ↓", "scroll"],
5700
+ ["g / G", "top / bottom"],
5701
+ ] },
5702
+ { section: "Prompt", bindings: [
5703
+ ["enter", "send"],
5704
+ ["alt/ctrl-j", "newline"],
5705
+ ["ctrl-a", "attach artifact"],
5706
+ ["@ / @@", "artifact / session reference"],
5707
+ ] },
5708
+ { section: "Overlays", bindings: [
5709
+ ["Esc / q", "close"],
5710
+ ["enter", "confirm / apply"],
5711
+ ["j k ↑ ↓", "navigate"],
5712
+ ["Tab", "switch modal pane"],
5713
+ ] },
5714
+ ];
5715
+
5716
+ export function buildHelpModalRows() {
5717
+ const rows = [];
5718
+ const keysWidth = 18;
5719
+ for (const group of KEYBINDING_HELP) {
5720
+ if (rows.length > 0) rows.push([{ text: "", color: "gray" }]);
5721
+ rows.push([{ text: group.section, color: "cyan", bold: true }]);
5722
+ for (const [keys, desc, opts] of group.bindings) {
5723
+ const dim = Boolean(opts?.dim);
5724
+ rows.push([
5725
+ { text: " " + String(keys).padEnd(keysWidth), color: dim ? "gray" : "yellow" },
5726
+ { text: String(desc), color: dim ? "gray" : "white" },
5727
+ ]);
5728
+ }
5729
+ }
5730
+ return rows;
5731
+ }
5732
+
5733
+ // TUI-only help overlay (the portal relies on tooltips). Reuses the single
5734
+ // modal slot; opened with `?` and dismissed with `?`/Esc.
5735
+ export function selectHelpModal(state, maxWidth = 88) {
5736
+ const modal = state.ui?.modal;
5737
+ if (!modal || modal.type !== "help") return null;
5738
+ const rows = buildHelpModalRows();
5739
+ const selectedRowIndex = Math.max(0, Math.min(Number(modal.selectedIndex) || 0, rows.length - 1));
5740
+ return {
5741
+ title: "Keybindings — ? or Esc to close",
5742
+ idealWidth: Math.max(64, Math.min(maxWidth, 88)),
5743
+ rows,
5744
+ selectedRowIndex,
5745
+ };
5746
+ }
5747
+
5372
5748
  export function selectThemePickerModal(state, maxWidth = 80) {
5373
5749
  const modal = state.ui.modal;
5374
5750
  if (!modal || modal.type !== "themePicker") return null;
@@ -126,6 +126,8 @@ export function createInitialState({ mode = "local", branding = null, themeId =
126
126
  ui: {
127
127
  focusRegion: FOCUS_REGIONS.SESSIONS,
128
128
  inspectorTab: INSPECTOR_TABS[0],
129
+ sequenceExpandedTurns: [],
130
+ sequenceSelectedTurn: null,
129
131
  chatViewMode: chatViewMode === "summary" ? "summary" : "transcript",
130
132
  statsViewMode: "session",
131
133
  prompt: "",
@@ -2,6 +2,28 @@ import React from "react";
2
2
 
3
3
  const DOT_FRAMES = [".\u00a0\u00a0", "..\u00a0", "..."];
4
4
 
5
+ const SPINNER_FRAMES = ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"];
6
+
7
+ // Braille spinner frame that advances while `active`, for the live activity
8
+ // line's leading marker. Returns "" when inactive so callers can fall back to a
9
+ // static glyph.
10
+ export function useSpinnerFrame(active, intervalMs = 120) {
11
+ const [frameIndex, setFrameIndex] = React.useState(0);
12
+
13
+ React.useEffect(() => {
14
+ if (!active) {
15
+ setFrameIndex(0);
16
+ return undefined;
17
+ }
18
+ const timer = setInterval(() => {
19
+ setFrameIndex((current) => (current + 1) % SPINNER_FRAMES.length);
20
+ }, intervalMs);
21
+ return () => clearInterval(timer);
22
+ }, [active, intervalMs]);
23
+
24
+ return active ? SPINNER_FRAMES[frameIndex] : "";
25
+ }
26
+
5
27
  export function useAnimatedDots(active, intervalMs = 360) {
6
28
  const [frameIndex, setFrameIndex] = React.useState(0);
7
29