pilotswarm 0.5.0 → 0.5.2

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 (37) hide show
  1. package/mcp/dist/src/session-id.d.ts +13 -0
  2. package/mcp/dist/src/session-id.d.ts.map +1 -0
  3. package/mcp/dist/src/session-id.js +15 -0
  4. package/mcp/dist/src/session-id.js.map +1 -0
  5. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -1
  6. package/mcp/dist/src/tools/artifacts.js +5 -4
  7. package/mcp/dist/src/tools/artifacts.js.map +1 -1
  8. package/mcp/dist/src/tools/observability.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/observability.js +2 -1
  10. package/mcp/dist/src/tools/observability.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +9 -8
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/mcp/dist/src/tools/turn-control.d.ts.map +1 -1
  15. package/mcp/dist/src/tools/turn-control.js +5 -4
  16. package/mcp/dist/src/tools/turn-control.js.map +1 -1
  17. package/package.json +3 -3
  18. package/tui/plugins/.mcp.json +1 -7
  19. package/tui/src/app.js +36 -5
  20. package/tui/src/node-sdk-transport.js +17 -0
  21. package/ui/core/src/commands.js +6 -0
  22. package/ui/core/src/controller.js +193 -10
  23. package/ui/core/src/reducer.js +118 -1
  24. package/ui/core/src/selectors.js +428 -21
  25. package/ui/core/src/state.js +17 -0
  26. package/ui/react/src/chat-status.js +22 -0
  27. package/ui/react/src/components.js +68 -6
  28. package/ui/react/src/web-app.js +67 -13
  29. package/web/dist/assets/index-BA1Gd5ZC.js +24 -0
  30. package/web/dist/assets/index-Jr_Wn0fg.css +1 -0
  31. package/web/dist/assets/pilotswarm-C6iSDexX.js +90 -0
  32. package/web/dist/assets/{react-IRUjK5Iz.js → react-CqzaLZob.js} +1 -1
  33. package/web/dist/index.html +4 -4
  34. package/web/runtime.js +23 -2
  35. package/web/dist/assets/index-B8ge3rzb.js +0 -24
  36. package/web/dist/assets/index-DXRIxmIv.css +0 -1
  37. package/web/dist/assets/pilotswarm-Dt1qY6hD.js +0 -90
@@ -1868,6 +1868,182 @@ 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
+ // Format an elapsed duration for the live activity card header (e.g. "12s",
1891
+ // "1m 05s", "1h 02m").
1892
+ function formatElapsed(ms) {
1893
+ const totalSec = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
1894
+ if (totalSec < 60) return `${totalSec}s`;
1895
+ const minutes = Math.floor(totalSec / 60);
1896
+ const seconds = totalSec % 60;
1897
+ if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1898
+ const hours = Math.floor(minutes / 60);
1899
+ const mins = minutes % 60;
1900
+ return `${hours}h ${String(mins).padStart(2, "0")}m`;
1901
+ }
1902
+
1903
+ // Fact-store, graph-store, and skill tools get first-class phases instead of
1904
+ // the generic "running tool: X". Classification is pattern-based so new tools
1905
+ // in these families inherit the right phase without a table update.
1906
+ const FACTS_READ_TOOLS = new Set(["read_facts", "facts_search", "facts_similar", "facts_read_uncrawled", "facts_tombstone_stats"]);
1907
+ const FACTS_WRITE_TOOLS = new Set(["store_fact", "delete_fact", "facts_set_crawled", "facts_purge_tombstones", "facts_force_purge"]);
1908
+ function knowledgeToolPhase(name, starting) {
1909
+ const n = String(name || "").toLowerCase();
1910
+ if (n === "search_skills" || n.includes("skill")) {
1911
+ return starting ? "loading skills\u2026" : "loaded skills";
1912
+ }
1913
+ if (FACTS_READ_TOOLS.has(n)) return starting ? "reading facts\u2026" : "read facts";
1914
+ if (FACTS_WRITE_TOOLS.has(n)) return starting ? "writing facts\u2026" : "wrote facts";
1915
+ if (n.startsWith("graph_")) {
1916
+ const writes = /upsert|delete|archive|merge|remove|set_/.test(n);
1917
+ if (writes) return starting ? "writing to the graph\u2026" : "wrote to the graph";
1918
+ return starting ? "reading the graph\u2026" : "read the graph";
1919
+ }
1920
+ if (n.startsWith("facts_") || n.endsWith("_fact") || n.endsWith("_facts")) {
1921
+ // Unrecognized fact-family tool: read/write by verb.
1922
+ const writes = /store|write|set|delete|purge|update/.test(n);
1923
+ return writes ? (starting ? "writing facts\u2026" : "wrote facts") : (starting ? "reading facts\u2026" : "read facts");
1924
+ }
1925
+ return "";
1926
+ }
1927
+
1928
+ // High-level phase for the live activity status line. Maps the newest
1929
+ // activity event to a short human description ("thinking\u2026", "running tool:
1930
+ // read_file\u2026") — never raw payloads; detail lives in the Inspector.
1931
+ function liveActivityPhaseText(item) {
1932
+ const type = String(item?.eventType || "");
1933
+ if (type === "tool.execution_start" || type === "tool.execution_complete") {
1934
+ const raw = String(item?.text || "")
1935
+ .replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
1936
+ .replace(/^\[[^\]]+\]\s*/, "")
1937
+ .replace(/^[^A-Za-z0-9_]+/, "")
1938
+ .trim();
1939
+ const name = (raw.match(/^[A-Za-z0-9_.:-]+/) || [""])[0];
1940
+ if (!name) return "running tools\u2026";
1941
+ const knowledge = knowledgeToolPhase(name, type === "tool.execution_start");
1942
+ if (knowledge) return knowledge;
1943
+ const shortName = name.length > 28 ? `${name.slice(0, 27)}\u2026` : name;
1944
+ return type === "tool.execution_start"
1945
+ ? `running tool: ${shortName}\u2026`
1946
+ : `finished tool: ${shortName}`;
1947
+ }
1948
+ switch (type) {
1949
+ case "skill.invoked": return "loading skills\u2026";
1950
+ case "learned_skill.read": return "loading skills\u2026";
1951
+ case "skills.searched": return "loading skills\u2026";
1952
+ case "facts.searched":
1953
+ case "facts.similar": return "reading facts\u2026";
1954
+ case "graph.searched":
1955
+ case "graph.node_searched":
1956
+ case "graph.node_loaded": return "reading the graph\u2026";
1957
+ case "graph.namespace_mutated": return "writing to the graph\u2026";
1958
+ case "assistant.reasoning": return "thinking\u2026";
1959
+ case "assistant.intent": return "planning\u2026";
1960
+ case "assistant.message_start":
1961
+ case "assistant.message": return "writing reply\u2026";
1962
+ case "session.compaction_start": return "compacting context\u2026";
1963
+ case "session.compaction_complete": return "context compacted";
1964
+ case "session.dehydrated": return "dehydrating\u2026";
1965
+ case "session.hydrated":
1966
+ case "session.rehydrated": return "rehydrating\u2026";
1967
+ case "session.agent_spawned": return "coordinating agents\u2026";
1968
+ case "session.wait_started": return "waiting\u2026";
1969
+ case "session.input_required_started": return "awaiting input\u2026";
1970
+ case "session.lossy_handoff": return "moving to a new worker\u2026";
1971
+ case "session.error": return "recovering from an error\u2026";
1972
+ default: return "";
1973
+ }
1974
+ }
1975
+
1976
+ // Single-line live activity status: while a turn is running, one dim line —
1977
+ // spinner + "Working" + elapsed + a high-level phase. Replaces the old
1978
+ // bordered multi-line card: hosts pin it OUTSIDE the scrolling transcript
1979
+ // (portal: bottom-sticky strip above the composer; TUI: appended below the
1980
+ // transcript, which autoscroll keeps at the bottom). Assistant messages can
1981
+ // land before the turn is actually done (streamed/tool-interleaved replies),
1982
+ // so visibility is governed by session status rather than transcript role.
1983
+ // `options.spinnerFrame` animates the marker.
1984
+ export function selectLiveActivityLines(state, options = {}) {
1985
+ if (state?.ui?.chatViewMode === "summary") return [];
1986
+ const session = selectActiveSession(state);
1987
+ if (!session || session.isGroup) return [];
1988
+ const status = String(session?.status || "").toLowerCase();
1989
+ if (status !== "running") return [];
1990
+ const history = state.history?.bySessionId?.get(session.sessionId) || null;
1991
+ // Latest user message anchors the elapsed clock when available.
1992
+ const chat = Array.isArray(history?.chat) ? history.chat : [];
1993
+ let turnStartAt = 0;
1994
+ for (let i = chat.length - 1; i >= 0; i -= 1) {
1995
+ if (chat[i]?.role === "user") {
1996
+ turnStartAt = Number(chat[i]?.createdAt || 0);
1997
+ break;
1998
+ }
1999
+ }
2000
+ const activity = Array.isArray(history?.activity) ? history.activity : [];
2001
+ // Current-turn boundaries. When a new turn starts, status flips to
2002
+ // "running" BEFORE fresh history lands; anchoring the clock on stale
2003
+ // data flashes a huge elapsed (the whole idle gap) that then snaps to
2004
+ // the real value. Anchor only on evidence belonging to THIS turn — a
2005
+ // user message or turn_start newer than the last turn end — and show
2006
+ // no timer (and no phase) until such evidence exists.
2007
+ let lastTurnStartAt = 0;
2008
+ let lastTurnEndAt = 0;
2009
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
2010
+ const item = activity[i];
2011
+ const type = item?.eventType;
2012
+ const at = Number(item?.createdAt || 0);
2013
+ if (!lastTurnStartAt && type === "assistant.turn_start") lastTurnStartAt = at;
2014
+ if (!lastTurnEndAt && (type === "assistant.turn_end" || type === "session.turn_completed")) lastTurnEndAt = at;
2015
+ if (lastTurnStartAt && lastTurnEndAt) break;
2016
+ }
2017
+ let startAt = 0;
2018
+ if (turnStartAt > lastTurnEndAt) {
2019
+ startAt = turnStartAt; // user-triggered turn
2020
+ } else if (lastTurnStartAt > lastTurnEndAt) {
2021
+ startAt = lastTurnStartAt; // cron/command-triggered turn
2022
+ }
2023
+ let latest = null;
2024
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
2025
+ const item = activity[i];
2026
+ if (!item || LIVE_ACTIVITY_SKIP.has(item.eventType)) continue;
2027
+ // Phase must come from the current turn — a stale "finished tool"
2028
+ // from the previous turn must not flash on turn start.
2029
+ if (Number(item.createdAt || 0) <= lastTurnEndAt) break;
2030
+ latest = item;
2031
+ break;
2032
+ }
2033
+ const nowMs = Number.isFinite(options.now) ? options.now : Date.now();
2034
+ const elapsedLabel = startAt > 0 ? formatElapsed(nowMs - startAt) : "";
2035
+ const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
2036
+ const phase = latest ? liveActivityPhaseText(latest) : "";
2037
+ const runs = [
2038
+ { text: `${spinner} `, color: "cyan" },
2039
+ { text: "Working", color: "cyan" },
2040
+ ...(elapsedLabel ? [{ text: ` \u00b7 ${elapsedLabel}`, color: "gray" }] : []),
2041
+ ...(phase ? [{ text: ` \u2014 ${phase}`, color: "gray" }] : []),
2042
+ ];
2043
+ const maxWidth = Math.max(24, Math.min(120, Math.floor(Number(options.maxWidth) || 80)));
2044
+ return [fitRuns(runs, maxWidth)];
2045
+ }
2046
+
1871
2047
  export function selectChatLines(state, maxWidth = 80, options = {}) {
1872
2048
  const messages = selectActiveChat(state);
1873
2049
  if (!messages || messages.length === 0) {
@@ -2020,10 +2196,19 @@ export function selectChatPaneChrome(state, options = {}) {
2020
2196
  ? null
2021
2197
  : buildLiveProgressState(session, history, history?.chat || [], outboxItems);
2022
2198
 
2199
+ // Border top-right: status · model · context (mirrors the session-row meta),
2200
+ // with the live turn-progress label appended while a turn is running.
2201
+ const mode = state.connection?.mode || "local";
2202
+ const metaRuns = buildSelectedSessionMetaRuns(session, mode);
2203
+ const progressRuns = buildChatProgressTitleRuns(progress);
2204
+ const titleRight = progressRuns
2205
+ ? [...metaRuns, ...(metaRuns.length ? [{ text: " · ", color: "gray" }] : []), ...progressRuns]
2206
+ : metaRuns;
2207
+
2023
2208
  return {
2024
2209
  color: session.isSystem ? "yellow" : "cyan",
2025
2210
  title,
2026
- titleRight: buildChatProgressTitleRuns(progress),
2211
+ titleRight: titleRight.length ? titleRight : null,
2027
2212
  animateTitleRight: Boolean(progress),
2028
2213
  };
2029
2214
  }
@@ -2555,19 +2740,34 @@ export function selectAdminConsole(state) {
2555
2740
  }
2556
2741
  : (state.auth?.principal || null);
2557
2742
 
2743
+ const systemGhcpKey = admin.systemGhcpKey || { supported: false, loading: false, configured: false, changedBy: null, changedAt: null, error: null };
2744
+ const isAdmin = Boolean(profile?.isAdmin);
2745
+ // Admin-only target switch: when on, Set/Replace/Clear act on the
2746
+ // SYSTEM user's key (ownerless system sessions run on it).
2747
+ const storeAsSystem = isAdmin && Boolean(ghcpKey.storeAsSystem);
2748
+ const targetConfigured = storeAsSystem ? Boolean(systemGhcpKey.configured) : Boolean(profile?.githubCopilotKeySet);
2749
+ const keyNoun = storeAsSystem ? "System key" : "key";
2750
+
2751
+ const systemProvenance = systemGhcpKey.configured && systemGhcpKey.changedBy
2752
+ ? ` (set by ${systemGhcpKey.changedBy})`
2753
+ : "";
2558
2754
  const ghcpStatusText = ghcpKey.saving
2559
2755
  ? "Saving..."
2560
2756
  : ghcpKey.editing
2561
2757
  ? "Editing — Enter to save, Esc to cancel"
2562
- : (profile?.githubCopilotKeySet
2563
- ? "Configured (overrides env GITHUB_TOKEN for this user)"
2564
- : "Not configured (using env GITHUB_TOKEN fallback)");
2758
+ : storeAsSystem
2759
+ ? (systemGhcpKey.configured
2760
+ ? `System key configured ownerless system sessions use it for GitHub Copilot models${systemProvenance}`
2761
+ : "System key not configured — ownerless system sessions fall back to env GITHUB_TOKEN")
2762
+ : (profile?.githubCopilotKeySet
2763
+ ? "Configured (overrides env GITHUB_TOKEN for this user)"
2764
+ : "Not configured (using env GITHUB_TOKEN fallback)");
2565
2765
 
2566
2766
  const actions = [];
2567
2767
  if (!ghcpKey.editing) {
2568
- actions.push({ id: "edit", label: profile?.githubCopilotKeySet ? "Replace key" : "Set key", key: "e" });
2569
- if (profile?.githubCopilotKeySet) {
2570
- actions.push({ id: "clear", label: "Clear key", key: "c" });
2768
+ actions.push({ id: "edit", label: targetConfigured ? `Replace ${keyNoun}` : `Set ${keyNoun}`, key: "e" });
2769
+ if (targetConfigured) {
2770
+ actions.push({ id: "clear", label: `Clear ${keyNoun}`, key: "c" });
2571
2771
  }
2572
2772
  actions.push({ id: "refresh", label: "Refresh", key: "r" });
2573
2773
  } else {
@@ -2581,8 +2781,11 @@ export function selectAdminConsole(state) {
2581
2781
  loading: Boolean(admin.loading),
2582
2782
  loadError: admin.loadError || null,
2583
2783
  principal,
2784
+ isAdmin,
2584
2785
  ghcpKey: {
2585
2786
  configured: Boolean(profile?.githubCopilotKeySet),
2787
+ targetConfigured,
2788
+ storeAsSystem,
2586
2789
  editing: Boolean(ghcpKey.editing),
2587
2790
  draft: ghcpKey.draft || "",
2588
2791
  cursorIndex: Math.max(0, Math.min(Number(ghcpKey.cursorIndex) || 0, String(ghcpKey.draft || "").length)),
@@ -2590,6 +2793,14 @@ export function selectAdminConsole(state) {
2590
2793
  error: ghcpKey.error || null,
2591
2794
  statusText: ghcpStatusText,
2592
2795
  },
2796
+ systemGhcpKey: {
2797
+ supported: Boolean(systemGhcpKey.supported),
2798
+ loading: Boolean(systemGhcpKey.loading),
2799
+ configured: Boolean(systemGhcpKey.configured),
2800
+ changedBy: systemGhcpKey.changedBy || null,
2801
+ changedAt: systemGhcpKey.changedAt || null,
2802
+ error: systemGhcpKey.error || null,
2803
+ },
2593
2804
  actions,
2594
2805
  };
2595
2806
  }
@@ -2759,22 +2970,24 @@ export function selectStatusBar(state) {
2759
2970
  const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
2760
2971
  const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
2761
2972
  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`,
2973
+ [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`,
2974
+ [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
2975
  [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`
2976
+ ? `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
2977
  : 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`
2978
+ ? `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
2979
  : state.ui.inspectorTab === "files"
2769
2980
  ? 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"
2981
+ ? "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"
2982
+ : "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
2983
  : 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`,
2984
+ ? `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`
2985
+ : state.ui.inspectorTab === "sequence"
2986
+ ? `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`
2987
+ : `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`,
2988
+ [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
2989
  [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"}`
2990
+ ? `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
2991
  : editingPendingOutbox
2779
2992
  ? selectedQueuedOutbox
2780
2993
  ? `queued prompt selected · d delete · up/down cycle queued · enter/esc new prompt · ${paneFullscreen ? "esc pane" : "esc sessions"}`
@@ -2785,12 +2998,18 @@ export function selectStatusBar(state) {
2785
2998
  ? `type message · enter queues · enter on empty sends batch · up/down recall pending · alt-enter newline · @ artifacts · @@ sessions · ${paneFullscreen ? "esc pane" : "esc sessions"}`
2786
2999
  : hasOutbox
2787
3000
  ? `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"}`,
3001
+ : `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
3002
  };
2790
3003
 
3004
+ let right = hints[focus] || hints[FOCUS_REGIONS.SESSIONS];
3005
+ // Surface the Stop-turn hint at the front (so truncation never eats it)
3006
+ // exactly while a turn is running; it stays listed, grayed, in `?` help.
3007
+ if (canStopSessionTurn(selectActiveSession(state))) {
3008
+ right = `ctrl-x stop · ${right}`;
3009
+ }
2791
3010
  return {
2792
3011
  left: state.ui.statusText,
2793
- right: hints[focus] || hints[FOCUS_REGIONS.SESSIONS],
3012
+ right,
2794
3013
  };
2795
3014
  }
2796
3015
 
@@ -3355,6 +3574,77 @@ function buildNodeMapHeaderLine(nodeLabels, colWidth) {
3355
3574
  return runs;
3356
3575
  }
3357
3576
 
3577
+ const SEQ_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "minimal", "none"]);
3578
+
3579
+ function shortModelForSequence(value) {
3580
+ const model = String(value || "").trim();
3581
+ if (!model) return "unknown";
3582
+ const parts = model.split(":").filter(Boolean);
3583
+ if (parts.length === 0) return "unknown";
3584
+ const last = String(parts[parts.length - 1]).toLowerCase();
3585
+ const index = parts.length > 1 && SEQ_REASONING_EFFORTS.has(last) ? parts.length - 2 : parts.length - 1;
3586
+ return parts[Math.max(0, index)] || parts[0] || "unknown";
3587
+ }
3588
+
3589
+ function formatTokenCountK(value) {
3590
+ const tokens = Number(value || 0);
3591
+ if (!Number.isFinite(tokens)) return "?K";
3592
+ const scaled = tokens / 1000;
3593
+ return `${scaled.toFixed(Math.abs(scaled) >= 10 ? 1 : 2)}K`;
3594
+ }
3595
+
3596
+ function formatSeqCount(value) {
3597
+ return Number(value || 0).toLocaleString("en-US");
3598
+ }
3599
+
3600
+ // Turn index -> turn_completed event data, mirroring the portal's per-turn
3601
+ // completion model so the TUI sequence tab can show the same detail.
3602
+ export function buildSequenceCompletionByTurn(events) {
3603
+ const map = new Map();
3604
+ for (const event of events || []) {
3605
+ if (event?.eventType !== "session.turn_completed") continue;
3606
+ const turn = Number(event?.data?.turnIndex ?? event?.data?.iteration);
3607
+ if (!Number.isFinite(turn)) continue;
3608
+ map.set(turn, event.data || {});
3609
+ }
3610
+ return map;
3611
+ }
3612
+
3613
+ // The magenta "Mod: ..." divider (+ optional detail lines) inserted under a
3614
+ // completed-turn row when the TUI sequence tab is showing turn expansion.
3615
+ function buildSequenceTurnLines(data, { expanded, selected, maxWidth }) {
3616
+ const caret = expanded ? "v" : ">";
3617
+ const model = shortModelForSequence(data?.model);
3618
+ const dur = data?.durationMs != null ? `${(Number(data.durationMs) / 1000).toFixed(1)}s` : "n/a";
3619
+ const dividerText = `${caret} Mod: ${model} tok ${formatTokenCountK(data?.tokensInput)}/${formatTokenCountK(data?.tokensOutput)} dur ${dur}`;
3620
+ const lines = [];
3621
+ if (selected) {
3622
+ lines.push(buildActiveHighlightLine(padDisplayText(dividerText, Math.max(18, maxWidth))));
3623
+ } else {
3624
+ lines.push([{ text: dividerText, color: "magenta" }]);
3625
+ }
3626
+ if (expanded) {
3627
+ const details = [
3628
+ ["model", data?.reasoningEffort ? `${data?.model || "(unknown)"}:${data.reasoningEffort}` : (data?.model || "(unknown)")],
3629
+ ["duration", data?.durationMs != null ? `${formatSeqCount(data.durationMs)} ms` : null],
3630
+ ["tokens", `${formatSeqCount(data?.tokensInput)} in / ${formatSeqCount(data?.tokensOutput)} out`],
3631
+ ["cache", `${formatSeqCount(data?.tokensCacheRead)} read / ${formatSeqCount(data?.tokensCacheWrite)} write`],
3632
+ ["tools", `${formatSeqCount(data?.toolCalls)} calls / ${formatSeqCount(data?.toolErrors)} errors`],
3633
+ ["names", Array.isArray(data?.toolNames) && data.toolNames.length ? data.toolNames.join(", ") : null],
3634
+ ["worker", data?.workerNodeId || null],
3635
+ ["result", data?.resultType || null],
3636
+ ["error", data?.errorMessage || null],
3637
+ ].filter(([, value]) => value != null && value !== "");
3638
+ for (const [label, value] of details) {
3639
+ lines.push([
3640
+ { text: ` ${label}: `, color: "gray" },
3641
+ { text: String(value), color: "white" },
3642
+ ]);
3643
+ }
3644
+ }
3645
+ return lines;
3646
+ }
3647
+
3358
3648
  function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3359
3649
  const allowWideColumns = Boolean(options?.allowWideColumns);
3360
3650
  const statsLines = buildSequenceStatsLines(state, session, maxWidth);
@@ -3396,6 +3686,29 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3396
3686
  Math.floor((availableWidth - timeWidth - 1 - gapWidth) / Math.max(1, nodeLabels.length)),
3397
3687
  );
3398
3688
 
3689
+ const expansion = options?.sequenceExpansion || null;
3690
+ const completionByTurn = expansion ? buildSequenceCompletionByTurn(history?.events || []) : null;
3691
+ const expandedTurns = expansion ? new Set((expansion.expandedTurns || []).map(Number)) : null;
3692
+ const selectedTurn = expansion && expansion.selectedTurn != null ? Number(expansion.selectedTurn) : null;
3693
+
3694
+ const lines = [];
3695
+ for (const entry of visibleEntries) {
3696
+ lines.push(buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth));
3697
+ if (expansion && entry.completedTurn != null) {
3698
+ const turn = Number(entry.completedTurn);
3699
+ const data = completionByTurn.get(turn);
3700
+ if (data) {
3701
+ for (const detailLine of buildSequenceTurnLines(data, {
3702
+ expanded: expandedTurns.has(turn),
3703
+ selected: selectedTurn === turn,
3704
+ maxWidth: availableWidth,
3705
+ })) {
3706
+ lines.push(detailLine);
3707
+ }
3708
+ }
3709
+ }
3710
+ }
3711
+
3399
3712
  return {
3400
3713
  stickyLines: [
3401
3714
  ...statsLines,
@@ -3403,7 +3716,7 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3403
3716
  buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth),
3404
3717
  buildSequenceDividerLine(nodeLabels, timeWidth, colWidth),
3405
3718
  ],
3406
- lines: visibleEntries.map((entry) => buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth)),
3719
+ lines,
3407
3720
  };
3408
3721
  }
3409
3722
 
@@ -5184,7 +5497,7 @@ export function selectInspector(state, options = {}) {
5184
5497
  switch (activeTab) {
5185
5498
  case "sequence": {
5186
5499
  const sequenceView = session
5187
- ? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns })
5500
+ ? buildSequenceViewForSession(state, session, maxWidth, { allowWideColumns, sequenceExpansion: options?.sequenceExpansion || null })
5188
5501
  : { stickyLines: [], lines: ["No session selected."] };
5189
5502
  stickyLines = sequenceView.stickyLines || [];
5190
5503
  lines = sequenceView.lines;
@@ -5369,6 +5682,100 @@ function buildThemeSwatchRuns(entries = []) {
5369
5682
  return runs;
5370
5683
  }
5371
5684
 
5685
+ const KEYBINDING_HELP = [
5686
+ { section: "Global", bindings: [
5687
+ ["Tab / Shift-Tab", "focus next / previous pane"],
5688
+ ["h l ← →", "focus left / right pane"],
5689
+ ["p", "focus the prompt"],
5690
+ ["[ ]", "shrink / grow the focused pane"],
5691
+ ["{ }", "grow left / right column"],
5692
+ ["v", "fullscreen the focused pane"],
5693
+ ["n / r", "new session / refresh"],
5694
+ ["a", "linked items — artifacts to download, links to open"],
5695
+ ["m", "cycle inspector tab"],
5696
+ ["c / d / D", "cancel / done / delete session"],
5697
+ ["ctrl-x (ctrl-esc)", "stop the current turn", { dim: true }],
5698
+ ["T / N / A", "theme / model / admin"],
5699
+ ["?", "toggle this help"],
5700
+ ["q", "quit (double-tap)"],
5701
+ ["Esc", "back / focus sessions"],
5702
+ ] },
5703
+ { section: "Sessions pane", bindings: [
5704
+ ["j k ↑ ↓", "move selection"],
5705
+ ["ctrl-u / ctrl-d", "page up / down"],
5706
+ ["ctrl-g", "move to group"],
5707
+ ["+ / -", "expand / collapse subtree"],
5708
+ ["t", "rename"],
5709
+ ["P", "pin / unpin"],
5710
+ ["V / space", "select mode / toggle selection"],
5711
+ ["f", "filter"],
5712
+ ] },
5713
+ { section: "Chat / transcript", bindings: [
5714
+ ["s", "toggle transcript / summary"],
5715
+ ["j k ↑ ↓", "scroll"],
5716
+ ["ctrl-u / ctrl-d", "page"],
5717
+ ["e", "expand older history"],
5718
+ ["g / G", "top / bottom"],
5719
+ ] },
5720
+ { section: "Inspector pane", bindings: [
5721
+ ["← → m", "previous / next / cycle tab"],
5722
+ ["j k", "scroll"],
5723
+ ["enter", "expand / collapse a turn (Sequence tab)"],
5724
+ ["logs", "t tail · f filter"],
5725
+ ["stats", "f cycle session/fleet/users"],
5726
+ ["files", "a download · x delete · u upload · o open · f filter · v full"],
5727
+ ["history", "r refresh · a export · f format"],
5728
+ ] },
5729
+ { section: "Activity pane", bindings: [
5730
+ ["j k ↑ ↓", "scroll"],
5731
+ ["g / G", "top / bottom"],
5732
+ ] },
5733
+ { section: "Prompt", bindings: [
5734
+ ["enter", "send"],
5735
+ ["alt/ctrl-j", "newline"],
5736
+ ["ctrl-a", "attach artifact"],
5737
+ ["@ / @@", "artifact / session reference"],
5738
+ ] },
5739
+ { section: "Overlays", bindings: [
5740
+ ["Esc / q", "close"],
5741
+ ["enter", "confirm / apply"],
5742
+ ["j k ↑ ↓", "navigate"],
5743
+ ["Tab", "switch modal pane"],
5744
+ ] },
5745
+ ];
5746
+
5747
+ export function buildHelpModalRows() {
5748
+ const rows = [];
5749
+ const keysWidth = 18;
5750
+ for (const group of KEYBINDING_HELP) {
5751
+ if (rows.length > 0) rows.push([{ text: "", color: "gray" }]);
5752
+ rows.push([{ text: group.section, color: "cyan", bold: true }]);
5753
+ for (const [keys, desc, opts] of group.bindings) {
5754
+ const dim = Boolean(opts?.dim);
5755
+ rows.push([
5756
+ { text: " " + String(keys).padEnd(keysWidth), color: dim ? "gray" : "yellow" },
5757
+ { text: String(desc), color: dim ? "gray" : "white" },
5758
+ ]);
5759
+ }
5760
+ }
5761
+ return rows;
5762
+ }
5763
+
5764
+ // TUI-only help overlay (the portal relies on tooltips). Reuses the single
5765
+ // modal slot; opened with `?` and dismissed with `?`/Esc.
5766
+ export function selectHelpModal(state, maxWidth = 88) {
5767
+ const modal = state.ui?.modal;
5768
+ if (!modal || modal.type !== "help") return null;
5769
+ const rows = buildHelpModalRows();
5770
+ const selectedRowIndex = Math.max(0, Math.min(Number(modal.selectedIndex) || 0, rows.length - 1));
5771
+ return {
5772
+ title: "Keybindings — ? or Esc to close",
5773
+ idealWidth: Math.max(64, Math.min(maxWidth, 88)),
5774
+ rows,
5775
+ selectedRowIndex,
5776
+ };
5777
+ }
5778
+
5372
5779
  export function selectThemePickerModal(state, maxWidth = 80) {
5373
5780
  const modal = state.ui.modal;
5374
5781
  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: "",
@@ -245,6 +247,21 @@ export function createInitialState({ mode = "local", branding = null, themeId =
245
247
  saving: false,
246
248
  error: null,
247
249
  lastSavedAt: 0,
250
+ // Admin-only target switch: when true, Set/Replace/Clear
251
+ // act on the SYSTEM user's key (used by ownerless system
252
+ // sessions) instead of the caller's own key.
253
+ storeAsSystem: false,
254
+ },
255
+ // Status of the SYSTEM user's Copilot key (admins only; never
256
+ // the key itself). `supported` flips on the first successful
257
+ // status load so non-admin/legacy transports hide the toggle.
258
+ systemGhcpKey: {
259
+ supported: false,
260
+ loading: false,
261
+ configured: false,
262
+ changedBy: null,
263
+ changedAt: null,
264
+ error: null,
248
265
  },
249
266
  },
250
267
  };
@@ -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