pilotswarm 0.4.1 → 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.
Files changed (89) hide show
  1. package/mcp/README.md +88 -4
  2. package/mcp/dist/src/context.d.ts +36 -6
  3. package/mcp/dist/src/context.d.ts.map +1 -1
  4. package/mcp/dist/src/context.js +102 -8
  5. package/mcp/dist/src/context.js.map +1 -1
  6. package/mcp/dist/src/resources/artifacts.d.ts +5 -0
  7. package/mcp/dist/src/resources/artifacts.d.ts.map +1 -0
  8. package/mcp/dist/src/resources/artifacts.js +39 -0
  9. package/mcp/dist/src/resources/artifacts.js.map +1 -0
  10. package/mcp/dist/src/resources/capabilities.d.ts +4 -0
  11. package/mcp/dist/src/resources/capabilities.d.ts.map +1 -0
  12. package/mcp/dist/src/resources/capabilities.js +17 -0
  13. package/mcp/dist/src/resources/capabilities.js.map +1 -0
  14. package/mcp/dist/src/resources/graph.d.ts +5 -0
  15. package/mcp/dist/src/resources/graph.d.ts.map +1 -0
  16. package/mcp/dist/src/resources/graph.js +45 -0
  17. package/mcp/dist/src/resources/graph.js.map +1 -0
  18. package/mcp/dist/src/server.d.ts.map +1 -1
  19. package/mcp/dist/src/server.js +61 -2
  20. package/mcp/dist/src/server.js.map +1 -1
  21. package/mcp/dist/src/tools/agents.d.ts.map +1 -1
  22. package/mcp/dist/src/tools/agents.js +3 -2
  23. package/mcp/dist/src/tools/agents.js.map +1 -1
  24. package/mcp/dist/src/tools/artifacts.d.ts +12 -0
  25. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -0
  26. package/mcp/dist/src/tools/artifacts.js +92 -0
  27. package/mcp/dist/src/tools/artifacts.js.map +1 -0
  28. package/mcp/dist/src/tools/capabilities.d.ts +27 -0
  29. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -0
  30. package/mcp/dist/src/tools/capabilities.js +69 -0
  31. package/mcp/dist/src/tools/capabilities.js.map +1 -0
  32. package/mcp/dist/src/tools/debug.d.ts +4 -0
  33. package/mcp/dist/src/tools/debug.d.ts.map +1 -0
  34. package/mcp/dist/src/tools/debug.js +94 -0
  35. package/mcp/dist/src/tools/debug.js.map +1 -0
  36. package/mcp/dist/src/tools/facts-enhanced.d.ts +13 -0
  37. package/mcp/dist/src/tools/facts-enhanced.d.ts.map +1 -0
  38. package/mcp/dist/src/tools/facts-enhanced.js +80 -0
  39. package/mcp/dist/src/tools/facts-enhanced.js.map +1 -0
  40. package/mcp/dist/src/tools/graph.d.ts +12 -0
  41. package/mcp/dist/src/tools/graph.d.ts.map +1 -0
  42. package/mcp/dist/src/tools/graph.js +237 -0
  43. package/mcp/dist/src/tools/graph.js.map +1 -0
  44. package/mcp/dist/src/tools/groups.d.ts +10 -0
  45. package/mcp/dist/src/tools/groups.d.ts.map +1 -0
  46. package/mcp/dist/src/tools/groups.js +107 -0
  47. package/mcp/dist/src/tools/groups.js.map +1 -0
  48. package/mcp/dist/src/tools/models.d.ts.map +1 -1
  49. package/mcp/dist/src/tools/models.js +16 -4
  50. package/mcp/dist/src/tools/models.js.map +1 -1
  51. package/mcp/dist/src/tools/observability.d.ts +13 -0
  52. package/mcp/dist/src/tools/observability.d.ts.map +1 -0
  53. package/mcp/dist/src/tools/observability.js +174 -0
  54. package/mcp/dist/src/tools/observability.js.map +1 -0
  55. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  56. package/mcp/dist/src/tools/sessions.js +120 -94
  57. package/mcp/dist/src/tools/sessions.js.map +1 -1
  58. package/mcp/dist/src/tools/system.d.ts +14 -0
  59. package/mcp/dist/src/tools/system.d.ts.map +1 -0
  60. package/mcp/dist/src/tools/system.js +110 -0
  61. package/mcp/dist/src/tools/system.js.map +1 -0
  62. package/mcp/dist/src/tools/turn-control.d.ts +13 -0
  63. package/mcp/dist/src/tools/turn-control.d.ts.map +1 -0
  64. package/mcp/dist/src/tools/turn-control.js +98 -0
  65. package/mcp/dist/src/tools/turn-control.js.map +1 -0
  66. package/mcp/dist/src/util/respond.d.ts +21 -0
  67. package/mcp/dist/src/util/respond.d.ts.map +1 -0
  68. package/mcp/dist/src/util/respond.js +42 -0
  69. package/mcp/dist/src/util/respond.js.map +1 -0
  70. package/package.json +5 -4
  71. package/tui/bin/tui.js +0 -3
  72. package/tui/src/app.js +36 -5
  73. package/tui/src/node-sdk-transport.js +24 -0
  74. package/ui/core/src/commands.js +6 -0
  75. package/ui/core/src/controller.js +137 -2
  76. package/ui/core/src/reducer.js +31 -1
  77. package/ui/core/src/selectors.js +414 -17
  78. package/ui/core/src/state.js +2 -0
  79. package/ui/react/src/chat-status.js +22 -0
  80. package/ui/react/src/components.js +68 -6
  81. package/ui/react/src/web-app.js +69 -8
  82. package/web/dist/assets/{index-xJ8IzIZY.js → index-BmqU2WsR.js} +4 -4
  83. package/web/dist/assets/index-D2-7fi56.css +1 -0
  84. package/web/dist/assets/pilotswarm-cIaYAE9e.js +90 -0
  85. package/web/dist/assets/{react-CEPDSRB6.js → react-CRYBfNR4.js} +1 -1
  86. package/web/dist/index.html +4 -4
  87. package/web/runtime.js +30 -0
  88. package/web/dist/assets/index-DmGOcKR-.css +0 -1
  89. package/web/dist/assets/pilotswarm-D9pEmenA.js +0 -90
@@ -1706,9 +1706,17 @@ function splashArtWidth(text) {
1706
1706
  function buildChatMessageLines(message, maxWidth, options = {}) {
1707
1707
  if (message?.splash) {
1708
1708
  // Swap in the narrow-viewport variant when the main art would
1709
- // overflow the pane (mobile portal, narrow terminals).
1709
+ // overflow the pane (mobile portal, narrow terminals). When the
1710
+ // width metrics keep the desktop art, still hand renderers the
1711
+ // mobile variant (splashAlt) so environments with real media
1712
+ // queries (the browser) can make the final call by CSS viewport —
1713
+ // character metrics and device width can disagree.
1710
1714
  const useMobile = message.mobileText && splashArtWidth(message.text) > maxWidth;
1711
- return [{ kind: "markup", value: useMobile ? message.mobileText : message.text }];
1715
+ return [{
1716
+ kind: "markup",
1717
+ value: useMobile ? message.mobileText : message.text,
1718
+ splashAlt: !useMobile && message.mobileText ? message.mobileText : null,
1719
+ }];
1712
1720
  }
1713
1721
 
1714
1722
  if (message?.thinking) {
@@ -1860,6 +1868,177 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
1860
1868
  return renderedLines;
1861
1869
  }
1862
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
+
1863
2042
  export function selectChatLines(state, maxWidth = 80, options = {}) {
1864
2043
  const messages = selectActiveChat(state);
1865
2044
  if (!messages || messages.length === 0) {
@@ -2012,10 +2191,19 @@ export function selectChatPaneChrome(state, options = {}) {
2012
2191
  ? null
2013
2192
  : buildLiveProgressState(session, history, history?.chat || [], outboxItems);
2014
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
+
2015
2203
  return {
2016
2204
  color: session.isSystem ? "yellow" : "cyan",
2017
2205
  title,
2018
- titleRight: buildChatProgressTitleRuns(progress),
2206
+ titleRight: titleRight.length ? titleRight : null,
2019
2207
  animateTitleRight: Boolean(progress),
2020
2208
  };
2021
2209
  }
@@ -2751,22 +2939,24 @@ export function selectStatusBar(state) {
2751
2939
  const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
2752
2940
  const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
2753
2941
  const hints = {
2754
- [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`,
2755
- [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`,
2756
2944
  [FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
2757
- ? `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`
2758
2946
  : state.ui.inspectorTab === "stats"
2759
- ? `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`
2760
2948
  : state.ui.inspectorTab === "files"
2761
2949
  ? state.files?.fullscreen
2762
- ? "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"
2763
- : "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"
2764
2952
  : state.ui.inspectorTab === "history"
2765
- ? `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`
2766
- : `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`,
2767
- [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`,
2768
2958
  [FOCUS_REGIONS.PROMPT]: hasPendingQuestion
2769
- ? `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"}`
2770
2960
  : editingPendingOutbox
2771
2961
  ? selectedQueuedOutbox
2772
2962
  ? `queued prompt selected · d delete · up/down cycle queued · enter/esc new prompt · ${paneFullscreen ? "esc pane" : "esc sessions"}`
@@ -2777,12 +2967,18 @@ export function selectStatusBar(state) {
2777
2967
  ? `type message · enter queues · enter on empty sends batch · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2778
2968
  : hasOutbox
2779
2969
  ? `type message · enter queues behind durable items · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2780
- : `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"}`,
2781
2971
  };
2782
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
+ }
2783
2979
  return {
2784
2980
  left: state.ui.statusText,
2785
- right: hints[focus] || hints[FOCUS_REGIONS.SESSIONS],
2981
+ right,
2786
2982
  };
2787
2983
  }
2788
2984
 
@@ -2848,6 +3044,15 @@ function formatCompactBytes(value) {
2848
3044
  return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;
2849
3045
  }
2850
3046
 
3047
+ /** "12.4×" compression ratio (raw / stored); null when either is missing. */
3048
+ function formatCompressionRatio(rawBytes, storedBytes) {
3049
+ const raw = Number(rawBytes);
3050
+ const stored = Number(storedBytes);
3051
+ if (!Number.isFinite(raw) || !Number.isFinite(stored) || raw <= 0 || stored <= 0) return null;
3052
+ const ratio = raw / stored;
3053
+ return `${ratio >= 10 ? ratio.toFixed(0) : ratio.toFixed(1)}×`;
3054
+ }
3055
+
2851
3056
  function summarizeEventPreview(text, maxLength = 18) {
2852
3057
  const normalized = String(text || "")
2853
3058
  .replace(/\s+/g, " ")
@@ -3338,6 +3543,77 @@ function buildNodeMapHeaderLine(nodeLabels, colWidth) {
3338
3543
  return runs;
3339
3544
  }
3340
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
+
3341
3617
  function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3342
3618
  const allowWideColumns = Boolean(options?.allowWideColumns);
3343
3619
  const statsLines = buildSequenceStatsLines(state, session, maxWidth);
@@ -3379,6 +3655,29 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3379
3655
  Math.floor((availableWidth - timeWidth - 1 - gapWidth) / Math.max(1, nodeLabels.length)),
3380
3656
  );
3381
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
+
3382
3681
  return {
3383
3682
  stickyLines: [
3384
3683
  ...statsLines,
@@ -3386,7 +3685,7 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3386
3685
  buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth),
3387
3686
  buildSequenceDividerLine(nodeLabels, timeWidth, colWidth),
3388
3687
  ],
3389
- lines: visibleEntries.map((entry) => buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth)),
3688
+ lines,
3390
3689
  };
3391
3690
  }
3392
3691
 
@@ -4463,6 +4762,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
4463
4762
  title: "Persistence",
4464
4763
  body: formatKeyValueTable([
4465
4764
  ["Snapshot", formatCompactBytes(summary.snapshotSizeBytes)],
4765
+ ["Uncompressed", summary.rawSizeBytes ? formatCompactBytes(summary.rawSizeBytes) : null],
4766
+ ["Compression", formatCompressionRatio(summary.rawSizeBytes, summary.snapshotSizeBytes)],
4466
4767
  ["Dehydrations", String(summary.dehydrationCount)],
4467
4768
  ["Hydrations", String(summary.hydrationCount)],
4468
4769
  ["Lossy Handoffs", String(summary.lossyHandoffCount)],
@@ -4492,6 +4793,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
4492
4793
  ["Cache Write", formatCompactNumber(tree.totalTokensCacheWrite)],
4493
4794
  ["Hit Ratio", treeHitLabel],
4494
4795
  ["Snapshot", formatCompactBytes(tree.totalSnapshotSizeBytes)],
4796
+ ["Uncompressed", tree.totalRawSizeBytes ? formatCompactBytes(tree.totalRawSizeBytes) : null],
4797
+ ["Compression", formatCompressionRatio(tree.totalRawSizeBytes, tree.totalSnapshotSizeBytes)],
4495
4798
  ["Dehydrations", String(tree.totalDehydrationCount)],
4496
4799
  ["Hydrations", String(tree.totalHydrationCount)],
4497
4800
  ]),
@@ -5163,7 +5466,7 @@ export function selectInspector(state, options = {}) {
5163
5466
  switch (activeTab) {
5164
5467
  case "sequence": {
5165
5468
  const sequenceView = session
5166
- ? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns })
5469
+ ? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns, sequenceExpansion: options?.sequenceExpansion || null })
5167
5470
  : { stickyLines: [], lines: ["No session selected."] };
5168
5471
  stickyLines = sequenceView.stickyLines || [];
5169
5472
  lines = sequenceView.lines;
@@ -5348,6 +5651,100 @@ function buildThemeSwatchRuns(entries = []) {
5348
5651
  return runs;
5349
5652
  }
5350
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
+
5351
5748
  export function selectThemePickerModal(state, maxWidth = 80) {
5352
5749
  const modal = state.ui.modal;
5353
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