pilotswarm 0.5.22 → 0.5.24

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 (83) hide show
  1. package/package.json +2 -1
  2. package/tui/src/node-sdk-transport.js +22 -5
  3. package/ui/core/src/controller.js +329 -6
  4. package/ui/core/src/formatting.js +46 -1
  5. package/ui/core/src/history.js +29 -0
  6. package/ui/core/src/reducer.js +52 -2
  7. package/ui/core/src/selectors.js +288 -11
  8. package/ui/core/src/state.js +12 -1
  9. package/ui/core/src/themes/github-light.js +5 -1
  10. package/ui/core/src/themes/index.js +4 -0
  11. package/ui/core/src/themes/light-high-contrast.js +4 -1
  12. package/ui/core/src/themes/ms-dos.js +84 -0
  13. package/ui/core/src/themes/win95.js +81 -0
  14. package/ui/react/src/web-app.js +1342 -84
  15. package/web/dist/assets/abnfDiagram-VRR7QNED-Bjz_3uNq.js +1 -0
  16. package/web/dist/assets/arc-a3km8Cfi.js +1 -0
  17. package/web/dist/assets/architectureDiagram-ZJ3FMSHR-4twPAKdk.js +36 -0
  18. package/web/dist/assets/blockDiagram-677ZJIJ3-FnFRjWxr.js +132 -0
  19. package/web/dist/assets/c4Diagram-LMCZKHZV-B8R4iXqh.js +10 -0
  20. package/web/dist/assets/channel-BQwFhqIW.js +1 -0
  21. package/web/dist/assets/chunk-2Q5K7J3B-C7dcVQSj.js +1 -0
  22. package/web/dist/assets/chunk-32BRIVSS-DQQ_NOog.js +1 -0
  23. package/web/dist/assets/chunk-5VM5RSS4-BRR5_oWJ.js +15 -0
  24. package/web/dist/assets/chunk-EX3LRPZG-C0Jg_k6w.js +231 -0
  25. package/web/dist/assets/chunk-JWPE2WC7-CoHkckoP.js +1 -0
  26. package/web/dist/assets/chunk-MOJQB5TN-CTIj5Opw.js +88 -0
  27. package/web/dist/assets/chunk-RYQCIY6F-BsP4OCo4.js +1 -0
  28. package/web/dist/assets/chunk-V7JOEXUC-B5KS36_O.js +206 -0
  29. package/web/dist/assets/chunk-VR4S4FIN-Dm92_7ut.js +1 -0
  30. package/web/dist/assets/chunk-XXDRQBXY-BGivbXmv.js +1 -0
  31. package/web/dist/assets/classDiagram-OUVF2IWQ-C12RKvcF.js +1 -0
  32. package/web/dist/assets/classDiagram-v2-EOCWNBFH-C12RKvcF.js +1 -0
  33. package/web/dist/assets/cose-bilkent-JH36ORCC-DVjdL84y.js +1 -0
  34. package/web/dist/assets/cynefin-VYW2F7L2-pcKhk42O.js +166 -0
  35. package/web/dist/assets/cynefinDiagram-TSTJHNR4-CdHQXyCH.js +62 -0
  36. package/web/dist/assets/cytoscape.esm-D3_iZ_3b.js +321 -0
  37. package/web/dist/assets/dagre-VKFMJZFB-Okt50Noy.js +4 -0
  38. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  39. package/web/dist/assets/diagram-FQU43EPY-D52R5ow3.js +3 -0
  40. package/web/dist/assets/diagram-G47NLZAW-KiJTa2OF.js +24 -0
  41. package/web/dist/assets/diagram-NH7WQ7WH-DZ-NFYLH.js +24 -0
  42. package/web/dist/assets/diagram-OA4YK3LP-et-Lf7yH.js +30 -0
  43. package/web/dist/assets/diagram-WEI45ONY-eXhZ7Ksl.js +41 -0
  44. package/web/dist/assets/ebnfDiagram-CCIWWBDH-CTWYusI0.js +1 -0
  45. package/web/dist/assets/erDiagram-Q63AITRT-DwTN2EqF.js +85 -0
  46. package/web/dist/assets/flowDiagram-23GEKE2U-CmxOIpNk.js +156 -0
  47. package/web/dist/assets/ganttDiagram-NO4QXBWP-TsPnDFPN.js +292 -0
  48. package/web/dist/assets/gitGraphDiagram-IHSO6WYX-B_IVM0u8.js +106 -0
  49. package/web/dist/assets/graph-DOmOIIwC.js +1 -0
  50. package/web/dist/assets/{index-CuxEGti7.js → index-CB16o5aO.js} +3 -3
  51. package/web/dist/assets/index-CN_7NnbR.css +1 -0
  52. package/web/dist/assets/infoDiagram-FWYZ7A6U-BFjk6921.js +2 -0
  53. package/web/dist/assets/init-Gi6I4Gst.js +1 -0
  54. package/web/dist/assets/ishikawaDiagram-FXEZZL3T-BiTzJYKW.js +70 -0
  55. package/web/dist/assets/journeyDiagram-5HDEW3XC-eeyGUQSj.js +139 -0
  56. package/web/dist/assets/kanban-definition-HUTT4EX6-DPYZ3frK.js +89 -0
  57. package/web/dist/assets/katex-HP8lGamR.js +257 -0
  58. package/web/dist/assets/layout-D-LzfAck.js +1 -0
  59. package/web/dist/assets/linear-Buu9IOJQ.js +1 -0
  60. package/web/dist/assets/map-DxJ2ADlA.js +1 -0
  61. package/web/dist/assets/mermaid.core-xO1oSQS3.js +308 -0
  62. package/web/dist/assets/mindmap-definition-LN4V7U3C-TasB4KZ4.js +96 -0
  63. package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  64. package/web/dist/assets/pegDiagram-2B236MQR-Btr2u_Xy.js +1 -0
  65. package/web/dist/assets/pieDiagram-ENE6RG2P-0iBpCJcu.js +39 -0
  66. package/web/dist/assets/pilotswarm-v4IdVngK.js +93 -0
  67. package/web/dist/assets/quadrantDiagram-ABIIQ3AL-CbVQOzUs.js +7 -0
  68. package/web/dist/assets/railroadDiagram-RFXS5EU6-BkwzDhMx.js +1 -0
  69. package/web/dist/assets/requirementDiagram-TGXJPOKE-DpF9yvmD.js +84 -0
  70. package/web/dist/assets/sankeyDiagram-HTMAVEWB-CjvbElms.js +40 -0
  71. package/web/dist/assets/sequenceDiagram-DBY2YBRQ-DRj2K28H.js +162 -0
  72. package/web/dist/assets/sizeCapture-X5ZJPWSS-Bi3gxz8F.js +1 -0
  73. package/web/dist/assets/stateDiagram-2N3HPSRC-_INdmyGU.js +1 -0
  74. package/web/dist/assets/stateDiagram-v2-6OUMAXLB-A5HcuE43.js +1 -0
  75. package/web/dist/assets/swimlanes-5IMT3BWC-D1pFtL4Y.js +2 -0
  76. package/web/dist/assets/swimlanesDiagram-G3AALYLV-Bf_Rn9Zk.js +8 -0
  77. package/web/dist/assets/timeline-definition-FHXFAJF6-PYQ9uRSp.js +120 -0
  78. package/web/dist/assets/vennDiagram-L72KCM5P-xCF6u31z.js +34 -0
  79. package/web/dist/assets/wardleyDiagram-EHGQE667-DPcm9Msn.js +78 -0
  80. package/web/dist/assets/xychartDiagram-FW5EYKEG-D9ZNU0hG.js +7 -0
  81. package/web/dist/index.html +3 -3
  82. package/web/dist/assets/index-CxkLM8_a.css +0 -1
  83. package/web/dist/assets/pilotswarm-CjMx44so.js +0 -90
@@ -637,7 +637,8 @@ export function appReducer(state, action) {
637
637
  const hasOwnerFilter = Object.prototype.hasOwnProperty.call(settings, "sessionOwnerFilter");
638
638
  const hasLayout = Object.prototype.hasOwnProperty.call(settings, "layoutAdjustments");
639
639
  const hasChatViewMode = Object.prototype.hasOwnProperty.call(settings, "chatViewMode")
640
- && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
640
+ && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript"
641
+ || settings.chatViewMode === "rich");
641
642
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
642
643
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
643
644
  // A pending/resolved deep-link intent outranks the profile's
@@ -701,7 +702,7 @@ export function appReducer(state, action) {
701
702
  }
702
703
 
703
704
  case "ui/chatViewMode":
704
- if (action.mode !== "summary" && action.mode !== "transcript") {
705
+ if (action.mode !== "summary" && action.mode !== "transcript" && action.mode !== "rich") {
705
706
  return state;
706
707
  }
707
708
  return {
@@ -1921,6 +1922,8 @@ export function appReducer(state, action) {
1921
1922
  downloads: {},
1922
1923
  };
1923
1924
  const entries = normalizeArtifactEntries(action.entries);
1925
+ // Stamped by the caller, never Date.now() here — reducers stay pure.
1926
+ const fetchedAt = Number(action.fetchedAt) || 0;
1924
1927
  const hasFilename = (filename) => entries.some((entry) => entry.filename === filename);
1925
1928
  const selectedFilename = current.selectedFilename && hasFilename(current.selectedFilename)
1926
1929
  ? current.selectedFilename
@@ -1933,6 +1936,10 @@ export function appReducer(state, action) {
1933
1936
  selectedFilename,
1934
1937
  loading: false,
1935
1938
  loaded: true,
1939
+ // Stored, not just computed — without this the TTL guard read
1940
+ // undefined, treated every list as ancient, and refetched on
1941
+ // every poll cycle. That was the ~3s flicker.
1942
+ fetchedAt,
1936
1943
  error: null,
1937
1944
  };
1938
1945
  return {
@@ -1998,6 +2005,39 @@ export function appReducer(state, action) {
1998
2005
  };
1999
2006
  }
2000
2007
 
2008
+ case "files/toggleMark": {
2009
+ const id = String(action.artifactId || "");
2010
+ if (!id) return state;
2011
+ const marked = new Set(state.files.markedIds || []);
2012
+ if (marked.has(id)) marked.delete(id);
2013
+ else marked.add(id);
2014
+ return { ...state, files: { ...state.files, markedIds: [...marked] } };
2015
+ }
2016
+
2017
+ case "files/setMarks":
2018
+ return {
2019
+ ...state,
2020
+ files: {
2021
+ ...state.files,
2022
+ markedIds: Array.isArray(action.artifactIds) ? [...new Set(action.artifactIds.map(String))] : [],
2023
+ },
2024
+ };
2025
+
2026
+ case "files/previewOrigin":
2027
+ return {
2028
+ ...state,
2029
+ files: {
2030
+ ...state.files,
2031
+ previewOrigin: action.origin || null,
2032
+ restoreArtifactId: action.origin === "chat"
2033
+ ? (action.restoreArtifactId ?? state.files.restoreArtifactId ?? null)
2034
+ : null,
2035
+ },
2036
+ };
2037
+
2038
+ case "files/clearMarks":
2039
+ return { ...state, files: { ...state.files, markedIds: [] } };
2040
+
2001
2041
  case "files/selectGlobal":
2002
2042
  return {
2003
2043
  ...state,
@@ -2136,6 +2176,16 @@ export function appReducer(state, action) {
2136
2176
  }
2137
2177
 
2138
2178
  case "files/deleted": {
2179
+ // Fall through to the existing handling, but drop the deleted id
2180
+ // from the marks first so a bulk delete cannot leave phantoms.
2181
+ state = {
2182
+ ...state,
2183
+ files: {
2184
+ ...state.files,
2185
+ markedIds: (state.files.markedIds || [])
2186
+ .filter((id) => id !== `${action.sessionId}/${action.filename}`),
2187
+ },
2188
+ };
2139
2189
  const bySessionId = cloneFilesBySessionId(state.files.bySessionId);
2140
2190
  const current = bySessionId[action.sessionId] || {
2141
2191
  entries: [],
@@ -12,6 +12,7 @@ import {
12
12
  extractHttpLinks,
13
13
  formatHumanDurationSeconds,
14
14
  formatTimestamp,
15
+ formatTimestampCompact,
15
16
  padRunsToDisplayWidth,
16
17
  parseMarkdownLines,
17
18
  shortModelName,
@@ -229,7 +230,15 @@ function shouldDecorateSessionOwners(state) {
229
230
  // distinct human owner — otherwise the owner chip is pure noise on every
230
231
  // row (you are always "you"). A narrowed owner filter is an explicit
231
232
  // multi-user context, so honor that too.
232
- if (state.sessions?.ownerFilter && state.sessions.ownerFilter.all !== true) return true;
233
+ // A narrowed filter counts as an explicit multi-user context ONLY when it
234
+ // names specific owners. The DEFAULT signed-in filter already sets
235
+ // `all: false` AND `includeShared: true` (see
236
+ // defaultOwnerFilterForPrincipal), so testing either of those tagged every
237
+ // row in a single-user deployment with a pointless "you are you" chip.
238
+ // Everything else falls through to counting DISTINCT owners below, which
239
+ // is what the chip actually communicates.
240
+ const ownerFilter = state.sessions?.ownerFilter;
241
+ if (Array.isArray(ownerFilter?.ownerKeys) && ownerFilter.ownerKeys.length > 0) return true;
233
242
  const owners = new Set();
234
243
  for (const session of Object.values(state.sessions?.byId || {})) {
235
244
  if (!session || session.isSystem || session.isGroup) continue;
@@ -316,7 +325,38 @@ function splitTypedSessionTitle(displayTitle) {
316
325
  return { userTitle, typeTitle };
317
326
  }
318
327
 
328
+ // Titles are plain text everywhere they are rendered (terminal runs, DOM
329
+ // text nodes) — never interpreted as HTML. A title that arrived already
330
+ // HTML-escaped therefore shows its entities literally ("PostgreSQL &
331
+ // MySQL"). Decoding the handful of entities an escaper emits repairs those
332
+ // rows on read; the write path also normalizes now, so this covers history.
333
+ const HTML_ENTITY_REPLACEMENTS = [
334
+ [/&lt;/g, "<"],
335
+ [/&gt;/g, ">"],
336
+ [/&quot;/g, '"'],
337
+ [/&#0?39;/g, "'"],
338
+ [/&apos;/g, "'"],
339
+ [/&nbsp;/g, " "],
340
+ // Ampersand LAST: decoding it first would let "&amp;lt;" collapse to "<".
341
+ [/&amp;/g, "&"],
342
+ ];
343
+
344
+ export function decodeHtmlEntitiesForDisplay(value) {
345
+ let text = String(value ?? "");
346
+ if (!text.includes("&")) return text;
347
+ for (const [pattern, replacement] of HTML_ENTITY_REPLACEMENTS) {
348
+ text = text.replace(pattern, replacement);
349
+ }
350
+ return text;
351
+ }
352
+
319
353
  function buildSessionDisplayTitle(session) {
354
+ // Decode at the single exit so the agent-prefixed and typed-title paths
355
+ // are covered too, not just the bare title.
356
+ return decodeHtmlEntitiesForDisplay(buildRawSessionDisplayTitle(session));
357
+ }
358
+
359
+ function buildRawSessionDisplayTitle(session) {
320
360
  const title = String(session?.title || "").trim();
321
361
  const splitTitle = splitAgentPrefixedSessionTitle(session);
322
362
  if (!splitTitle) return title;
@@ -731,6 +771,37 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
731
771
  badgeRuns: [],
732
772
  selectedMetaRuns: detailRuns,
733
773
  detailRuns,
774
+ // Structured row description for renderers that draw real chrome
775
+ // instead of terminal runs (the portal's rich session list): status
776
+ // becomes a dot, depth becomes indentation, the owner/child/cron
777
+ // badges become chips. Purely additive — `runs`/`titleRuns` above
778
+ // stay the terminal's source of truth and the TUI ignores this.
779
+ chrome: {
780
+ kind: session?.isGroup ? "group"
781
+ : session?.isSystem ? "system"
782
+ : session?.serviceKind ? "service"
783
+ : "session",
784
+ title: hasRealTitle ? rawTitle : shortId,
785
+ untitled: !hasRealTitle,
786
+ statusColor: sessionStatusColor(session, mode),
787
+ accentColor: mainColor,
788
+ pinned: isPinned,
789
+ selectMode,
790
+ checked: isSelected,
791
+ owner: shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup
792
+ ? {
793
+ initials: effectiveOwner ? ownerInitials(effectiveOwner) : "?",
794
+ isMine: Boolean(ownerKeyForOwner(state?.auth?.principal)
795
+ && ownerKeyForOwner(effectiveOwner) === ownerKeyForOwner(state?.auth?.principal)),
796
+ }
797
+ : null,
798
+ childBadge: collapseBadge ? { text: collapseBadge.text, color: collapseBadge.color } : null,
799
+ cron: Boolean(cronBadge),
800
+ age: relTime || null,
801
+ model: modelLabel || null,
802
+ shortId,
803
+ ctx: ctxRuns.length > 0 ? { text: ctxRuns[0].text, color: ctxRuns[0].color } : null,
804
+ },
734
805
  };
735
806
  }
736
807
 
@@ -1374,7 +1445,12 @@ function prefixRuns(text, color = "gray", options = {}) {
1374
1445
  }];
1375
1446
  }
1376
1447
 
1377
- function buildChatMessagePrefix(message, options = {}) {
1448
+ // Header facts for one chat message — speaker label, label color, delivery
1449
+ // glyph, and timestamp — extracted from buildChatMessagePrefix so the rich
1450
+ // (block) chat renderer can consume the same labeling rules without the
1451
+ // terminal run/prefix shape. Returns { time, glyph, glyphColor, roleLabel,
1452
+ // roleColor }.
1453
+ function describeChatMessageHeader(message, options = {}) {
1378
1454
  const time = formatTimestamp(message?.createdAt || message?.time);
1379
1455
  // A user.message is labeled from the CURRENT VIEWER's perspective: "You"
1380
1456
  // for the viewer's own messages, the sender's name for anyone else. When
@@ -1459,6 +1535,15 @@ function buildChatMessagePrefix(message, options = {}) {
1459
1535
  : message?.role === "system"
1460
1536
  ? "yellow"
1461
1537
  : "white";
1538
+ // `fromOtherPerson` lets renderers drop the speaker label for the
1539
+ // viewer's own messages and the agent's (a 1:1 chat needs no "You:" /
1540
+ // "Agent:" on every turn) while still naming OTHER humans in a shared
1541
+ // session, where attribution is the whole point.
1542
+ return { time, glyph, glyphColor, roleLabel, roleColor, fromOtherPerson };
1543
+ }
1544
+
1545
+ function buildChatMessagePrefix(message, options = {}) {
1546
+ const { time, glyph, glyphColor, roleLabel, roleColor } = describeChatMessageHeader(message, options);
1462
1547
  const prefix = time ? `[${time}] ` : "";
1463
1548
  const runs = [...prefixRuns(prefix, "gray")];
1464
1549
  if (glyph) {
@@ -2303,6 +2388,8 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2303
2388
  lines.push(buildEpochDividerLine(message, maxWidth));
2304
2389
  } else if (message?.kind === "regen-refused") {
2305
2390
  lines.push(buildRegenRefusedLine(message, maxWidth));
2391
+ } else if (message?.kind === "regen-failed") {
2392
+ lines.push(buildRegenFailedLine(message, maxWidth));
2306
2393
  } else {
2307
2394
  const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
2308
2395
  appendChatBlockLines(lines, messageLines);
@@ -2319,6 +2406,97 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2319
2406
  return lines.length > 0 ? lines : [{ text: "No messages yet.", color: "gray" }];
2320
2407
  }
2321
2408
 
2409
+ // True when a chat message is plain user/assistant prose the rich (block)
2410
+ // renderer can format as markdown directly. Everything with terminal-era
2411
+ // special handling — splash art, thinking cards, chrome-less summaries,
2412
+ // system/PilotSwarm cards, asked-and-answered exchanges, embedded
2413
+ // [SYSTEM: …] notice segments — stays on the line renderer, which already
2414
+ // knows every edge case.
2415
+ function isRichRenderableChatMessage(message) {
2416
+ if (!message) return false;
2417
+ if (message.kind === "epoch-divider" || message.kind === "regen-refused" || message.kind === "regen-failed") return false;
2418
+ if (message.splash || message.thinking || message.noChrome) return false;
2419
+ if (message.role !== "user" && message.role !== "assistant") return false;
2420
+ if (message.role === "user" && parseAskedAndAnsweredExchange(message?.text || "")) return false;
2421
+ const segments = splitSystemNoticeSegments(message?.text || "");
2422
+ if (segments.some((segment) => segment.kind === "system")) return false;
2423
+ return true;
2424
+ }
2425
+
2426
+ /**
2427
+ * Message-level view of the active chat for the rich (desktop-style) web
2428
+ * renderer. Returns an ordered array of blocks:
2429
+ *
2430
+ * { kind: "message", id, role, header, text, pendingPhase, attachments }
2431
+ * — plain user/assistant prose. `header` is describeChatMessageHeader()
2432
+ * output; `text` is the artifact-link-decorated markdown source the
2433
+ * renderer formats itself; `attachments` is { sessionId, attachments }
2434
+ * (the sentinel shape) or null.
2435
+ *
2436
+ * { kind: "lines", variant, lines }
2437
+ * — everything else (splash, thinking cards, system cards, dividers),
2438
+ * pre-rendered through the exact same line builders the terminal
2439
+ * transcript uses, so no edge case has two implementations.
2440
+ *
2441
+ * The TUI never calls this; selectChatLines remains the terminal path.
2442
+ */
2443
+ export function selectChatBlocks(state, maxWidth = 80, options = {}) {
2444
+ const messages = selectActiveChat(state);
2445
+ if (!messages || messages.length === 0) return [];
2446
+
2447
+ const viewerKey = ownerKeyForOwner(state?.auth?.principal);
2448
+ const activeSessionId = state?.sessions?.activeSessionId;
2449
+ const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
2450
+ const ownerKey = ownerKeyForOwner(activeSession?.owner);
2451
+ const sharedContext = Boolean(activeSession && (
2452
+ (activeSession.visibility && activeSession.visibility !== "private")
2453
+ || (ownerKey && viewerKey && ownerKey !== viewerKey)
2454
+ ));
2455
+ const buildOptions = {
2456
+ ...(options?.tableMode ? { tableMode: options.tableMode } : {}),
2457
+ ...(viewerKey ? { viewerKey } : {}),
2458
+ ...(ownerKey ? { ownerKey } : {}),
2459
+ ...(sharedContext ? { sharedContext: true } : {}),
2460
+ };
2461
+
2462
+ const blocks = [];
2463
+ for (const message of messages) {
2464
+ if (message?.kind === "epoch-divider") {
2465
+ blocks.push({ kind: "lines", variant: "divider", lines: [buildEpochDividerLine(message, maxWidth)] });
2466
+ continue;
2467
+ }
2468
+ if (message?.kind === "regen-failed") {
2469
+ blocks.push({ kind: "lines", variant: "divider", lines: [buildRegenFailedLine(message, maxWidth)] });
2470
+ continue;
2471
+ }
2472
+ if (message?.kind === "regen-refused") {
2473
+ blocks.push({ kind: "lines", variant: "divider", lines: [buildRegenRefusedLine(message, maxWidth)] });
2474
+ continue;
2475
+ }
2476
+ if (isRichRenderableChatMessage(message)) {
2477
+ const attachmentLines = buildAttachmentChipLines(message, { tableMode: "sentinel" });
2478
+ const attachmentSentinel = attachmentLines.find((line) => line?.kind === "imageAttachments") || null;
2479
+ blocks.push({
2480
+ kind: "message",
2481
+ id: message?.id ?? null,
2482
+ role: message.role,
2483
+ header: describeChatMessageHeader(message, buildOptions),
2484
+ text: decorateArtifactLinksForChat(message?.text || ""),
2485
+ pendingPhase: message?.pendingPhase || null,
2486
+ attachments: attachmentSentinel
2487
+ ? { sessionId: attachmentSentinel.sessionId, attachments: attachmentSentinel.attachments }
2488
+ : null,
2489
+ });
2490
+ continue;
2491
+ }
2492
+ const lines = buildChatMessageLines(message, maxWidth, buildOptions);
2493
+ if (lines.length > 0) {
2494
+ blocks.push({ kind: "lines", variant: "event", lines });
2495
+ }
2496
+ }
2497
+ return blocks;
2498
+ }
2499
+
2322
2500
  // A centered inline rule ("──── label ────") for transcript markers. The dash
2323
2501
  // runs are CAPPED (not stretched to maxWidth): the web portal wraps by pixel
2324
2502
  // width, and a maxWidth-long "─" run overflows a narrower pane and wraps the
@@ -2368,6 +2546,18 @@ function buildRegenRefusedLine(message, maxWidth) {
2368
2546
  return buildRuleLine(label, "yellow", maxWidth);
2369
2547
  }
2370
2548
 
2549
+ // A regeneration that was accepted then failed downstream. RED (vs yellow for
2550
+ // a refusal): a refusal is the system declining on purpose, a failure is the
2551
+ // pipeline breaking. Names the stage and the underlying cause, because the
2552
+ // agent's own turn will have already claimed success from the optimistic ack.
2553
+ function buildRegenFailedLine(message, maxWidth) {
2554
+ const stage = String(message?.stage || "unknown");
2555
+ const raw = String(message?.error || "").split("\n")[0];
2556
+ const cause = raw.length > 120 ? `${raw.slice(0, 117)}…` : raw;
2557
+ const label = ` ✕ regeneration failed at ${stage}${cause ? ` · ${cause}` : ""} `;
2558
+ return buildRuleLine(label, "red", maxWidth);
2559
+ }
2560
+
2371
2561
  export function selectOutboxOverlayLines(state, maxWidth = 80, options = {}) {
2372
2562
  const messages = selectActiveOutboxMessages(state);
2373
2563
  if (!messages || messages.length === 0) return [];
@@ -2764,6 +2954,40 @@ export function selectFileSessionIdsForScope(state, scope = selectFilesScope(sta
2764
2954
  return ordered;
2765
2955
  }
2766
2956
 
2957
+ /**
2958
+ * Artifacts in the order they appear in the CONVERSATION.
2959
+ *
2960
+ * The Files list is sorted for browsing (alphabetical); the transcript order is
2961
+ * the order the work actually happened in. Stepping through a preview opened
2962
+ * from a chat link should follow the conversation, not the filing cabinet — so
2963
+ * these are two distinct sequences and neither can be derived from the other.
2964
+ *
2965
+ * Ids are `<sessionId>/<filename>`, matching files.selectedArtifactId.
2966
+ */
2967
+ export function selectChatOrderedArtifactIds(state) {
2968
+ const session = selectActiveSession(state);
2969
+ const sessionId = session?.sessionId;
2970
+ if (!sessionId) return [];
2971
+ const history = state.history?.bySessionId?.get?.(sessionId);
2972
+ const events = history?.events || [];
2973
+ const seen = new Set();
2974
+ const ordered = [];
2975
+ const re = /artifact:\/\/([a-f0-9-]+)\/([^\s"'{})\]]+)/g;
2976
+ for (const event of events) {
2977
+ const text = typeof event?.data?.content === "string" ? event.data.content : "";
2978
+ if (!text) continue;
2979
+ re.lastIndex = 0;
2980
+ let m;
2981
+ while ((m = re.exec(text)) !== null) {
2982
+ const id = `${m[1]}/${m[2]}`;
2983
+ if (seen.has(id)) continue;
2984
+ seen.add(id);
2985
+ ordered.push(id);
2986
+ }
2987
+ }
2988
+ return ordered;
2989
+ }
2990
+
2767
2991
  export function selectFileBrowserItems(state) {
2768
2992
  const scope = selectFilesScope(state);
2769
2993
  const activeSessionId = selectActiveSession(state)?.sessionId || null;
@@ -3516,7 +3740,9 @@ function isSequenceOrchestratorType(type) {
3516
3740
  }
3517
3741
 
3518
3742
  function mapEventToSequenceEntry(event) {
3519
- const time = formatTimestamp(event?.createdAt);
3743
+ // Compact: the sequence TIME column is fixed-width, and the full form
3744
+ // truncates any dated row to "26 Jul …", losing the time entirely.
3745
+ const time = formatTimestampCompact(event?.createdAt);
3520
3746
  const nodeLabel = shortNodeLabel(event?.workerNodeId);
3521
3747
  const detailText = eventMessageText(event);
3522
3748
  const preview = summarizeEventPreview(detailText, 20);
@@ -3709,7 +3935,7 @@ function buildSessionStatusSequenceEntry(session) {
3709
3935
  const safeCreatedAtMs = Number.isFinite(createdAtMs) ? createdAtMs : Date.now();
3710
3936
 
3711
3937
  return {
3712
- time: formatTimestamp(safeCreatedAtMs),
3938
+ time: formatTimestampCompact(safeCreatedAtMs),
3713
3939
  createdAtMs: safeCreatedAtMs,
3714
3940
  nodeLabel: "orch",
3715
3941
  color: errorKind === "failed" ? "red" : "yellow",
@@ -3768,6 +3994,39 @@ function buildNodeMapNodeUnionForWindow(state, window) {
3768
3994
  return Array.from(labels).sort((left, right) => left.localeCompare(right));
3769
3995
  }
3770
3996
 
3997
+ /**
3998
+ * Strip trailing pad from the last run of a sequence line.
3999
+ *
4000
+ * Every column is padded to colWidth for alignment, but the FINAL column's
4001
+ * trailing spaces align nothing — there is no column after them. With
4002
+ * `white-space: pre` those spaces occupy real width, so every line rendered at
4003
+ * exactly the computed width and the pane had zero tolerance for rounding
4004
+ * between "columns x char width" and its actual pixel width. The result was a
4005
+ * horizontal scrollbar that never went away, on content that visibly fit.
4006
+ */
4007
+ function trimTrailingRunPad(runs) {
4008
+ const out = runs.slice();
4009
+ while (out.length > 0) {
4010
+ const last = out[out.length - 1];
4011
+ const trimmed = String(last.text ?? "").replace(/\s+$/, "");
4012
+ if (trimmed === "") { out.pop(); continue; }
4013
+ out[out.length - 1] = { ...last, text: trimmed };
4014
+ break;
4015
+ }
4016
+ return out;
4017
+ }
4018
+
4019
+ /** Widest rendered line, so decoration never outruns real content. */
4020
+ function sequenceLineWidth(line) {
4021
+ if (Array.isArray(line?.runs)) {
4022
+ return line.runs.reduce((n, r) => n + displayLength(String(r.text ?? "")), 0);
4023
+ }
4024
+ if (Array.isArray(line)) {
4025
+ return line.reduce((n, r) => n + displayLength(String(r.text ?? "")), 0);
4026
+ }
4027
+ return displayLength(String(line?.text ?? ""));
4028
+ }
4029
+
3771
4030
  function buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth) {
3772
4031
  const runs = [
3773
4032
  { text: padDisplayText("TIME", timeWidth), color: "white", bold: true },
@@ -3777,12 +4036,15 @@ function buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth) {
3777
4036
  if (index > 0) runs.push({ text: " ", color: null });
3778
4037
  runs.push({ text: padDisplayText(nodeLabel, colWidth), color: "white", bold: true });
3779
4038
  });
3780
- return runs;
4039
+ return trimTrailingRunPad(runs);
3781
4040
  }
3782
4041
 
3783
- function buildSequenceDividerLine(nodeLabels, timeWidth, colWidth) {
4042
+ function buildSequenceDividerLine(nodeLabels, timeWidth, colWidth, contentWidth = Infinity) {
4043
+ const full = `${"-".repeat(timeWidth)} ${nodeLabels.map(() => "─".repeat(colWidth)).join(" ")}`;
4044
+ // Clip to the widest real line: a full-width rule under short content is
4045
+ // itself enough to force the scrollbar this trimming exists to remove.
3784
4046
  return plainInspectorLine(
3785
- `${"-".repeat(timeWidth)} ${nodeLabels.map(() => "─".repeat(colWidth)).join(" ")}`,
4047
+ Number.isFinite(contentWidth) ? full.slice(0, Math.max(timeWidth, contentWidth)) : full,
3786
4048
  "gray",
3787
4049
  );
3788
4050
  }
@@ -3871,7 +4133,7 @@ function buildSequenceEventLine(entry, nodeLabels, timeWidth, colWidth) {
3871
4133
  }
3872
4134
  });
3873
4135
 
3874
- return runs;
4136
+ return trimTrailingRunPad(runs);
3875
4137
  }
3876
4138
 
3877
4139
  function buildNodeMapHeaderLine(nodeLabels, colWidth) {
@@ -3977,7 +4239,13 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
3977
4239
  .map((entry) => entry.nodeLabel)
3978
4240
  .filter((nodeLabel) => nodeLabel && nodeLabel !== "orch"))).sort((left, right) => left.localeCompare(right));
3979
4241
  const uniqueNodes = unionNodes.length > 0 ? unionNodes : activeSessionNodes;
3980
- const timeWidth = 8;
4242
+ // Size the TIME column to what is actually in it. A fixed 8 fits "20:10:19"
4243
+ // but silently truncates any dated row, which is precisely the row where
4244
+ // the date matters. Capped so one odd stamp cannot starve the lanes.
4245
+ const timeWidth = Math.min(13, Math.max(
4246
+ 8,
4247
+ ...visibleEntries.map((entry) => displayLength(String(entry.time || ""))),
4248
+ ));
3981
4249
  const availableWidth = Math.max(18, maxWidth);
3982
4250
  const maxNodes = Math.max(1, Math.floor((availableWidth - timeWidth - 1) / 6));
3983
4251
  let nodeLabels = ["orch", ...uniqueNodes];
@@ -4018,12 +4286,21 @@ function buildSequenceViewForSession(state, session, maxWidth, options = {}) {
4018
4286
  }
4019
4287
  }
4020
4288
 
4289
+ // Decoration is sized to real content, never to the pane's nominal width:
4290
+ // a full-width rule under short content forces the very scrollbar the
4291
+ // trimming above exists to remove.
4292
+ const headerLine = buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth);
4293
+ const contentWidth = lines.reduce(
4294
+ (widest, line) => Math.max(widest, sequenceLineWidth(line)),
4295
+ sequenceLineWidth(headerLine),
4296
+ );
4297
+
4021
4298
  return {
4022
4299
  stickyLines: [
4023
4300
  ...statsLines,
4024
4301
  plainInspectorLine(`Window: ${recentWindow.label}`, "gray"),
4025
- buildSequenceHeaderLine(nodeLabels, timeWidth, colWidth),
4026
- buildSequenceDividerLine(nodeLabels, timeWidth, colWidth),
4302
+ headerLine,
4303
+ buildSequenceDividerLine(nodeLabels, timeWidth, colWidth, contentWidth),
4027
4304
  ],
4028
4305
  lines,
4029
4306
  };
@@ -129,7 +129,7 @@ export function createInitialState({ mode = "local", branding = null, themeId =
129
129
  inspectorTab: INSPECTOR_TABS[0],
130
130
  sequenceExpandedTurns: [],
131
131
  sequenceSelectedTurn: null,
132
- chatViewMode: chatViewMode === "summary" ? "summary" : "transcript",
132
+ chatViewMode: (chatViewMode === "summary" || chatViewMode === "rich") ? chatViewMode : "transcript",
133
133
  statsViewMode: "session",
134
134
  prompt: "",
135
135
  promptCursor: 0,
@@ -201,6 +201,17 @@ export function createInitialState({ mode = "local", branding = null, themeId =
201
201
  bySessionId: {},
202
202
  fullscreen: false,
203
203
  selectedArtifactId: null,
204
+ // Bulk selection. Deliberately separate from selectedArtifactId:
205
+ // marking rows must not move the preview, which keeps showing the
206
+ // originally selected artifact.
207
+ markedIds: [],
208
+ // How the preview was opened. "chat" must back out to the chat
209
+ // pane and leave the artifact list exactly as it was; "list" backs
210
+ // out to the list. One back button cannot serve both without this.
211
+ previewOrigin: null,
212
+ // Selection to restore when leaving a chat-opened preview, so
213
+ // following a chat link never disturbs the list.
214
+ restoreArtifactId: null,
204
215
  filter: {
205
216
  scope: "selectedSession",
206
217
  query: "",
@@ -5,7 +5,11 @@ const githubLightTheme = createTheme({
5
5
  label: "GitHub Light",
6
6
  description: "GitHub light palette with crisp neutrals, blue actions, and high-contrast status colors.",
7
7
  page: {
8
- background: "#ffffff",
8
+ // Canvas, deliberately NOT the same as tui.surface (#ffffff). When the
9
+ // page and the panes are both pure white there is nothing for a pane to
10
+ // sit on, and every surface-vs-transparent color-mix in the stylesheet
11
+ // collapses to white. GitHub's own light UI uses exactly this pairing.
12
+ background: "#f6f8fa",
9
13
  foreground: "#1f2328",
10
14
  overlayBackground: "#ffffff",
11
15
  overlayForeground: "#1f2328",
@@ -16,6 +16,8 @@ import darkHighContrastTheme from "./dark-high-contrast.js";
16
16
  import terminalGreenTheme from "./terminal-green.js";
17
17
  import solarizedOpsTheme from "./solarized-ops.js";
18
18
  import highContrastMonoTheme from "./high-contrast-mono.js";
19
+ import win95Theme from "./win95.js";
20
+ import msDosTheme from "./ms-dos.js";
19
21
 
20
22
  const THEMES = Object.freeze([
21
23
  draculaTheme,
@@ -36,6 +38,8 @@ const THEMES = Object.freeze([
36
38
  terminalGreenTheme,
37
39
  solarizedOpsTheme,
38
40
  highContrastMonoTheme,
41
+ win95Theme,
42
+ msDosTheme,
39
43
  ].sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: "base" })));
40
44
 
41
45
  const THEME_MAP = new Map(THEMES.map((theme) => [theme.id, theme]));
@@ -8,7 +8,10 @@ const lightHighContrastTheme = createTheme({
8
8
  label: "Light High Contrast",
9
9
  description: "Pure black on white with maximum-contrast accents — nothing washes out, ever.",
10
10
  page: {
11
- background: "#ffffff",
11
+ // Canvas only — panes stay pure #ffffff (tui.surface), so "black on
12
+ // white" still holds for every piece of text. Without this the panes,
13
+ // headers and composer were all the same white as the page.
14
+ background: "#eef0f2",
12
15
  foreground: "#000000",
13
16
  overlayBackground: "#ffffff",
14
17
  overlayForeground: "#000000",
@@ -0,0 +1,84 @@
1
+ import { createTheme } from "./helpers.js";
2
+
3
+ // MS-DOS 2.0 (1983) — MDA/CGA, text only, no windows.
4
+ //
5
+ // The detail that makes or breaks this: DOS's default attribute was 0x07,
6
+ // which is LIGHT GREY on black, not white. White (#ffffff) was attribute 15,
7
+ // used sparingly for emphasis. A pure white-on-black theme reads as "modern
8
+ // terminal"; #aaaaaa reads as DOS instantly.
9
+ //
10
+ // The palette is the CGA 16, including the quirk everyone remembers — dark
11
+ // yellow rendered as BROWN (#aa5500), never as olive.
12
+ //
13
+ // DOS 2.0 itself drew no boxes; the shell was an 80x25 scrolling field. The
14
+ // code-page-437 framing that index.css adds is how DOS APPLICATIONS looked,
15
+ // and CP437 shipped with the original PC, so it is era-correct even though the
16
+ // 2.0 command line never used it. A multi-pane portal needs some framing, and
17
+ // this is the honest period answer.
18
+ const msDosTheme = createTheme({
19
+ id: "ms-dos",
20
+ label: "MS-DOS 2.0",
21
+ description: "CGA light-grey on black, the 16-colour CGA palette with brown, and CP437 box framing.",
22
+ page: {
23
+ background: "#000000",
24
+ foreground: "#aaaaaa",
25
+ overlayBackground: "#000000",
26
+ overlayForeground: "#aaaaaa",
27
+ // DEVIATION, deliberate: authentic CGA dark grey is #555555, which is
28
+ // roughly 3:1 on black and fails the muted-contrast bar the theme suite
29
+ // enforces. This is the lightest grey that still reads as secondary
30
+ // against #aaaaaa body text while staying legible.
31
+ hintColor: "#8f8f8f",
32
+ modalBackdrop: "rgba(0, 0, 0, 0.78)",
33
+ modalBackground: "#000000",
34
+ modalBorder: "#aaaaaa",
35
+ modalForeground: "#aaaaaa",
36
+ modalMuted: "#8f8f8f",
37
+ // Reverse video — exactly how CGA highlighted a selection.
38
+ modalSelectedBackground: "#aaaaaa",
39
+ modalSelectedBorder: "#ffffff",
40
+ modalSelectedForeground: "#000000",
41
+ },
42
+ terminal: {
43
+ background: "#000000",
44
+ foreground: "#aaaaaa",
45
+ cursor: "#aaaaaa",
46
+ cursorAccent: "#000000",
47
+ selectionBackground: "#aaaaaa",
48
+ // The CGA 16, unmodified.
49
+ black: "#000000",
50
+ red: "#aa0000",
51
+ green: "#00aa00",
52
+ yellow: "#aa5500", // brown — the CGA quirk, not olive
53
+ blue: "#0000aa",
54
+ magenta: "#aa00aa",
55
+ cyan: "#00aaaa",
56
+ white: "#aaaaaa", // attribute 7, the DOS default
57
+ brightBlack: "#8f8f8f",
58
+ brightRed: "#ff5555",
59
+ brightGreen: "#55ff55",
60
+ brightYellow: "#ffff55",
61
+ brightBlue: "#5555ff",
62
+ brightMagenta: "#ff55ff",
63
+ brightCyan: "#55ffff",
64
+ brightWhite: "#ffffff", // attribute 15, emphasis only
65
+ },
66
+ tui: {
67
+ surface: "#000000",
68
+ // Frames are drawn in body grey so they read as CP437 line characters
69
+ // rather than as chrome borrowed from a modern UI.
70
+ border: "#aaaaaa",
71
+ // Bright white is reserved for emphasis, so the operator's own turns
72
+ // get it — the one place attribute 15 is worth spending.
73
+ userChat: "#ffffff",
74
+ userChatLabel: "#ffffff",
75
+ activeHighlightBackground: "#aaaaaa",
76
+ activeHighlightForeground: "#000000",
77
+ selectionBackground: "#aaaaaa",
78
+ selectionForeground: "#000000",
79
+ promptCursorBackground: "#aaaaaa",
80
+ promptCursorForeground: "#000000",
81
+ },
82
+ });
83
+
84
+ export default msDosTheme;