pilotswarm 0.5.33 → 0.5.35

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 (101) 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 +533 -24
  5. package/ui/core/src/formatting.js +13 -1
  6. package/ui/core/src/history.js +8 -0
  7. package/ui/core/src/reducer.js +172 -28
  8. package/ui/core/src/selectors.js +160 -272
  9. package/ui/core/src/state.js +34 -1
  10. package/ui/core/src/themes/catppuccin-mocha.js +2 -1
  11. package/ui/core/src/themes/cobalt2.js +2 -1
  12. package/ui/core/src/themes/doom.js +2 -1
  13. package/ui/core/src/themes/dracula.js +2 -1
  14. package/ui/core/src/themes/duroxide.js +136 -0
  15. package/ui/core/src/themes/github-dark.js +2 -1
  16. package/ui/core/src/themes/github-light.js +2 -1
  17. package/ui/core/src/themes/gruvbox-dark.js +2 -1
  18. package/ui/core/src/themes/hacker-x-matrix.js +2 -1
  19. package/ui/core/src/themes/hacker-x-orion-prime.js +2 -1
  20. package/ui/core/src/themes/helpers.js +22 -4
  21. package/ui/core/src/themes/high-contrast-mono.js +2 -1
  22. package/ui/core/src/themes/index.js +26 -5
  23. package/ui/core/src/themes/ms-dos.js +2 -1
  24. package/ui/core/src/themes/noctis-obscuro.js +2 -1
  25. package/ui/core/src/themes/noctis.js +2 -1
  26. package/ui/core/src/themes/rust.js +125 -0
  27. package/ui/core/src/themes/spongebob.js +110 -0
  28. package/ui/core/src/themes/terminal-green.js +2 -1
  29. package/ui/core/src/themes/win95.js +2 -1
  30. package/ui/core/src/themes/winamp.js +2 -1
  31. package/ui/core/src/themes/workspace-dark.js +2 -1
  32. package/ui/react/src/web-app.js +1153 -307
  33. package/web/dist/assets/{abnfDiagram-VRR7QNED-DlP1x0QR.js → abnfDiagram-VRR7QNED-BwJEohLG.js} +1 -1
  34. package/web/dist/assets/{arc-Zo_8acjl.js → arc-CWUJ_S5s.js} +1 -1
  35. package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-Bc18aajT.js → architectureDiagram-ZJ3FMSHR-i6mrLxuE.js} +1 -1
  36. package/web/dist/assets/{blockDiagram-677ZJIJ3-DU76JwlX.js → blockDiagram-677ZJIJ3-C_KG-qPQ.js} +1 -1
  37. package/web/dist/assets/{c4Diagram-LMCZKHZV-COqs9Vhg.js → c4Diagram-LMCZKHZV-BBJH7hbB.js} +1 -1
  38. package/web/dist/assets/channel-5Ddqi9ib.js +1 -0
  39. package/web/dist/assets/{chunk-2Q5K7J3B-DLg4HU8R.js → chunk-2Q5K7J3B-CXwaFcaM.js} +1 -1
  40. package/web/dist/assets/{chunk-32BRIVSS-CU0Q4Vsz.js → chunk-32BRIVSS-C8ujjkK-.js} +1 -1
  41. package/web/dist/assets/{chunk-5VM5RSS4-BqRSeab5.js → chunk-5VM5RSS4-M9rz7YYk.js} +1 -1
  42. package/web/dist/assets/{chunk-EX3LRPZG-DenHmuGS.js → chunk-EX3LRPZG-bk6tRxOA.js} +1 -1
  43. package/web/dist/assets/{chunk-JWPE2WC7-BUpE28jH.js → chunk-JWPE2WC7-DZ4WSHGH.js} +1 -1
  44. package/web/dist/assets/{chunk-MOJQB5TN-D4WdKP4p.js → chunk-MOJQB5TN-B5-y6tMF.js} +1 -1
  45. package/web/dist/assets/{chunk-RYQCIY6F-DJgnfeyv.js → chunk-RYQCIY6F-yKcnHxm0.js} +1 -1
  46. package/web/dist/assets/{chunk-V7JOEXUC-DII2R5xT.js → chunk-V7JOEXUC-CpoNcATD.js} +1 -1
  47. package/web/dist/assets/{chunk-VR4S4FIN-BJ_02Sft.js → chunk-VR4S4FIN-Bf1yD2fn.js} +1 -1
  48. package/web/dist/assets/{chunk-XXDRQBXY-DTi_vPws.js → chunk-XXDRQBXY-EnPvDVhP.js} +1 -1
  49. package/web/dist/assets/classDiagram-OUVF2IWQ-B8u1fZiB.js +1 -0
  50. package/web/dist/assets/classDiagram-v2-EOCWNBFH-B8u1fZiB.js +1 -0
  51. package/web/dist/assets/{cose-bilkent-JH36ORCC-DzdVN7Sx.js → cose-bilkent-JH36ORCC-qhKoA2qj.js} +1 -1
  52. package/web/dist/assets/{cynefin-VYW2F7L2-CLw-4-yw.js → cynefin-VYW2F7L2-jcyYelJS.js} +1 -1
  53. package/web/dist/assets/{cynefinDiagram-TSTJHNR4-P8h9O8wh.js → cynefinDiagram-TSTJHNR4-D4RLufLP.js} +1 -1
  54. package/web/dist/assets/{dagre-VKFMJZFB-CDArowlh.js → dagre-VKFMJZFB-C9BA3GTO.js} +1 -1
  55. package/web/dist/assets/{diagram-FQU43EPY-GiMWEGCC.js → diagram-FQU43EPY-DbSSrVUJ.js} +1 -1
  56. package/web/dist/assets/{diagram-G47NLZAW-CpiRJOW2.js → diagram-G47NLZAW-3egISQXQ.js} +1 -1
  57. package/web/dist/assets/{diagram-NH7WQ7WH-BOdTWSov.js → diagram-NH7WQ7WH-8IKEMGRg.js} +1 -1
  58. package/web/dist/assets/{diagram-OA4YK3LP-BPKB7KD5.js → diagram-OA4YK3LP-VnaogqtK.js} +1 -1
  59. package/web/dist/assets/{diagram-WEI45ONY-CPS66m-d.js → diagram-WEI45ONY-DxigXH0T.js} +1 -1
  60. package/web/dist/assets/{ebnfDiagram-CCIWWBDH-nbpYqaxu.js → ebnfDiagram-CCIWWBDH-BSkNwJ9x.js} +1 -1
  61. package/web/dist/assets/{erDiagram-Q63AITRT-BF547i7I.js → erDiagram-Q63AITRT-CKN8JXl6.js} +1 -1
  62. package/web/dist/assets/{flowDiagram-23GEKE2U-CWIuM8_R.js → flowDiagram-23GEKE2U-DDaD5ExP.js} +1 -1
  63. package/web/dist/assets/{ganttDiagram-NO4QXBWP-DZRmOOgZ.js → ganttDiagram-NO4QXBWP-tOrBOuoI.js} +1 -1
  64. package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DQASL6yJ.js → gitGraphDiagram-IHSO6WYX-Cdp867ON.js} +1 -1
  65. package/web/dist/assets/{index-MikpTOty.js → index-BmwU43_u.js} +6 -6
  66. package/web/dist/assets/index-D-nA7gUe.css +1 -0
  67. package/web/dist/assets/{infoDiagram-FWYZ7A6U-DlK6reuU.js → infoDiagram-FWYZ7A6U-DwcKHD2h.js} +1 -1
  68. package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-DOiJ-xI_.js → ishikawaDiagram-FXEZZL3T-CWffK5kz.js} +1 -1
  69. package/web/dist/assets/{journeyDiagram-5HDEW3XC-B4EVeK0i.js → journeyDiagram-5HDEW3XC-BEAgS-EZ.js} +1 -1
  70. package/web/dist/assets/{kanban-definition-HUTT4EX6-eBT_l0PV.js → kanban-definition-HUTT4EX6-xDm-mRRj.js} +1 -1
  71. package/web/dist/assets/{linear-CGPMpgYy.js → linear-oP9MA0as.js} +1 -1
  72. package/web/dist/assets/{mermaid.core-CMg-D4qI.js → mermaid.core-DgXXt1OS.js} +4 -4
  73. package/web/dist/assets/{mindmap-definition-LN4V7U3C-COIkL1R_.js → mindmap-definition-LN4V7U3C-BBQgVrYC.js} +1 -1
  74. package/web/dist/assets/{papaparse.min-BkzE5bQr.js → papaparse.min-CR6JmBWm.js} +1 -1
  75. package/web/dist/assets/{pegDiagram-2B236MQR-nXLPVk3y.js → pegDiagram-2B236MQR-C2O0cf3j.js} +1 -1
  76. package/web/dist/assets/{pieDiagram-ENE6RG2P-CLFUyga1.js → pieDiagram-ENE6RG2P-D08pUtYU.js} +1 -1
  77. package/web/dist/assets/pilotswarm-D7NLCbuc.js +99 -0
  78. package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-BHSt68p5.js → quadrantDiagram-ABIIQ3AL-D-Z7YoEK.js} +1 -1
  79. package/web/dist/assets/{railroadDiagram-RFXS5EU6-CyohRm8J.js → railroadDiagram-RFXS5EU6-ClkdveLI.js} +1 -1
  80. package/web/dist/assets/{requirementDiagram-TGXJPOKE-CQTikEcB.js → requirementDiagram-TGXJPOKE-IY4xbn-Q.js} +1 -1
  81. package/web/dist/assets/{sankeyDiagram-HTMAVEWB-CxDlhil0.js → sankeyDiagram-HTMAVEWB-Cpt5i7KM.js} +1 -1
  82. package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-n4ZRJPY0.js → sequenceDiagram-DBY2YBRQ-c07lALcz.js} +1 -1
  83. package/web/dist/assets/{sizeCapture-X5ZJPWSS-J_LGaCmN.js → sizeCapture-X5ZJPWSS-CmCDiNbI.js} +1 -1
  84. package/web/dist/assets/{stateDiagram-2N3HPSRC-CWavHbpD.js → stateDiagram-2N3HPSRC-Dueu17zX.js} +1 -1
  85. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-AGpIV7wW.js +1 -0
  86. package/web/dist/assets/{swimlanes-5IMT3BWC-l-bxBeeJ.js → swimlanes-5IMT3BWC-Csc2Fup8.js} +2 -2
  87. package/web/dist/assets/swimlanesDiagram-G3AALYLV-BTGZSV0N.js +8 -0
  88. package/web/dist/assets/{timeline-definition-FHXFAJF6-ChA5iURW.js → timeline-definition-FHXFAJF6-4-lf702e.js} +1 -1
  89. package/web/dist/assets/{vennDiagram-L72KCM5P-DVkk3iKL.js → vennDiagram-L72KCM5P-CmlaKpLI.js} +1 -1
  90. package/web/dist/assets/{wardleyDiagram-EHGQE667-CrN8c7Sv.js → wardleyDiagram-EHGQE667-B9Qe44pi.js} +1 -1
  91. package/web/dist/assets/{xychartDiagram-FW5EYKEG-Hym-Hdkg.js → xychartDiagram-FW5EYKEG-BFcc0CLn.js} +1 -1
  92. package/web/dist/index.html +3 -3
  93. package/ui/core/src/themes/dark-high-contrast.js +0 -56
  94. package/ui/core/src/themes/workspace-dark-rich.js +0 -65
  95. package/web/dist/assets/channel-DA38xcdK.js +0 -1
  96. package/web/dist/assets/classDiagram-OUVF2IWQ-1k2XCPxl.js +0 -1
  97. package/web/dist/assets/classDiagram-v2-EOCWNBFH-1k2XCPxl.js +0 -1
  98. package/web/dist/assets/index-Do0UrHeI.css +0 -1
  99. package/web/dist/assets/pilotswarm-C17s5NOb.js +0 -99
  100. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-ClqTpkzd.js +0 -1
  101. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bfar1rKu.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
  }
@@ -2705,238 +2770,6 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2705
2770
  return memoize(lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }]);
2706
2771
  }
2707
2772
 
2708
- // True when a chat message is plain user/assistant prose the rich (block)
2709
- // renderer can format as markdown directly. Everything with terminal-era
2710
- // special handling — splash art, thinking cards, chrome-less summaries,
2711
- // system/PilotSwarm cards, asked-and-answered exchanges, embedded
2712
- // [SYSTEM: …] notice segments — stays on the line renderer, which already
2713
- // knows every edge case.
2714
- function isRichRenderableChatMessage(message) {
2715
- if (!message) return false;
2716
- if (message.kind === "epoch-divider" || message.kind === "regen-refused" || message.kind === "regen-failed") return false;
2717
- if (message.splash || message.thinking || message.noChrome) return false;
2718
- if (message.role !== "user" && message.role !== "assistant") return false;
2719
- if (message.role === "user" && parseAskedAndAnsweredExchange(message?.text || "")) return false;
2720
- const segments = splitSystemNoticeSegments(message?.text || "");
2721
- if (segments.some((segment) => segment.kind === "system")) return false;
2722
- return true;
2723
- }
2724
-
2725
- /**
2726
- * Message-level view of the active chat for the rich (desktop-style) web
2727
- * renderer. Returns an ordered array of blocks:
2728
- *
2729
- * { kind: "message", id, role, header, text, pendingPhase, attachments }
2730
- * — plain user/assistant prose. `header` is describeChatMessageHeader()
2731
- * output; `text` is the artifact-link-decorated markdown source the
2732
- * renderer formats itself; `attachments` is { sessionId, attachments }
2733
- * (the sentinel shape) or null.
2734
- *
2735
- * { kind: "lines", variant, lines }
2736
- * — everything else (splash, thinking cards, system cards, dividers),
2737
- * pre-rendered through the exact same line builders the terminal
2738
- * transcript uses, so no edge case has two implementations.
2739
- *
2740
- * The TUI never calls this; selectChatLines remains the terminal path.
2741
- */
2742
- // Rich message blocks, cached on the message object. Keyed weakly so entries
2743
- // die with the transcript they came from.
2744
- const chatMessageBlockCache = new WeakMap();
2745
-
2746
- export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2747
- const messages = selectActiveChat(state);
2748
- if (!messages || messages.length === 0) return [];
2749
-
2750
- const viewerKey = ownerKeyForOwner(state?.auth?.principal);
2751
- const activeSessionId = state?.sessions?.activeSessionId;
2752
- const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
2753
- const ownerKey = ownerKeyForOwner(activeSession?.owner);
2754
- const sharedContext = Boolean(activeSession && (
2755
- (activeSession.visibility && activeSession.visibility !== "private")
2756
- || (ownerKey && viewerKey && ownerKey !== viewerKey)
2757
- ));
2758
- const buildOptions = {
2759
- ...(options?.tableMode ? { tableMode: options.tableMode } : {}),
2760
- ...(viewerKey ? { viewerKey } : {}),
2761
- ...(ownerKey ? { ownerKey } : {}),
2762
- ...(sharedContext ? { sharedContext: true } : {}),
2763
- };
2764
-
2765
- const blocks = [];
2766
- for (const message of messages) {
2767
- // Fast path FIRST. Classifying a message as rich-renderable scans its
2768
- // text (asked-and-answered parsing, system-notice splitting), and that
2769
- // ran for every message on every call — so a pane-splitter drag paid
2770
- // it across the whole transcript per width step. A cache entry only
2771
- // exists for a message already classified rich, and `kind` is part of
2772
- // what is compared, so a hit is safe to take before classifying.
2773
- // Object.is, NOT ===: `createdAt` is NaN whenever the source event had
2774
- // no parseable timestamp, and NaN === NaN is false, so a plain equality
2775
- // check could never hit for those messages — the cache would silently
2776
- // do nothing while still costing a lookup. Same trap as comparing
2777
- // Infinity by subtraction.
2778
- const cached = message ? chatMessageBlockCache.get(message) : null;
2779
- if (cached
2780
- && Object.is(cached.text, message.text)
2781
- && Object.is(cached.kind, message.kind)
2782
- && Object.is(cached.role, message.role)
2783
- && Object.is(cached.id, message.id)
2784
- && Object.is(cached.pendingPhase, message.pendingPhase)
2785
- && Object.is(cached.stopped, message.stopped)
2786
- // The header is derived from these, and the chips from attachments.
2787
- // Messages are mutated in place (pendingPhase provably is), so every
2788
- // field the block is built from has to be compared — not just the
2789
- // obvious ones.
2790
- && Object.is(cached.createdAt, message.createdAt)
2791
- && Object.is(cached.time, message.time)
2792
- && Object.is(cached.sender, message.sender)
2793
- && Object.is(cached.attachments, message.attachments)
2794
- && Object.is(cached.tableMode, buildOptions.tableMode)
2795
- && Object.is(cached.viewerKey, buildOptions.viewerKey)
2796
- && Object.is(cached.ownerKey, buildOptions.ownerKey)
2797
- && Object.is(cached.sharedContext, buildOptions.sharedContext)) {
2798
- blocks.push(cached.block);
2799
- continue;
2800
- }
2801
-
2802
- if (message?.kind === "epoch-divider") {
2803
- blocks.push({ kind: "lines", variant: "divider", lines: [buildEpochDividerLine(message, maxWidth)] });
2804
- continue;
2805
- }
2806
- if (message?.kind === "regen-failed") {
2807
- blocks.push({ kind: "lines", variant: "divider", lines: [buildRegenFailedLine(message, maxWidth)] });
2808
- continue;
2809
- }
2810
- if (message?.kind === "regen-refused") {
2811
- blocks.push({ kind: "lines", variant: "divider", lines: [buildRegenRefusedLine(message, maxWidth)] });
2812
- continue;
2813
- }
2814
- if (isRichRenderableChatMessage(message)) {
2815
- // A rich message block does NOT depend on maxWidth — the browser
2816
- // wraps this text with CSS; only the terminal needs a column count.
2817
- // Rebuilding it on every width step meant dragging a pane splitter
2818
- // re-parsed artifact links and re-derived headers for the entire
2819
- // loaded transcript to produce byte-identical output. Cache on the
2820
- // message itself, keyed by the inputs that CAN change it.
2821
- const attachmentLines = buildAttachmentChipLines(message, { tableMode: "sentinel" });
2822
- const attachmentSentinel = attachmentLines.find((line) => line?.kind === "imageAttachments") || null;
2823
- const block = {
2824
- kind: "message",
2825
- id: message?.id ?? null,
2826
- role: message.role,
2827
- header: describeChatMessageHeader(message, buildOptions),
2828
- text: decorateArtifactLinksForChat(message?.text || ""),
2829
- pendingPhase: message?.pendingPhase || null,
2830
- attachments: attachmentSentinel
2831
- ? { sessionId: attachmentSentinel.sessionId, attachments: attachmentSentinel.attachments }
2832
- : null,
2833
- };
2834
- chatMessageBlockCache.set(message, {
2835
- block,
2836
- text: message.text,
2837
- kind: message.kind,
2838
- createdAt: message.createdAt,
2839
- time: message.time,
2840
- sender: message.sender,
2841
- attachments: message.attachments,
2842
- role: message.role,
2843
- id: message.id,
2844
- pendingPhase: message.pendingPhase,
2845
- stopped: message.stopped,
2846
- tableMode: buildOptions.tableMode,
2847
- viewerKey: buildOptions.viewerKey,
2848
- ownerKey: buildOptions.ownerKey,
2849
- sharedContext: buildOptions.sharedContext,
2850
- });
2851
- blocks.push(block);
2852
- continue;
2853
- }
2854
- const lineBlock = cachedLineBlock(message, maxWidth, buildOptions);
2855
- if (lineBlock) blocks.push(lineBlock);
2856
- }
2857
- return blocks;
2858
- }
2859
-
2860
- /**
2861
- * Non-message blocks (thinking cards, system cards, splash, chrome-less
2862
- * summaries) go through the terminal line builders, which DO depend on width —
2863
- * so unlike message blocks these cannot be made width-independent. What they
2864
- * can avoid is being rebuilt when the width has not moved, which is every poll:
2865
- * the poll replaces `session`, the portal's useMemo sees a new selectorState,
2866
- * and the whole transcript is re-derived. Measured at 1500 messages with 10%
2867
- * line blocks that was 1.17ms a time, and 5.26ms at 50%.
2868
- *
2869
- * Only the LAST width is kept per message. A drag sweeps widths and will miss
2870
- * on every step — that work is genuinely required, since these lines are
2871
- * wrapped to a column count.
2872
- *
2873
- * The compared fields were enumerated from buildChatMessageLines and the
2874
- * builders it delegates to (message/thinking/system-notice cards). `stopped` is
2875
- * included because history.js sets it IN PLACE on an existing message.
2876
- */
2877
- const chatLineBlockCache = new WeakMap();
2878
-
2879
- function cachedLineBlock(message, maxWidth, buildOptions) {
2880
- if (!message || typeof message !== "object") {
2881
- const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2882
- return lines.length > 0 ? { kind: "lines", variant: "event", lines } : null;
2883
- }
2884
- const cached = chatLineBlockCache.get(message);
2885
- if (cached
2886
- && Object.is(cached.maxWidth, maxWidth)
2887
- && Object.is(cached.text, message.text)
2888
- && Object.is(cached.mobileText, message.mobileText)
2889
- && Object.is(cached.role, message.role)
2890
- && Object.is(cached.time, message.time)
2891
- && Object.is(cached.createdAt, message.createdAt)
2892
- && Object.is(cached.splash, message.splash)
2893
- && Object.is(cached.thinking, message.thinking)
2894
- && Object.is(cached.thinkingLabel, message.thinkingLabel)
2895
- && Object.is(cached.progressKind, message.progressKind)
2896
- && Object.is(cached.noChrome, message.noChrome)
2897
- && Object.is(cached.cardTitle, message.cardTitle)
2898
- && Object.is(cached.cardTitleColor, message.cardTitleColor)
2899
- && Object.is(cached.cardBorderColor, message.cardBorderColor)
2900
- && Object.is(cached.stopped, message.stopped)
2901
- && Object.is(cached.tableMode, buildOptions.tableMode)
2902
- && Object.is(cached.viewerKey, buildOptions.viewerKey)
2903
- && Object.is(cached.ownerKey, buildOptions.ownerKey)
2904
- && Object.is(cached.sharedContext, buildOptions.sharedContext)) {
2905
- return cached.block;
2906
- }
2907
- const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2908
- const block = lines.length > 0 ? { kind: "lines", variant: "event", lines } : null;
2909
- chatLineBlockCache.set(message, {
2910
- block,
2911
- maxWidth,
2912
- text: message.text,
2913
- mobileText: message.mobileText,
2914
- role: message.role,
2915
- time: message.time,
2916
- createdAt: message.createdAt,
2917
- splash: message.splash,
2918
- thinking: message.thinking,
2919
- thinkingLabel: message.thinkingLabel,
2920
- progressKind: message.progressKind,
2921
- noChrome: message.noChrome,
2922
- cardTitle: message.cardTitle,
2923
- cardTitleColor: message.cardTitleColor,
2924
- cardBorderColor: message.cardBorderColor,
2925
- stopped: message.stopped,
2926
- tableMode: buildOptions.tableMode,
2927
- viewerKey: buildOptions.viewerKey,
2928
- ownerKey: buildOptions.ownerKey,
2929
- sharedContext: buildOptions.sharedContext,
2930
- });
2931
- return block;
2932
- }
2933
-
2934
- // A centered inline rule ("──── label ────") for transcript markers. The dash
2935
- // runs are CAPPED (not stretched to maxWidth): the web portal wraps by pixel
2936
- // width, and a maxWidth-long "─" run overflows a narrower pane and wraps the
2937
- // rule mid-label. A short symmetric rule reads as a divider on every width, and
2938
- // when the label alone will not fit it is rendered bare (wraps as plain text,
2939
- // never as dangling dash fragments).
2940
2773
  function buildRuleLine(label, color, maxWidth) {
2941
2774
  const safeWidth = Math.max(24, Number(maxWidth) || 80);
2942
2775
  const room = safeWidth - label.length;
@@ -4390,7 +4223,9 @@ export function selectStatusBar(state) {
4390
4223
  if (state.ui.modal?.type === "sessionAgentPicker") {
4391
4224
  return {
4392
4225
  left: "Select an agent for the new session",
4393
- 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",
4394
4229
  };
4395
4230
  }
4396
4231
  if (state.ui.modal?.type === "sessionGroupPicker") {
@@ -5615,36 +5450,52 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
5615
5450
  const items = Array.isArray(modal.items) ? modal.items : [];
5616
5451
  const selectedIndex = Math.max(0, Number(modal.selectedIndex) || 0);
5617
5452
  const contentWidth = Math.max(24, maxWidth - 4);
5618
- // Items arrive pre-sorted Shared → My agents → Generic (controller);
5619
- // emit a non-clickable heading row at each group boundary. rowItemIndexes
5620
- // maps rendered rows back to item indexes (null = heading) — the same
5621
- // mechanism the model picker uses.
5622
- const GROUP_HEADINGS = { shared: "Shared", mine: "My agents", generic: "" };
5623
- // Package-scope vocabulary only makes sense when packages exist: a pure
5624
- // baked deployment keeps the classic flat list, no headings.
5625
- 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).
5626
5457
  const rows = [];
5627
5458
  const rowItemIndexes = [];
5628
- let lastGroup = null;
5629
5459
  items.forEach((item, index) => {
5630
- const group = item?.group || "shared";
5631
- if (hasPackageItems && group !== lastGroup) {
5632
- const heading = GROUP_HEADINGS[group] ?? "";
5633
- rows.push([{ text: heading ? `── ${heading} ──` : "──", color: "gray" }]);
5634
- rowItemIndexes.push(null);
5635
- lastGroup = group;
5636
- }
5637
5460
  const isSelected = index === selectedIndex;
5638
- const suffix = item?.kind === "generic"
5639
- ? " [generic]"
5640
- : item?.packageName
5641
- ? ` ${item.packageName} ${item.packageSemver || ""}`.trimEnd()
5642
- : ` (${item?.agentName || item?.id || "agent"})`;
5643
- const labelRuns = fitRuns([
5644
- { text: item?.kind === "generic" ? "○ " : "· ", color: "gray" },
5645
- { text: item?.title || item?.agentName || item?.id || "Agent", color: "white", bold: true },
5646
- { text: suffix, color: "gray" },
5647
- ], 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
+
5648
5499
  rows.push(isSelected
5649
5500
  ? buildActiveHighlightLine(labelRuns.map((run) => run.text).join("").padEnd(contentWidth, " "))
5650
5501
  : labelRuns);
@@ -5654,7 +5505,29 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
5654
5505
  const selectedItem = items[selectedIndex] || null;
5655
5506
  const selectedModel = modal.sessionOptions?.model || null;
5656
5507
  const selectedReasoningEffort = modal.sessionOptions?.reasoningEffort || null;
5657
- 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
5658
5531
  ? [
5659
5532
  [{
5660
5533
  text: selectedItem.title || selectedItem.agentName || selectedItem.id || "Agent",
@@ -5667,6 +5540,12 @@ export function selectSessionAgentPickerModal(state, maxWidth = 76) {
5667
5540
  ...(selectedItem.packageName
5668
5541
  ? [[{ text: `Package: ${selectedItem.packageName}@${selectedItem.packageSemver || "?"} · ${selectedItem.packageScope || "shared"}`, color: "gray" }]]
5669
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
+ : []),
5670
5549
  ...(selectedModel ? [[{ text: `Model: ${selectedModel}`, color: "gray" }]] : []),
5671
5550
  ...(selectedReasoningEffort ? [[{ text: `Reasoning: ${selectedReasoningEffort}`, color: "gray" }]] : []),
5672
5551
  [{ text: "", color: "gray" }],
@@ -5922,11 +5801,16 @@ export function selectRenameSessionModal(state, maxWidth = 76) {
5922
5801
  text: " cancel",
5923
5802
  color: "gray",
5924
5803
  }],
5925
- [{ text: "", color: "gray" }],
5926
- [{
5927
- text: "Manual titles stop future automatic LLM title changes for this session.",
5928
- color: "gray",
5929
- }],
5804
+ // A folder has no agent writing titles for it, so the auto-title
5805
+ // caveat is noise there — and worse, it implies a folder is a
5806
+ // session.
5807
+ ...(modal.isGroup ? [] : [
5808
+ [{ text: "", color: "gray" }],
5809
+ [{
5810
+ text: "Manual titles stop future automatic LLM title changes for this session.",
5811
+ color: "gray",
5812
+ }],
5813
+ ]),
5930
5814
  ],
5931
5815
  detailsLines,
5932
5816
  idealWidth: Math.min(
@@ -7632,6 +7516,10 @@ export function selectThemePickerModal(state, maxWidth = 80) {
7632
7516
  const detailsLines = selectedItem
7633
7517
  ? [
7634
7518
  [{ text: `theme: ${selectedItem.description || "Shared theme for the portal and native TUI."}`, color: "white" }],
7519
+ // The portal draws group headings between rows; the TUI list is a
7520
+ // flat scroller with no room for them, so the group is surfaced
7521
+ // here instead. The ORDER still clusters by group in both.
7522
+ ...(selectedItem.group ? [[{ text: `group: ${selectedItem.group}`, color: "gray" }]] : []),
7635
7523
  { text: "", color: "gray" },
7636
7524
  [{
7637
7525
  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: {
@@ -263,6 +268,34 @@ export function createInitialState({ mode = "local", branding = null, docs = nul
263
268
  // Selection to restore when leaving a chat-opened preview, so
264
269
  // following a chat link never disturbs the list.
265
270
  restoreArtifactId: null,
271
+ // The portal's artifact takeover pane: a full-height reader that
272
+ // replaces the inspector AND activity panes in the right column.
273
+ // Opened by chat artifact cards and artifact deep links; the Files
274
+ // list keeps its own stacked list-above-preview view, because
275
+ // taking over the column would hide the list being browsed.
276
+ //
277
+ // The TUI ignores this entirely — it has no such pane.
278
+ paneOpen: false,
279
+ // What the right column looked like BEFORE the pane opened, so ✕
280
+ // can put it back. When the user had already resized the column
281
+ // out of existence, closing must return to that — not conjure an
282
+ // inspector they had deliberately dismissed.
283
+ paneRestoresToHidden: false,
284
+ // Rendered page vs. HTML source. Lifted out of the preview
285
+ // component so the reader pane's single header can own the toggle
286
+ // — the alternative was a second header row stacked under the
287
+ // first, which is exactly the box-in-a-box the pane exists to undo.
288
+ htmlViewMode: "rendered",
289
+ // Zoom for the rendered HTML frame ONLY. Browser zoom scales the
290
+ // whole workspace — the session list and transcript included —
291
+ // when all you wanted was to read a dense chart. This scales the
292
+ // artifact and nothing else.
293
+ htmlZoom: 1,
294
+ // Fit-to-width for the rendered HTML frame. Lifted here for the
295
+ // same reason htmlViewMode is: on mobile the panel is chromeless
296
+ // and the artifact bar owns the controls, so the two surfaces have
297
+ // to read one value.
298
+ htmlFitWidth: false,
266
299
  filter: {
267
300
  scope: "selectedSession",
268
301
  query: "",
@@ -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",