pilotswarm 0.5.32 → 0.5.34

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 (98) hide show
  1. package/package.json +2 -2
  2. package/tui/src/app.js +10 -0
  3. package/tui/src/node-sdk-transport.js +11 -0
  4. package/ui/core/src/controller.js +381 -12
  5. package/ui/core/src/reducer.js +125 -28
  6. package/ui/core/src/selectors.js +158 -268
  7. package/ui/core/src/state.js +6 -1
  8. package/ui/core/src/themes/catppuccin-mocha.js +2 -1
  9. package/ui/core/src/themes/cobalt2.js +2 -1
  10. package/ui/core/src/themes/doom.js +2 -1
  11. package/ui/core/src/themes/dracula.js +2 -1
  12. package/ui/core/src/themes/github-dark.js +2 -1
  13. package/ui/core/src/themes/github-light.js +2 -1
  14. package/ui/core/src/themes/gruvbox-dark.js +2 -1
  15. package/ui/core/src/themes/hacker-x-matrix.js +2 -1
  16. package/ui/core/src/themes/hacker-x-orion-prime.js +2 -1
  17. package/ui/core/src/themes/helpers.js +22 -4
  18. package/ui/core/src/themes/high-contrast-mono.js +2 -1
  19. package/ui/core/src/themes/index.js +24 -5
  20. package/ui/core/src/themes/ms-dos.js +2 -1
  21. package/ui/core/src/themes/noctis-obscuro.js +2 -1
  22. package/ui/core/src/themes/noctis.js +2 -1
  23. package/ui/core/src/themes/rust.js +125 -0
  24. package/ui/core/src/themes/spongebob.js +110 -0
  25. package/ui/core/src/themes/terminal-green.js +2 -1
  26. package/ui/core/src/themes/win95.js +2 -1
  27. package/ui/core/src/themes/winamp.js +2 -1
  28. package/ui/core/src/themes/workspace-dark.js +2 -1
  29. package/ui/react/src/web-app.js +240 -266
  30. package/web/dist/assets/{abnfDiagram-VRR7QNED-mXFQ4a3c.js → abnfDiagram-VRR7QNED-BFOimVWK.js} +1 -1
  31. package/web/dist/assets/{arc-BPuuvbge.js → arc-uUHjvwEo.js} +1 -1
  32. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Dt4uUYIe.js → architectureDiagram-ZJ3FMSHR-CxeX_vea.js} +1 -1
  33. package/web/dist/assets/{blockDiagram-677ZJIJ3-Ax8Q22vV.js → blockDiagram-677ZJIJ3-Cno8kltG.js} +1 -1
  34. package/web/dist/assets/{c4Diagram-LMCZKHZV-CPiukyHt.js → c4Diagram-LMCZKHZV-viONVybf.js} +1 -1
  35. package/web/dist/assets/channel-IURmSsjn.js +1 -0
  36. package/web/dist/assets/{chunk-2Q5K7J3B-VJLqCZJX.js → chunk-2Q5K7J3B-CKfICIGD.js} +1 -1
  37. package/web/dist/assets/{chunk-32BRIVSS-DqOshxhf.js → chunk-32BRIVSS-CxmwEY56.js} +1 -1
  38. package/web/dist/assets/{chunk-5VM5RSS4-KPqV21jV.js → chunk-5VM5RSS4-_eVYLGll.js} +1 -1
  39. package/web/dist/assets/{chunk-EX3LRPZG-DfhiCVl_.js → chunk-EX3LRPZG-C4aqbjrf.js} +1 -1
  40. package/web/dist/assets/{chunk-JWPE2WC7-H6khJPuW.js → chunk-JWPE2WC7-Bh8ZxrI8.js} +1 -1
  41. package/web/dist/assets/{chunk-MOJQB5TN-BKMAzQxj.js → chunk-MOJQB5TN-BXgLlWVP.js} +1 -1
  42. package/web/dist/assets/{chunk-RYQCIY6F-DNXRTxtj.js → chunk-RYQCIY6F-B1rMIdfF.js} +1 -1
  43. package/web/dist/assets/{chunk-V7JOEXUC-B56PM6Gv.js → chunk-V7JOEXUC-C3AnSHXk.js} +1 -1
  44. package/web/dist/assets/{chunk-VR4S4FIN-Boi3hGkB.js → chunk-VR4S4FIN-CeoMJIBs.js} +1 -1
  45. package/web/dist/assets/{chunk-XXDRQBXY-DmJyn890.js → chunk-XXDRQBXY-DzaxQ9iP.js} +1 -1
  46. package/web/dist/assets/classDiagram-OUVF2IWQ-FsfpM3iu.js +1 -0
  47. package/web/dist/assets/classDiagram-v2-EOCWNBFH-FsfpM3iu.js +1 -0
  48. package/web/dist/assets/{cose-bilkent-JH36ORCC-BRdFlFWt.js → cose-bilkent-JH36ORCC-G46fvUOx.js} +1 -1
  49. package/web/dist/assets/{cynefin-VYW2F7L2-DqE-xib2.js → cynefin-VYW2F7L2-DXwqVhWA.js} +1 -1
  50. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-D-QBZjIL.js → cynefinDiagram-TSTJHNR4-DNNFmaXb.js} +1 -1
  51. package/web/dist/assets/{dagre-VKFMJZFB-DYu9oQV6.js → dagre-VKFMJZFB-BCfh-h3o.js} +1 -1
  52. package/web/dist/assets/{diagram-FQU43EPY-DOwYccCg.js → diagram-FQU43EPY-CyG9b6bj.js} +1 -1
  53. package/web/dist/assets/{diagram-G47NLZAW-DTSEmrPr.js → diagram-G47NLZAW-DdxDahK3.js} +1 -1
  54. package/web/dist/assets/{diagram-NH7WQ7WH-OqbzzquA.js → diagram-NH7WQ7WH-CjrZ_eja.js} +1 -1
  55. package/web/dist/assets/{diagram-OA4YK3LP-BMVG6A8I.js → diagram-OA4YK3LP-5NAjQpk2.js} +1 -1
  56. package/web/dist/assets/{diagram-WEI45ONY-Rn2Q5xaz.js → diagram-WEI45ONY-fLuDb54-.js} +1 -1
  57. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-aL32_v5y.js → ebnfDiagram-CCIWWBDH-DL9qnZpa.js} +1 -1
  58. package/web/dist/assets/{erDiagram-Q63AITRT-DKCpyczx.js → erDiagram-Q63AITRT-C_ynfrWY.js} +1 -1
  59. package/web/dist/assets/{flowDiagram-23GEKE2U-CrZTPgR_.js → flowDiagram-23GEKE2U-dYuH8HYv.js} +1 -1
  60. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DnSE1OtC.js → ganttDiagram-NO4QXBWP-e8Meh3f3.js} +1 -1
  61. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-C5SeYPUR.js → gitGraphDiagram-IHSO6WYX-D_tZ4VL9.js} +1 -1
  62. package/web/dist/assets/index-Ct7SQv1M.css +1 -0
  63. package/web/dist/assets/{index-B-TEKrRB.js → index-DcQes9h8.js} +6 -6
  64. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DDnJZp0X.js → infoDiagram-FWYZ7A6U-Dffhqp73.js} +1 -1
  65. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-ewhzfFkq.js → ishikawaDiagram-FXEZZL3T-DYt9H7kU.js} +1 -1
  66. package/web/dist/assets/{journeyDiagram-5HDEW3XC-T0iWWkLM.js → journeyDiagram-5HDEW3XC-Qv1vVu0F.js} +1 -1
  67. package/web/dist/assets/{kanban-definition-HUTT4EX6-D1MFQZjb.js → kanban-definition-HUTT4EX6-CTAUo40G.js} +1 -1
  68. package/web/dist/assets/{linear-BKfPK9wE.js → linear-CqLsWFLj.js} +1 -1
  69. package/web/dist/assets/{mermaid.core-CyzD7dTc.js → mermaid.core-okRCGPQQ.js} +4 -4
  70. package/web/dist/assets/{mindmap-definition-LN4V7U3C-CWXtcTlP.js → mindmap-definition-LN4V7U3C-DVOOrIwD.js} +1 -1
  71. package/web/dist/assets/{papaparse.min-DLUoV5k5.js → papaparse.min-Ddflu8oC.js} +1 -1
  72. package/web/dist/assets/{pegDiagram-2B236MQR-BFjZun-r.js → pegDiagram-2B236MQR-BLJlC0MZ.js} +1 -1
  73. package/web/dist/assets/{pieDiagram-ENE6RG2P-DMDtW4g7.js → pieDiagram-ENE6RG2P-DdOwICOg.js} +1 -1
  74. package/web/dist/assets/pilotswarm-CKh2MxfL.js +99 -0
  75. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CAwPVaYL.js → quadrantDiagram-ABIIQ3AL-B0Th1YO5.js} +1 -1
  76. package/web/dist/assets/{railroadDiagram-RFXS5EU6-Co102PkQ.js → railroadDiagram-RFXS5EU6-BF2XKMKf.js} +1 -1
  77. package/web/dist/assets/{requirementDiagram-TGXJPOKE-D19VlRSl.js → requirementDiagram-TGXJPOKE-Mr1iSwbS.js} +1 -1
  78. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-Sd_a3ukL.js → sankeyDiagram-HTMAVEWB-CrOjVZyg.js} +1 -1
  79. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-CojDRK1c.js → sequenceDiagram-DBY2YBRQ-H0X4Lydk.js} +1 -1
  80. package/web/dist/assets/{sizeCapture-X5ZJPWSS-XqXcxnDo.js → sizeCapture-X5ZJPWSS-Dus1QEqh.js} +1 -1
  81. package/web/dist/assets/{stateDiagram-2N3HPSRC-BcN_w699.js → stateDiagram-2N3HPSRC-BOBUSwdR.js} +1 -1
  82. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-DABTDwFh.js +1 -0
  83. package/web/dist/assets/{swimlanes-5IMT3BWC-BcO6ai_I.js → swimlanes-5IMT3BWC-BoqT-Lh7.js} +2 -2
  84. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BF5mFN4t.js +8 -0
  85. package/web/dist/assets/{timeline-definition-FHXFAJF6-DOPfYxUq.js → timeline-definition-FHXFAJF6-BOrqUQHX.js} +1 -1
  86. package/web/dist/assets/{vennDiagram-L72KCM5P-BSKLkEOC.js → vennDiagram-L72KCM5P-DDkNL-Oo.js} +1 -1
  87. package/web/dist/assets/{wardleyDiagram-EHGQE667-BdcYAVw2.js → wardleyDiagram-EHGQE667-XUAJQ2X0.js} +1 -1
  88. package/web/dist/assets/{xychartDiagram-FW5EYKEG-C6EJT4jB.js → xychartDiagram-FW5EYKEG-ph69PV9y.js} +1 -1
  89. package/web/dist/index.html +3 -3
  90. package/ui/core/src/themes/dark-high-contrast.js +0 -56
  91. package/ui/core/src/themes/workspace-dark-rich.js +0 -65
  92. package/web/dist/assets/channel-BCzDctYg.js +0 -1
  93. package/web/dist/assets/classDiagram-OUVF2IWQ-ChqjVZil.js +0 -1
  94. package/web/dist/assets/classDiagram-v2-EOCWNBFH-ChqjVZil.js +0 -1
  95. package/web/dist/assets/index-D2B5bzSN.css +0 -1
  96. package/web/dist/assets/pilotswarm-DA0u7Cq_.js +0 -99
  97. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-BV_w1D3C.js +0 -1
  98. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BnYdP0qP.js +0 -8
@@ -80,19 +80,66 @@ function shouldCompactPaneTitleMetadata(width) {
80
80
  function getSessionVisualStatus(session) {
81
81
  if (!session) return "unknown";
82
82
  const status = session.status || "unknown";
83
- if (
84
- session.cronActive === true
85
- && (status === "waiting" || status === "idle" || status === "unknown")
86
- ) {
83
+ const dormant = status === "waiting" || status === "idle" || status === "unknown";
84
+ if (session.cronActive === true && dormant) {
87
85
  return "cron_waiting";
88
86
  }
87
+ // `activeChildCount` is stamped by the reducer (applyActiveDescendantCounts)
88
+ // and counts only running/input_required descendants — a sub-agent that has
89
+ // finished stays alive and idle, so a plain child count would leave every
90
+ // parent stuck in this state forever. Checked after cron so a scheduled
91
+ // session keeps reading as scheduled.
92
+ if (dormant && (Number(session.activeChildCount) || 0) > 0) {
93
+ return "awaiting_children";
94
+ }
95
+ return status;
96
+ }
97
+
98
+ function sessionStatusLabelForVisualStatus(session, status) {
99
+ if (status === "cron_waiting") return "waiting";
100
+ if (status === "awaiting_children") {
101
+ // The row status is debounced (see mergeSessionRowVisualStatus), so it
102
+ // can outlive the count that produced it by a few seconds.
103
+ const active = Number(session?.activeChildCount) || 0;
104
+ return active > 0 ? `waiting on ${active}` : "waiting on children";
105
+ }
89
106
  return status;
90
107
  }
91
108
 
92
109
  function getSessionSummaryStatusLabel(session) {
93
110
  const status = getSessionVisualStatus(session);
94
111
  if (!status || status === "unknown") return "";
95
- return status === "cron_waiting" ? "waiting" : status;
112
+ return sessionStatusLabelForVisualStatus(session, status);
113
+ }
114
+
115
+ /**
116
+ * The label + timestamp the session-detail box shows, as ONE derivation shared
117
+ * with the list row.
118
+ *
119
+ * The detail box used to print `session.status` raw, and that is why it flipped
120
+ * between "idle" and "waiting" while a turn sat parked: two writers disagree
121
+ * mid-turn — the 4s catalog poll carries the CMS row state, the post-event
122
+ * detail sync carries the live orchestration customStatus — and whichever
123
+ * landed last won. The list has never had that problem because it reads the
124
+ * DEBOUNCED status (mergeSessionRowVisualStatus holds a change for 5s for
125
+ * exactly this reason) and because the cron-aware derivation folds
126
+ * idle/waiting/unknown into one steady state for a scheduled session.
127
+ *
128
+ * Reading raw meant the box opted out of both. This is the same accessor the
129
+ * row uses, so the two can no longer disagree with each other either.
130
+ */
131
+ export function selectSessionStatusSummary(session) {
132
+ if (!session) return null;
133
+ const timestampMs = session.updatedAt
134
+ || session.summaryUpdatedAt
135
+ || session.latestSummaryUpdatedAt
136
+ || session.createdAt
137
+ || 0;
138
+ return {
139
+ status: getSessionRowStatusLabel(session) || "unknown",
140
+ timestampMs: timestampMs || null,
141
+ relative: timestampMs ? formatRelativeTime(timestampMs) : "—",
142
+ };
96
143
  }
97
144
 
98
145
  function getSessionRowStatusLabel(session) {
@@ -104,7 +151,7 @@ function getSessionRowStatusLabel(session) {
104
151
  // already smoothed. The summary view keeps the immediate raw label above.
105
152
  const status = getSessionRowVisualStatus(session);
106
153
  if (!status || status === "unknown") return "";
107
- return status === "cron_waiting" ? "waiting" : status;
154
+ return sessionStatusLabelForVisualStatus(session, status);
108
155
  }
109
156
 
110
157
  function getSessionRowVisualStatus(session) {
@@ -132,6 +179,7 @@ function getSessionVisualKind(session, mode = "local") {
132
179
  mode === "remote"
133
180
  && isTerminalOrchestrationStatus(session?.orchestrationStatus)
134
181
  && status !== "cron_waiting"
182
+ && status !== "awaiting_children"
135
183
  && status !== "waiting"
136
184
  && status !== "input_required"
137
185
  ) {
@@ -151,6 +199,7 @@ function getSessionRowVisualKind(session, mode = "local") {
151
199
  mode === "remote"
152
200
  && isTerminalOrchestrationStatus(session?.orchestrationStatus)
153
201
  && status !== "cron_waiting"
202
+ && status !== "awaiting_children"
154
203
  && status !== "waiting"
155
204
  && status !== "input_required"
156
205
  ) {
@@ -165,6 +214,9 @@ function sessionStatusColor(session, mode = "local") {
165
214
  switch (getSessionRowVisualKind(session, mode)) {
166
215
  case "running": return "green";
167
216
  case "cron_waiting": return "yellow";
217
+ // Distinct from both running (this session is not doing the work) and
218
+ // waiting (nothing is happening) — something IS running, one level down.
219
+ case "awaiting_children": return "magenta";
168
220
  case "waiting": return "yellow";
169
221
  case "input_required": return "cyan";
170
222
  case "cancelled": return "gray";
@@ -181,6 +233,7 @@ function sessionStatusIcon(session, mode = "local") {
181
233
  switch (getSessionRowVisualKind(session, mode)) {
182
234
  case "running": return "*";
183
235
  case "cron_waiting": return "~";
236
+ case "awaiting_children": return ">";
184
237
  case "waiting": return "~";
185
238
  case "input_required": return "?";
186
239
  case "cancelled": return "x";
@@ -242,7 +295,11 @@ function ownerInitials(owner) {
242
295
  */
243
296
  export function manualOrderContainerKey(session, pinned = false) {
244
297
  if (!isManuallyOrderableSession(session)) return null;
245
- if (pinned) return "pinned";
298
+ // Pinned folders and pinned sessions are SEPARATE bands (rankSessionBand
299
+ // 1 and 2), so they cannot share a container: the drag would offer drop
300
+ // positions among the pinned sessions that the sort then refuses, and the
301
+ // row landed somewhere other than the insertion line the user aimed at.
302
+ if (pinned) return session.isGroup ? "pinned:folders" : "pinned:sessions";
246
303
  if (session.isGroup) return "folders";
247
304
  if (session.groupId) return `g:${session.groupId}`;
248
305
  return "";
@@ -591,10 +648,18 @@ function buildTotalDescendantCounts(byId) {
591
648
  const { childMap } = buildChildMaps(byId);
592
649
  const counts = new Map();
593
650
 
651
+ // `visiting` bounds a malformed parent chain (self-parent, cycle). Without
652
+ // it the recursion below never terminates and the whole UI hangs on a
653
+ // RangeError rather than rendering a slightly wrong count.
654
+ const visiting = new Set();
655
+
594
656
  function countFor(sessionId) {
595
657
  if (counts.has(sessionId)) return counts.get(sessionId);
658
+ if (visiting.has(sessionId)) return 0;
659
+ visiting.add(sessionId);
596
660
  const children = childMap.get(sessionId) || [];
597
661
  const total = children.reduce((sum, childId) => sum + 1 + countFor(childId), 0);
662
+ visiting.delete(sessionId);
598
663
  counts.set(sessionId, total);
599
664
  return total;
600
665
  }
@@ -754,8 +819,15 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
754
819
  // Multi-selection reads as a selection BAR on the chosen rows (plus the
755
820
  // host's own row styling) — not a checkbox column that shifts every row
756
821
  // the moment selection mode turns on.
822
+ // role:"selection" so a host that already SHOWS selection some other way
823
+ // can drop the glyph instead of paying a cell for it — the same contract
824
+ // role:"status" and role:"depth" already use. The portal outlines the row,
825
+ // so the bar there was pure duplication that cost one column: selected
826
+ // rows truncated one character earlier than their neighbours, which
827
+ // clipped the pin/folder/system icon on any row whose glyph happened to
828
+ // land on the boundary. The TUI ignores the tag and keeps the bar.
757
829
  if (isSelected) {
758
- prefixRuns.push({ text: "▌", color: "cyan", bold: true });
830
+ prefixRuns.push({ text: "▌", color: "cyan", bold: true, role: "selection" });
759
831
  }
760
832
 
761
833
  // Pin column renders first on top-level non-system rows so pinned and
@@ -2698,238 +2770,6 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2698
2770
  return memoize(lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }]);
2699
2771
  }
2700
2772
 
2701
- // True when a chat message is plain user/assistant prose the rich (block)
2702
- // renderer can format as markdown directly. Everything with terminal-era
2703
- // special handling — splash art, thinking cards, chrome-less summaries,
2704
- // system/PilotSwarm cards, asked-and-answered exchanges, embedded
2705
- // [SYSTEM: …] notice segments — stays on the line renderer, which already
2706
- // knows every edge case.
2707
- function isRichRenderableChatMessage(message) {
2708
- if (!message) return false;
2709
- if (message.kind === "epoch-divider" || message.kind === "regen-refused" || message.kind === "regen-failed") return false;
2710
- if (message.splash || message.thinking || message.noChrome) return false;
2711
- if (message.role !== "user" && message.role !== "assistant") return false;
2712
- if (message.role === "user" && parseAskedAndAnsweredExchange(message?.text || "")) return false;
2713
- const segments = splitSystemNoticeSegments(message?.text || "");
2714
- if (segments.some((segment) => segment.kind === "system")) return false;
2715
- return true;
2716
- }
2717
-
2718
- /**
2719
- * Message-level view of the active chat for the rich (desktop-style) web
2720
- * renderer. Returns an ordered array of blocks:
2721
- *
2722
- * { kind: "message", id, role, header, text, pendingPhase, attachments }
2723
- * — plain user/assistant prose. `header` is describeChatMessageHeader()
2724
- * output; `text` is the artifact-link-decorated markdown source the
2725
- * renderer formats itself; `attachments` is { sessionId, attachments }
2726
- * (the sentinel shape) or null.
2727
- *
2728
- * { kind: "lines", variant, lines }
2729
- * — everything else (splash, thinking cards, system cards, dividers),
2730
- * pre-rendered through the exact same line builders the terminal
2731
- * transcript uses, so no edge case has two implementations.
2732
- *
2733
- * The TUI never calls this; selectChatLines remains the terminal path.
2734
- */
2735
- // Rich message blocks, cached on the message object. Keyed weakly so entries
2736
- // die with the transcript they came from.
2737
- const chatMessageBlockCache = new WeakMap();
2738
-
2739
- export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2740
- const messages = selectActiveChat(state);
2741
- if (!messages || messages.length === 0) return [];
2742
-
2743
- const viewerKey = ownerKeyForOwner(state?.auth?.principal);
2744
- const activeSessionId = state?.sessions?.activeSessionId;
2745
- const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
2746
- const ownerKey = ownerKeyForOwner(activeSession?.owner);
2747
- const sharedContext = Boolean(activeSession && (
2748
- (activeSession.visibility && activeSession.visibility !== "private")
2749
- || (ownerKey && viewerKey && ownerKey !== viewerKey)
2750
- ));
2751
- const buildOptions = {
2752
- ...(options?.tableMode ? { tableMode: options.tableMode } : {}),
2753
- ...(viewerKey ? { viewerKey } : {}),
2754
- ...(ownerKey ? { ownerKey } : {}),
2755
- ...(sharedContext ? { sharedContext: true } : {}),
2756
- };
2757
-
2758
- const blocks = [];
2759
- for (const message of messages) {
2760
- // Fast path FIRST. Classifying a message as rich-renderable scans its
2761
- // text (asked-and-answered parsing, system-notice splitting), and that
2762
- // ran for every message on every call — so a pane-splitter drag paid
2763
- // it across the whole transcript per width step. A cache entry only
2764
- // exists for a message already classified rich, and `kind` is part of
2765
- // what is compared, so a hit is safe to take before classifying.
2766
- // Object.is, NOT ===: `createdAt` is NaN whenever the source event had
2767
- // no parseable timestamp, and NaN === NaN is false, so a plain equality
2768
- // check could never hit for those messages — the cache would silently
2769
- // do nothing while still costing a lookup. Same trap as comparing
2770
- // Infinity by subtraction.
2771
- const cached = message ? chatMessageBlockCache.get(message) : null;
2772
- if (cached
2773
- && Object.is(cached.text, message.text)
2774
- && Object.is(cached.kind, message.kind)
2775
- && Object.is(cached.role, message.role)
2776
- && Object.is(cached.id, message.id)
2777
- && Object.is(cached.pendingPhase, message.pendingPhase)
2778
- && Object.is(cached.stopped, message.stopped)
2779
- // The header is derived from these, and the chips from attachments.
2780
- // Messages are mutated in place (pendingPhase provably is), so every
2781
- // field the block is built from has to be compared — not just the
2782
- // obvious ones.
2783
- && Object.is(cached.createdAt, message.createdAt)
2784
- && Object.is(cached.time, message.time)
2785
- && Object.is(cached.sender, message.sender)
2786
- && Object.is(cached.attachments, message.attachments)
2787
- && Object.is(cached.tableMode, buildOptions.tableMode)
2788
- && Object.is(cached.viewerKey, buildOptions.viewerKey)
2789
- && Object.is(cached.ownerKey, buildOptions.ownerKey)
2790
- && Object.is(cached.sharedContext, buildOptions.sharedContext)) {
2791
- blocks.push(cached.block);
2792
- continue;
2793
- }
2794
-
2795
- if (message?.kind === "epoch-divider") {
2796
- blocks.push({ kind: "lines", variant: "divider", lines: [buildEpochDividerLine(message, maxWidth)] });
2797
- continue;
2798
- }
2799
- if (message?.kind === "regen-failed") {
2800
- blocks.push({ kind: "lines", variant: "divider", lines: [buildRegenFailedLine(message, maxWidth)] });
2801
- continue;
2802
- }
2803
- if (message?.kind === "regen-refused") {
2804
- blocks.push({ kind: "lines", variant: "divider", lines: [buildRegenRefusedLine(message, maxWidth)] });
2805
- continue;
2806
- }
2807
- if (isRichRenderableChatMessage(message)) {
2808
- // A rich message block does NOT depend on maxWidth — the browser
2809
- // wraps this text with CSS; only the terminal needs a column count.
2810
- // Rebuilding it on every width step meant dragging a pane splitter
2811
- // re-parsed artifact links and re-derived headers for the entire
2812
- // loaded transcript to produce byte-identical output. Cache on the
2813
- // message itself, keyed by the inputs that CAN change it.
2814
- const attachmentLines = buildAttachmentChipLines(message, { tableMode: "sentinel" });
2815
- const attachmentSentinel = attachmentLines.find((line) => line?.kind === "imageAttachments") || null;
2816
- const block = {
2817
- kind: "message",
2818
- id: message?.id ?? null,
2819
- role: message.role,
2820
- header: describeChatMessageHeader(message, buildOptions),
2821
- text: decorateArtifactLinksForChat(message?.text || ""),
2822
- pendingPhase: message?.pendingPhase || null,
2823
- attachments: attachmentSentinel
2824
- ? { sessionId: attachmentSentinel.sessionId, attachments: attachmentSentinel.attachments }
2825
- : null,
2826
- };
2827
- chatMessageBlockCache.set(message, {
2828
- block,
2829
- text: message.text,
2830
- kind: message.kind,
2831
- createdAt: message.createdAt,
2832
- time: message.time,
2833
- sender: message.sender,
2834
- attachments: message.attachments,
2835
- role: message.role,
2836
- id: message.id,
2837
- pendingPhase: message.pendingPhase,
2838
- stopped: message.stopped,
2839
- tableMode: buildOptions.tableMode,
2840
- viewerKey: buildOptions.viewerKey,
2841
- ownerKey: buildOptions.ownerKey,
2842
- sharedContext: buildOptions.sharedContext,
2843
- });
2844
- blocks.push(block);
2845
- continue;
2846
- }
2847
- const lineBlock = cachedLineBlock(message, maxWidth, buildOptions);
2848
- if (lineBlock) blocks.push(lineBlock);
2849
- }
2850
- return blocks;
2851
- }
2852
-
2853
- /**
2854
- * Non-message blocks (thinking cards, system cards, splash, chrome-less
2855
- * summaries) go through the terminal line builders, which DO depend on width —
2856
- * so unlike message blocks these cannot be made width-independent. What they
2857
- * can avoid is being rebuilt when the width has not moved, which is every poll:
2858
- * the poll replaces `session`, the portal's useMemo sees a new selectorState,
2859
- * and the whole transcript is re-derived. Measured at 1500 messages with 10%
2860
- * line blocks that was 1.17ms a time, and 5.26ms at 50%.
2861
- *
2862
- * Only the LAST width is kept per message. A drag sweeps widths and will miss
2863
- * on every step — that work is genuinely required, since these lines are
2864
- * wrapped to a column count.
2865
- *
2866
- * The compared fields were enumerated from buildChatMessageLines and the
2867
- * builders it delegates to (message/thinking/system-notice cards). `stopped` is
2868
- * included because history.js sets it IN PLACE on an existing message.
2869
- */
2870
- const chatLineBlockCache = new WeakMap();
2871
-
2872
- function cachedLineBlock(message, maxWidth, buildOptions) {
2873
- if (!message || typeof message !== "object") {
2874
- const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2875
- return lines.length > 0 ? { kind: "lines", variant: "event", lines } : null;
2876
- }
2877
- const cached = chatLineBlockCache.get(message);
2878
- if (cached
2879
- && Object.is(cached.maxWidth, maxWidth)
2880
- && Object.is(cached.text, message.text)
2881
- && Object.is(cached.mobileText, message.mobileText)
2882
- && Object.is(cached.role, message.role)
2883
- && Object.is(cached.time, message.time)
2884
- && Object.is(cached.createdAt, message.createdAt)
2885
- && Object.is(cached.splash, message.splash)
2886
- && Object.is(cached.thinking, message.thinking)
2887
- && Object.is(cached.thinkingLabel, message.thinkingLabel)
2888
- && Object.is(cached.progressKind, message.progressKind)
2889
- && Object.is(cached.noChrome, message.noChrome)
2890
- && Object.is(cached.cardTitle, message.cardTitle)
2891
- && Object.is(cached.cardTitleColor, message.cardTitleColor)
2892
- && Object.is(cached.cardBorderColor, message.cardBorderColor)
2893
- && Object.is(cached.stopped, message.stopped)
2894
- && Object.is(cached.tableMode, buildOptions.tableMode)
2895
- && Object.is(cached.viewerKey, buildOptions.viewerKey)
2896
- && Object.is(cached.ownerKey, buildOptions.ownerKey)
2897
- && Object.is(cached.sharedContext, buildOptions.sharedContext)) {
2898
- return cached.block;
2899
- }
2900
- const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2901
- const block = lines.length > 0 ? { kind: "lines", variant: "event", lines } : null;
2902
- chatLineBlockCache.set(message, {
2903
- block,
2904
- maxWidth,
2905
- text: message.text,
2906
- mobileText: message.mobileText,
2907
- role: message.role,
2908
- time: message.time,
2909
- createdAt: message.createdAt,
2910
- splash: message.splash,
2911
- thinking: message.thinking,
2912
- thinkingLabel: message.thinkingLabel,
2913
- progressKind: message.progressKind,
2914
- noChrome: message.noChrome,
2915
- cardTitle: message.cardTitle,
2916
- cardTitleColor: message.cardTitleColor,
2917
- cardBorderColor: message.cardBorderColor,
2918
- stopped: message.stopped,
2919
- tableMode: buildOptions.tableMode,
2920
- viewerKey: buildOptions.viewerKey,
2921
- ownerKey: buildOptions.ownerKey,
2922
- sharedContext: buildOptions.sharedContext,
2923
- });
2924
- return block;
2925
- }
2926
-
2927
- // A centered inline rule ("──── label ────") for transcript markers. The dash
2928
- // runs are CAPPED (not stretched to maxWidth): the web portal wraps by pixel
2929
- // width, and a maxWidth-long "─" run overflows a narrower pane and wraps the
2930
- // rule mid-label. A short symmetric rule reads as a divider on every width, and
2931
- // when the label alone will not fit it is rendered bare (wraps as plain text,
2932
- // never as dangling dash fragments).
2933
2773
  function buildRuleLine(label, color, maxWidth) {
2934
2774
  const safeWidth = Math.max(24, Number(maxWidth) || 80);
2935
2775
  const room = safeWidth - label.length;
@@ -4383,7 +4223,9 @@ export function selectStatusBar(state) {
4383
4223
  if (state.ui.modal?.type === "sessionAgentPicker") {
4384
4224
  return {
4385
4225
  left: "Select an agent for the new session",
4386
- right: "up/down move · enter create · esc cancel",
4226
+ // Enter no longer always creates — on a section header it opens or
4227
+ // closes — and the disclosure keys have to be named somewhere.
4228
+ right: "up/down move · ←/→ open/close · enter start · esc cancel",
4387
4229
  };
4388
4230
  }
4389
4231
  if (state.ui.modal?.type === "sessionGroupPicker") {
@@ -5608,36 +5450,52 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
5608
5450
  const items = Array.isArray(modal.items) ? modal.items : [];
5609
5451
  const selectedIndex = Math.max(0, Number(modal.selectedIndex) || 0);
5610
5452
  const contentWidth = Math.max(24, maxWidth - 4);
5611
- // Items arrive pre-sorted Shared → My agents → Generic (controller);
5612
- // emit a non-clickable heading row at each group boundary. rowItemIndexes
5613
- // maps rendered rows back to item indexes (null = heading) — the same
5614
- // mechanism the model picker uses.
5615
- const GROUP_HEADINGS = { shared: "Shared", mine: "My agents", generic: "" };
5616
- // Package-scope vocabulary only makes sense when packages exist: a pure
5617
- // baked deployment keeps the classic flat list, no headings.
5618
- const hasPackageItems = items.some((item) => item?.packageName);
5453
+ // Items ARE rows, headings included: navigation walks onto a heading and
5454
+ // Enter toggles it, so every section is reachable from the keyboard.
5455
+ // rowItemIndexes stays 1:1 for the renderers that map a click back to an
5456
+ // item (the same mechanism the model picker uses).
5619
5457
  const rows = [];
5620
5458
  const rowItemIndexes = [];
5621
- let lastGroup = null;
5622
5459
  items.forEach((item, index) => {
5623
- const group = item?.group || "shared";
5624
- if (hasPackageItems && group !== lastGroup) {
5625
- const heading = GROUP_HEADINGS[group] ?? "";
5626
- rows.push([{ text: heading ? `── ${heading} ──` : "──", color: "gray" }]);
5627
- rowItemIndexes.push(null);
5628
- lastGroup = group;
5629
- }
5630
5460
  const isSelected = index === selectedIndex;
5631
- const suffix = item?.kind === "generic"
5632
- ? " [generic]"
5633
- : item?.packageName
5634
- ? ` ${item.packageName} ${item.packageSemver || ""}`.trimEnd()
5635
- : ` (${item?.agentName || item?.id || "agent"})`;
5636
- const labelRuns = fitRuns([
5637
- { text: item?.kind === "generic" ? "○ " : "· ", color: "gray" },
5638
- { text: item?.title || item?.agentName || item?.id || "Agent", color: "white", bold: true },
5639
- { text: suffix, color: "gray" },
5640
- ], contentWidth);
5461
+ const indent = " ".repeat(Math.max(0, Number(item?.depth) || 0) * 2);
5462
+ let labelRuns;
5463
+
5464
+ if (item?.kind === "section") {
5465
+ const arrow = item.collapsed ? "▸" : "▾";
5466
+ // Only when the row's scope disagrees with the shelf it is on:
5467
+ // an admin sees other users' user-scoped packages, and those land
5468
+ // under Shared while not being shared at all. Otherwise the
5469
+ // section heading already carries it.
5470
+ const scopeBadge = item.sectionKind === "package" && item.packageScope === "user" && !item.mine
5471
+ ? " [private]"
5472
+ : "";
5473
+ // The count runs INLINE after the title, not right-aligned with
5474
+ // padding. `contentWidth` is a character count, and the portal's
5475
+ // row is a fluid-width button — padding a row out to a guessed
5476
+ // column width simply overflowed it, and the run wrapped mid-phrase
5477
+ // ("2 / entries · 4 agents"). Inline can only ever break at a
5478
+ // space.
5479
+ labelRuns = fitRuns([
5480
+ { text: `${indent}${arrow} `, color: "cyan" },
5481
+ { text: item.title || "Section", color: "white", bold: true },
5482
+ ...(scopeBadge ? [{ text: scopeBadge, color: "cyan" }] : []),
5483
+ ...(item.meta ? [{ text: ` · ${item.meta}`, color: "gray" }] : []),
5484
+ ], contentWidth);
5485
+ } else {
5486
+ const callable = item?.kind === "generic" || item?.supportsDirectStart !== false;
5487
+ // A sub-agent that cannot be started is shown, not hidden — seeing
5488
+ // what a package is made of is most of the value of grouping it.
5489
+ // Dimming plus the └ connector carries it; the detail pane spells
5490
+ // out why. An extra "called only" tag on the row was redundant with
5491
+ // both, and read as a sentence fragment.
5492
+ const glyph = item?.kind === "generic" ? "○ " : (item?.depth || 0) > 0 && item?.parentAgentName ? "└ " : "★ ";
5493
+ labelRuns = fitRuns([
5494
+ { text: `${indent}${glyph}`, color: callable ? "yellow" : "gray" },
5495
+ { text: item?.title || item?.agentName || item?.id || "Agent", color: callable ? "white" : "gray", bold: callable },
5496
+ ], contentWidth);
5497
+ }
5498
+
5641
5499
  rows.push(isSelected
5642
5500
  ? buildActiveHighlightLine(labelRuns.map((run) => run.text).join("").padEnd(contentWidth, " "))
5643
5501
  : labelRuns);
@@ -5647,7 +5505,29 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
5647
5505
  const selectedItem = items[selectedIndex] || null;
5648
5506
  const selectedModel = modal.sessionOptions?.model || null;
5649
5507
  const selectedReasoningEffort = modal.sessionOptions?.reasoningEffort || null;
5650
- const detailsLines = selectedItem
5508
+ const detailsLines = selectedItem?.kind === "section"
5509
+ ? [
5510
+ [{ text: selectedItem.title || "Section", color: "white", bold: true }],
5511
+ ...(selectedItem.sectionKind === "package"
5512
+ ? [
5513
+ [{ text: `${selectedItem.packageName || "package"}@${selectedItem.packageSemver || "?"}`, color: "gray" }],
5514
+ ...(selectedItem.ownerLabel ? [[{ text: `Published by ${selectedItem.ownerLabel}`, color: "gray" }]] : []),
5515
+ ]
5516
+ : [[{
5517
+ text: selectedItem.sectionKind === "builtin"
5518
+ ? "Agents baked into this deployment."
5519
+ : "Agent packages installed on this deployment.",
5520
+ color: "gray",
5521
+ }]]),
5522
+ [{ text: "", color: "gray" }],
5523
+ [{ text: selectedItem.meta || "", color: "gray" }],
5524
+ [{ text: "", color: "gray" }],
5525
+ [{
5526
+ text: selectedItem.collapsed ? "Enter or → to open." : "Enter or ← to close.",
5527
+ color: "gray",
5528
+ }],
5529
+ ]
5530
+ : selectedItem
5651
5531
  ? [
5652
5532
  [{
5653
5533
  text: selectedItem.title || selectedItem.agentName || selectedItem.id || "Agent",
@@ -5660,6 +5540,12 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
5660
5540
  ...(selectedItem.packageName
5661
5541
  ? [[{ text: `Package: ${selectedItem.packageName}@${selectedItem.packageSemver || "?"} · ${selectedItem.packageScope || "shared"}`, color: "gray" }]]
5662
5542
  : (selectedItem.kind === "agent" ? [[{ text: "Built-in agent", color: "gray" }]] : [])),
5543
+ ...(selectedItem.startedBy?.length
5544
+ ? [[{ text: `Started by: ${selectedItem.startedBy.join(", ")}`, color: "gray" }]]
5545
+ : []),
5546
+ ...(selectedItem.kind !== "generic" && selectedItem.supportsDirectStart === false
5547
+ ? [[{ text: "Cannot be started on its own", color: "yellow" }]]
5548
+ : []),
5663
5549
  ...(selectedModel ? [[{ text: `Model: ${selectedModel}`, color: "gray" }]] : []),
5664
5550
  ...(selectedReasoningEffort ? [[{ text: `Reasoning: ${selectedReasoningEffort}`, color: "gray" }]] : []),
5665
5551
  [{ text: "", color: "gray" }],
@@ -7625,6 +7511,10 @@ export function selectThemePickerModal(state, maxWidth = 80) {
7625
7511
  const detailsLines = selectedItem
7626
7512
  ? [
7627
7513
  [{ text: `theme: ${selectedItem.description || "Shared theme for the portal and native TUI."}`, color: "white" }],
7514
+ // The portal draws group headings between rows; the TUI list is a
7515
+ // flat scroller with no room for them, so the group is surfaced
7516
+ // here instead. The ORDER still clusters by group in both.
7517
+ ...(selectedItem.group ? [[{ text: `group: ${selectedItem.group}`, color: "gray" }]] : []),
7628
7518
  { text: "", color: "gray" },
7629
7519
  [{
7630
7520
  text: previewingTheme
@@ -162,7 +162,8 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
162
162
  nodeMapSelectedNode: null,
163
163
  sequenceExpandedTurns: [],
164
164
  sequenceSelectedTurn: null,
165
- // "rich" is a THEME property now (theme.richChat), not a view mode.
165
+ // "rich" was retired as a view mode; a persisted "rich" falls
166
+ // through to the transcript.
166
167
  chatViewMode: chatViewMode === "summary" ? "summary" : "transcript",
167
168
  statsViewMode: "session",
168
169
  prompt: "",
@@ -174,6 +175,10 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
174
175
  // A persisted theme id may name a theme that no longer ships —
175
176
  // fall back to the default instead of crashing theme consumers.
176
177
  themeId: getTheme(themeId)?.id || DEFAULT_THEME_ID,
178
+ // Touch-friendly scale: bumps type and control sizes one step. A phone in
179
+ // portrait is not the only case — a wall display or a shared screen wants
180
+ // the same thing — so it is a preference, not a viewport query.
181
+ touchScale: false,
177
182
  modal: null,
178
183
  fullscreenPane: null,
179
184
  layout: {
@@ -1,9 +1,10 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_VSCODE } from "./helpers.js";
2
2
 
3
3
  const catppuccinMochaTheme = createTheme({
4
4
  id: "catppuccin-mocha",
5
5
  label: "Catppuccin Mocha",
6
6
  description: "Creamy dark palette with soft pastels and high legibility.",
7
+ group: THEME_GROUP_VSCODE,
7
8
  page: {
8
9
  background: "#1e1e2e",
9
10
  foreground: "#cdd6f4",
@@ -1,9 +1,10 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_VSCODE } from "./helpers.js";
2
2
 
3
3
  const cobalt2Theme = createTheme({
4
4
  id: "cobalt2",
5
5
  label: "Cobalt2",
6
6
  description: "Electric blue shell with bright yellow accents and saturated syntax colors.",
7
+ group: THEME_GROUP_VSCODE,
7
8
  page: {
8
9
  background: "#193549",
9
10
  foreground: "#ffffff",
@@ -1,4 +1,4 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_RETRO } from "./helpers.js";
2
2
 
3
3
  // DOOM (id Software, 1993) — the status bar, not the shooter.
4
4
  //
@@ -19,6 +19,7 @@ const doomTheme = createTheme({
19
19
  id: "doom",
20
20
  label: "DOOM",
21
21
  description: "STBAR brown metal, bone type, and the three keycards as the status palette.",
22
+ group: THEME_GROUP_RETRO,
22
23
  // The face, drawn as data rather than markup so ui/core stays free of any
23
24
  // framework and nothing has to inject raw HTML. The portal renders it in
24
25
  // place of the deployment's logo; the TUI ignores it.
@@ -1,9 +1,10 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_VSCODE } from "./helpers.js";
2
2
 
3
3
  const draculaTheme = createTheme({
4
4
  id: "dracula",
5
5
  label: "Dracula",
6
6
  description: "Classic Dracula contrast with neon green, pink, and violet accents.",
7
+ group: THEME_GROUP_VSCODE,
7
8
  page: {
8
9
  background: "#282a36",
9
10
  foreground: "#f8f8f2",
@@ -1,9 +1,10 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_VSCODE } from "./helpers.js";
2
2
 
3
3
  const githubDarkTheme = createTheme({
4
4
  id: "github-dark",
5
5
  label: "GitHub Dark",
6
6
  description: "GitHub dark palette with soft blue accents and cool neutrals.",
7
+ group: THEME_GROUP_VSCODE,
7
8
  page: {
8
9
  background: "#0d1117",
9
10
  foreground: "#e6edf3",
@@ -1,9 +1,10 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_VSCODE } from "./helpers.js";
2
2
 
3
3
  const githubLightTheme = createTheme({
4
4
  id: "github-light",
5
5
  label: "GitHub Light",
6
6
  description: "GitHub light palette with crisp neutrals, blue actions, and high-contrast status colors.",
7
+ group: THEME_GROUP_VSCODE,
7
8
  page: {
8
9
  // Canvas, deliberately NOT the same as tui.surface (#ffffff). When the
9
10
  // page and the panes are both pure white there is nothing for a pane to
@@ -1,9 +1,10 @@
1
- import { createTheme } from "./helpers.js";
1
+ import { createTheme, THEME_GROUP_VSCODE } from "./helpers.js";
2
2
 
3
3
  const gruvboxDarkTheme = createTheme({
4
4
  id: "gruvbox-dark",
5
5
  label: "Gruvbox Dark",
6
6
  description: "Retro warm palette with earthy browns, oranges, and high readability.",
7
+ group: THEME_GROUP_VSCODE,
7
8
  page: {
8
9
  background: "#282828",
9
10
  foreground: "#ebdbb2",