pilotswarm 0.4.0 → 0.5.0

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 (88) hide show
  1. package/mcp/README.md +88 -4
  2. package/mcp/dist/src/context.d.ts +36 -6
  3. package/mcp/dist/src/context.d.ts.map +1 -1
  4. package/mcp/dist/src/context.js +102 -8
  5. package/mcp/dist/src/context.js.map +1 -1
  6. package/mcp/dist/src/resources/artifacts.d.ts +5 -0
  7. package/mcp/dist/src/resources/artifacts.d.ts.map +1 -0
  8. package/mcp/dist/src/resources/artifacts.js +39 -0
  9. package/mcp/dist/src/resources/artifacts.js.map +1 -0
  10. package/mcp/dist/src/resources/capabilities.d.ts +4 -0
  11. package/mcp/dist/src/resources/capabilities.d.ts.map +1 -0
  12. package/mcp/dist/src/resources/capabilities.js +17 -0
  13. package/mcp/dist/src/resources/capabilities.js.map +1 -0
  14. package/mcp/dist/src/resources/graph.d.ts +5 -0
  15. package/mcp/dist/src/resources/graph.d.ts.map +1 -0
  16. package/mcp/dist/src/resources/graph.js +45 -0
  17. package/mcp/dist/src/resources/graph.js.map +1 -0
  18. package/mcp/dist/src/server.d.ts.map +1 -1
  19. package/mcp/dist/src/server.js +61 -2
  20. package/mcp/dist/src/server.js.map +1 -1
  21. package/mcp/dist/src/tools/agents.d.ts.map +1 -1
  22. package/mcp/dist/src/tools/agents.js +3 -2
  23. package/mcp/dist/src/tools/agents.js.map +1 -1
  24. package/mcp/dist/src/tools/artifacts.d.ts +12 -0
  25. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -0
  26. package/mcp/dist/src/tools/artifacts.js +92 -0
  27. package/mcp/dist/src/tools/artifacts.js.map +1 -0
  28. package/mcp/dist/src/tools/capabilities.d.ts +27 -0
  29. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -0
  30. package/mcp/dist/src/tools/capabilities.js +69 -0
  31. package/mcp/dist/src/tools/capabilities.js.map +1 -0
  32. package/mcp/dist/src/tools/debug.d.ts +4 -0
  33. package/mcp/dist/src/tools/debug.d.ts.map +1 -0
  34. package/mcp/dist/src/tools/debug.js +94 -0
  35. package/mcp/dist/src/tools/debug.js.map +1 -0
  36. package/mcp/dist/src/tools/facts-enhanced.d.ts +13 -0
  37. package/mcp/dist/src/tools/facts-enhanced.d.ts.map +1 -0
  38. package/mcp/dist/src/tools/facts-enhanced.js +80 -0
  39. package/mcp/dist/src/tools/facts-enhanced.js.map +1 -0
  40. package/mcp/dist/src/tools/graph.d.ts +12 -0
  41. package/mcp/dist/src/tools/graph.d.ts.map +1 -0
  42. package/mcp/dist/src/tools/graph.js +237 -0
  43. package/mcp/dist/src/tools/graph.js.map +1 -0
  44. package/mcp/dist/src/tools/groups.d.ts +10 -0
  45. package/mcp/dist/src/tools/groups.d.ts.map +1 -0
  46. package/mcp/dist/src/tools/groups.js +107 -0
  47. package/mcp/dist/src/tools/groups.js.map +1 -0
  48. package/mcp/dist/src/tools/models.d.ts.map +1 -1
  49. package/mcp/dist/src/tools/models.js +16 -4
  50. package/mcp/dist/src/tools/models.js.map +1 -1
  51. package/mcp/dist/src/tools/observability.d.ts +13 -0
  52. package/mcp/dist/src/tools/observability.d.ts.map +1 -0
  53. package/mcp/dist/src/tools/observability.js +174 -0
  54. package/mcp/dist/src/tools/observability.js.map +1 -0
  55. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  56. package/mcp/dist/src/tools/sessions.js +120 -94
  57. package/mcp/dist/src/tools/sessions.js.map +1 -1
  58. package/mcp/dist/src/tools/system.d.ts +14 -0
  59. package/mcp/dist/src/tools/system.d.ts.map +1 -0
  60. package/mcp/dist/src/tools/system.js +110 -0
  61. package/mcp/dist/src/tools/system.js.map +1 -0
  62. package/mcp/dist/src/tools/turn-control.d.ts +13 -0
  63. package/mcp/dist/src/tools/turn-control.d.ts.map +1 -0
  64. package/mcp/dist/src/tools/turn-control.js +98 -0
  65. package/mcp/dist/src/tools/turn-control.js.map +1 -0
  66. package/mcp/dist/src/util/respond.d.ts +21 -0
  67. package/mcp/dist/src/util/respond.d.ts.map +1 -0
  68. package/mcp/dist/src/util/respond.js +42 -0
  69. package/mcp/dist/src/util/respond.js.map +1 -0
  70. package/package.json +6 -4
  71. package/tui/bin/tui.js +0 -3
  72. package/tui/src/node-sdk-transport.js +30 -5
  73. package/tui/src/plugin-config.js +36 -2
  74. package/tui/tui-splash-mobile.txt +7 -0
  75. package/ui/core/src/controller.js +104 -7
  76. package/ui/core/src/history.js +20 -2
  77. package/ui/core/src/reducer.js +19 -2
  78. package/ui/core/src/selectors.js +32 -1
  79. package/ui/core/src/state.js +3 -0
  80. package/ui/react/src/web-app.js +122 -10
  81. package/web/dist/assets/{index-DMefB7Wb.js → index-B8ge3rzb.js} +4 -4
  82. package/web/dist/assets/index-DXRIxmIv.css +1 -0
  83. package/web/dist/assets/pilotswarm-Dt1qY6hD.js +90 -0
  84. package/web/dist/assets/{react-BZwsW8pe.js → react-IRUjK5Iz.js} +1 -1
  85. package/web/dist/index.html +4 -4
  86. package/web/runtime.js +33 -2
  87. package/web/dist/assets/index-CBgQQk-j.css +0 -1
  88. package/web/dist/assets/pilotswarm-DyBlqVTY.js +0 -87
@@ -2,6 +2,7 @@ import { UI_COMMANDS, FOCUS_REGIONS, INSPECTOR_TABS, cycleValue } from "./comman
2
2
  import {
3
3
  appendEventToHistory,
4
4
  buildHistoryModel,
5
+ CHAT_HISTORY_EVENT_TYPES,
5
6
  DEFAULT_HISTORY_EVENT_LIMIT,
6
7
  dedupeChatMessages,
7
8
  getNextHistoryEventLimit,
@@ -1833,6 +1834,57 @@ export class PilotSwarmUiController {
1833
1834
  await Promise.all(sessionIds.map((sessionId) => this.syncSessionDetail(sessionId).catch(() => {})));
1834
1835
  }
1835
1836
 
1837
+ /**
1838
+ * Append the events recorded after `afterSeq` to an in-memory history.
1839
+ * Returns the merged history, the unchanged history when there is
1840
+ * nothing new, or null when the delta is clamped (caller reloads).
1841
+ */
1842
+ async catchUpSessionHistory(sessionId, existingHistory, afterSeq) {
1843
+ const CATCH_UP_PAGE_LIMIT = 1000;
1844
+ let newEvents;
1845
+ try {
1846
+ newEvents = await this.transport.getSessionEvents(sessionId, afterSeq, CATCH_UP_PAGE_LIMIT);
1847
+ } catch {
1848
+ return null;
1849
+ }
1850
+ if (!Array.isArray(newEvents) || newEvents.length >= CATCH_UP_PAGE_LIMIT) {
1851
+ return null;
1852
+ }
1853
+ if (newEvents.length === 0) {
1854
+ return existingHistory;
1855
+ }
1856
+ let history = existingHistory;
1857
+ for (const event of newEvents) {
1858
+ history = appendEventToHistory(history, event);
1859
+ }
1860
+ history = {
1861
+ ...history,
1862
+ // Appends must never shrink the expanded window's clamp budget.
1863
+ loadedEventLimit: Math.max(
1864
+ Number(existingHistory.loadedEventLimit) || 0,
1865
+ Array.isArray(history.events) ? history.events.length : 0,
1866
+ ),
1867
+ };
1868
+ this.dispatch({ type: "history/set", sessionId, history });
1869
+ for (const event of newEvents) {
1870
+ this.reconcileOutboxAgainstEvent(sessionId, event);
1871
+ }
1872
+ const derivedModel = extractSessionModelFromEvents(newEvents);
1873
+ const currentSession = this.getState().sessions.byId[sessionId] || { sessionId };
1874
+ const derivedContextUsage = extractSessionContextUsageFromEvents(currentSession.contextUsage, newEvents);
1875
+ if (derivedModel || derivedContextUsage) {
1876
+ this.dispatch({
1877
+ type: "sessions/merged",
1878
+ session: {
1879
+ sessionId,
1880
+ ...(derivedModel || {}),
1881
+ ...(derivedContextUsage ? { contextUsage: derivedContextUsage } : {}),
1882
+ },
1883
+ });
1884
+ }
1885
+ return history;
1886
+ }
1887
+
1836
1888
  async ensureSessionHistory(sessionId, { force = false } = {}) {
1837
1889
  if (!sessionId) return null;
1838
1890
  const existingHistory = this.getState().history.bySessionId.get(sessionId);
@@ -1847,6 +1899,17 @@ export class PilotSwarmUiController {
1847
1899
  return this.sessionHistoryLoads.get(sessionId);
1848
1900
  }
1849
1901
 
1902
+ // Re-entry catch-up: when the expanded window is already in memory,
1903
+ // fetch only the delta after lastSeq and append — the user's pulled-in
1904
+ // older history (and its cursor) survives switching sessions. Fall
1905
+ // back to a full window reload when the delta hits the server's
1906
+ // 1000-row page clamp (too far behind to append reliably).
1907
+ const catchUpFrom = Number(existingHistory?.lastSeq) || 0;
1908
+ if (force && catchUpFrom > 0 && Array.isArray(existingHistory?.events) && existingHistory.events.length > 0) {
1909
+ const caughtUp = await this.catchUpSessionHistory(sessionId, existingHistory, catchUpFrom);
1910
+ if (caughtUp) return caughtUp;
1911
+ }
1912
+
1850
1913
  const loadPromise = (async () => {
1851
1914
  const events = await this.transport.getSessionEvents(sessionId, undefined, requestedLimit);
1852
1915
  const history = {
@@ -3285,6 +3348,7 @@ export class PilotSwarmUiController {
3285
3348
  description: "Open-ended session with no specialized agent boundary.",
3286
3349
  tools: [],
3287
3350
  splash: null,
3351
+ splashMobile: null,
3288
3352
  initialPrompt: null,
3289
3353
  });
3290
3354
  }
@@ -3300,6 +3364,7 @@ export class PilotSwarmUiController {
3300
3364
  description: String(agent?.description || "").trim(),
3301
3365
  tools: Array.isArray(agent?.tools) ? agent.tools.filter(Boolean) : [],
3302
3366
  splash: typeof agent?.splash === "string" && agent.splash.trim() ? agent.splash : null,
3367
+ splashMobile: typeof agent?.splashMobile === "string" && agent.splashMobile.trim() ? agent.splashMobile : null,
3303
3368
  initialPrompt: typeof agent?.initialPrompt === "string" && agent.initialPrompt.trim() ? agent.initialPrompt : null,
3304
3369
  });
3305
3370
  }
@@ -4275,6 +4340,7 @@ export class PilotSwarmUiController {
4275
4340
  ...sessionOptions,
4276
4341
  ...(item.title ? { title: item.title } : {}),
4277
4342
  ...(item.splash ? { splash: item.splash } : {}),
4343
+ ...(item.splashMobile ? { splashMobile: item.splashMobile } : {}),
4278
4344
  ...(item.initialPrompt ? { initialPrompt: item.initialPrompt } : {}),
4279
4345
  });
4280
4346
  return;
@@ -4906,17 +4972,18 @@ export class PilotSwarmUiController {
4906
4972
  this.chatTopHistoryLoadSessionId = null;
4907
4973
  }
4908
4974
 
4909
- async handleChatTopHistoryScrollIntent(requestedScrollOffset) {
4975
+ async handleChatTopHistoryScrollIntent(requestedScrollOffset, options = {}) {
4910
4976
  const state = this.getState();
4911
4977
  const sessionId = state.sessions.activeSessionId;
4912
4978
  if (!sessionId) return;
4913
- if (!this.chatTopHistoryLoadArmed || this.chatTopHistoryLoadSessionId !== sessionId) {
4979
+ if (!options.force && (!this.chatTopHistoryLoadArmed || this.chatTopHistoryLoadSessionId !== sessionId)) {
4914
4980
  this.armChatTopHistoryLoad();
4915
4981
  return;
4916
4982
  }
4917
4983
 
4918
4984
  await this.maybeAutoExpandActiveHistory(requestedScrollOffset, {
4919
4985
  pages: AUTO_HISTORY_SCROLL_PAGE_COUNT,
4986
+ force: Boolean(options.force),
4920
4987
  });
4921
4988
  }
4922
4989
 
@@ -5204,14 +5271,26 @@ export class PilotSwarmUiController {
5204
5271
  return;
5205
5272
  }
5206
5273
 
5207
- const { contentHeight, totalLines } = this.getActiveChatRenderMetrics(state);
5208
- const maxOffset = Math.max(0, totalLines - contentHeight);
5209
- if (targetOffset < maxOffset) return;
5274
+ // The scroll-position gate compares a DOM-derived target offset with
5275
+ // ui-core render metrics, and the two disagree on narrow viewports.
5276
+ // Skip it when the caller vouches for the gesture (options.force: an
5277
+ // explicit touch pull at the top of the pane) or when the transcript
5278
+ // has no real chat messages (splash-only: tall art inflates
5279
+ // totalLines past the viewport, so the gate can never pass).
5280
+ const hasChatMessages = Array.isArray(currentHistory?.chat) && currentHistory.chat.length > 0;
5281
+ if (!options.force && hasChatMessages) {
5282
+ const { contentHeight, totalLines } = this.getActiveChatRenderMetrics(state);
5283
+ const maxOffset = Math.max(0, totalLines - contentHeight);
5284
+ if (targetOffset < maxOffset) return;
5285
+ }
5210
5286
 
5211
5287
  await this.expandSessionHistory(sessionId, {
5212
5288
  requestedScrollOffset: targetOffset,
5213
5289
  autoTriggered: true,
5214
5290
  pages: options.pages,
5291
+ // Chat-driven pull: page backward over renderable message types
5292
+ // only, so each page is transcript instead of raw event noise.
5293
+ eventTypes: CHAT_HISTORY_EVENT_TYPES,
5215
5294
  });
5216
5295
  }
5217
5296
 
@@ -5249,6 +5328,14 @@ export class PilotSwarmUiController {
5249
5328
  const loadPromise = (async () => {
5250
5329
  let history;
5251
5330
  const pagesToLoad = Math.max(1, Math.floor(Number(options.pages) || 1));
5331
+ // Optional server-side type filter (chat pull passes the renderable
5332
+ // message types). A short/empty filtered page means the transcript
5333
+ // is complete, so exhausting it clears hasOlderEvents like a raw
5334
+ // page would. Servers without filter support return unfiltered
5335
+ // pages, which this loop already handles (today's raw paging).
5336
+ const eventTypes = Array.isArray(options.eventTypes) && options.eventTypes.length > 0
5337
+ ? options.eventTypes
5338
+ : undefined;
5252
5339
  if (typeof this.transport.getSessionEventsBefore === "function" && oldestSeq > 0) {
5253
5340
  history = currentHistory || buildHistoryModel([], { requestedLimit: currentLimit });
5254
5341
  for (let pageIndex = 0; pageIndex < pagesToLoad; pageIndex += 1) {
@@ -5265,7 +5352,7 @@ export class PilotSwarmUiController {
5265
5352
  if (Number(history.loadedEventCount || 0) >= AUTO_HISTORY_EVENT_SOFT_CAP) {
5266
5353
  break;
5267
5354
  }
5268
- const olderEvents = await this.transport.getSessionEventsBefore(sessionId, pageOldestSeq, pageLimit);
5355
+ const olderEvents = await this.transport.getSessionEventsBefore(sessionId, pageOldestSeq, pageLimit, eventTypes);
5269
5356
  if (!Array.isArray(olderEvents) || olderEvents.length === 0) {
5270
5357
  history = {
5271
5358
  ...history,
@@ -5321,7 +5408,17 @@ export class PilotSwarmUiController {
5321
5408
  this.reconcileOutboxAgainstEvent(sessionId, event);
5322
5409
  }
5323
5410
 
5324
- if (preserveChatView && previousScrollOffset > 0) {
5411
+ const hadChatBefore = Array.isArray(currentHistory?.chat) && currentHistory.chat.length > 0;
5412
+ if (preserveChatView && !hadChatBefore) {
5413
+ // The splash (or an empty transcript) was showing — there is no
5414
+ // reading position to preserve, so land on the latest messages
5415
+ // instead of wherever the offset math would leave the view.
5416
+ this.dispatch({
5417
+ type: "ui/scroll",
5418
+ pane: "chat",
5419
+ offset: 0,
5420
+ });
5421
+ } else if (preserveChatView && previousScrollOffset > 0) {
5325
5422
  const nextState = this.getState();
5326
5423
  const nextRenderedLines = this.getActiveChatRenderMetrics(nextState).totalLines;
5327
5424
  const addedLines = Math.max(0, nextRenderedLines - previousRenderedLines);
@@ -10,6 +10,16 @@ export const HISTORY_EVENT_LIMIT_STEPS = [
10
10
  10_000,
11
11
  ];
12
12
 
13
+ // The event types buildHistoryModel can render as chat transcript items.
14
+ // Passed as the server-side filter when paging backward for chat history, so
15
+ // noisy sessions (thousands of tool/orchestration events between messages)
16
+ // load transcript pages instead of raw-stream pages.
17
+ export const CHAT_HISTORY_EVENT_TYPES = [
18
+ "user.message",
19
+ "assistant.message",
20
+ "system.message",
21
+ ];
22
+
13
23
  function clampHistoryItems(items, maxItems) {
14
24
  const list = Array.isArray(items) ? items.filter(Boolean) : [];
15
25
  const safeMax = Math.max(DEFAULT_HISTORY_EVENT_LIMIT, Number(maxItems) || DEFAULT_HISTORY_EVENT_LIMIT);
@@ -908,10 +918,17 @@ export function appendEventToHistory(history, event) {
908
918
  }
909
919
 
910
920
  export function createSplashCard(branding, session = null) {
911
- const splash = typeof session?.splash === "string" && session.splash.trim()
921
+ const sessionSplash = typeof session?.splash === "string" && session.splash.trim()
912
922
  ? session.splash
913
- : branding?.splash;
923
+ : null;
924
+ const splash = sessionSplash || branding?.splash;
914
925
  if (!splash) return [];
926
+ // The narrow-viewport variant must come from the same source as the
927
+ // splash it replaces (a session splash never falls back to the branding
928
+ // mobile art). The renderer swaps it in when the main art is wider than
929
+ // the pane.
930
+ const mobileSource = sessionSplash ? session?.splashMobile : branding?.splashMobile;
931
+ const mobileSplash = typeof mobileSource === "string" && mobileSource.trim() ? mobileSource : null;
915
932
  const title = session?.isSystem
916
933
  ? canonicalSystemTitle(session, branding?.title || "PilotSwarm")
917
934
  : (session?.title || branding?.title || "PilotSwarm");
@@ -920,6 +937,7 @@ export function createSplashCard(branding, session = null) {
920
937
  id: `splash:${title}`,
921
938
  role: "system",
922
939
  text: `${splash}\n\n${hint}`,
940
+ ...(mobileSplash ? { mobileText: `${mobileSplash}\n\n${hint}` } : {}),
923
941
  time: "",
924
942
  splash: true,
925
943
  }];
@@ -951,7 +951,16 @@ export function appReducer(state, action) {
951
951
  };
952
952
  }
953
953
 
954
- case "sessions/selected":
954
+ case "sessions/selected": {
955
+ // Per-session chat scroll memory: stash the outgoing session's
956
+ // offset and restore the incoming one's. If new chat arrives on
957
+ // re-entry, history/set's activeChatUpdated reset still snaps to
958
+ // latest — "latest chat, or where you left it".
959
+ const previousActiveId = state.sessions.activeSessionId;
960
+ const savedChatScroll = { ...(state.ui.chatScrollBySession || {}) };
961
+ if (previousActiveId && previousActiveId !== action.sessionId) {
962
+ savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
963
+ }
955
964
  return {
956
965
  ...state,
957
966
  sessions: {
@@ -960,9 +969,10 @@ export function appReducer(state, action) {
960
969
  },
961
970
  ui: {
962
971
  ...state.ui,
972
+ chatScrollBySession: savedChatScroll,
963
973
  scroll: {
964
974
  ...state.ui.scroll,
965
- chat: 0,
975
+ chat: Number(savedChatScroll[action.sessionId]) || 0,
966
976
  inspector: 0,
967
977
  activity: 0,
968
978
  },
@@ -973,6 +983,7 @@ export function appReducer(state, action) {
973
983
  },
974
984
  },
975
985
  };
986
+ }
976
987
 
977
988
  case "ui/fullscreenPane": {
978
989
  const fullscreenPane = normalizeFullscreenPane(action.fullscreenPane);
@@ -1152,6 +1163,8 @@ export function appReducer(state, action) {
1152
1163
  for (const id of ids) nextHistory.delete(id);
1153
1164
  const nextOutbox = cloneOutboxBySessionId(state.outbox?.bySessionId);
1154
1165
  for (const id of ids) delete nextOutbox[id];
1166
+ const nextChatScroll = { ...(state.ui.chatScrollBySession || {}) };
1167
+ for (const id of ids) delete nextChatScroll[id];
1155
1168
  return {
1156
1169
  ...state,
1157
1170
  history: {
@@ -1162,6 +1175,10 @@ export function appReducer(state, action) {
1162
1175
  ...state.outbox,
1163
1176
  bySessionId: nextOutbox,
1164
1177
  },
1178
+ ui: {
1179
+ ...state.ui,
1180
+ chatScrollBySession: nextChatScroll,
1181
+ },
1165
1182
  };
1166
1183
  }
1167
1184
 
@@ -17,6 +17,7 @@ import {
17
17
  parseMarkdownLines,
18
18
  shortModelName,
19
19
  shortSessionId,
20
+ stripTerminalMarkupTags,
20
21
  wrapRunsToDisplayWidth,
21
22
  } from "./formatting.js";
22
23
  import {
@@ -1696,9 +1697,26 @@ function buildThinkingCardLines(message, maxWidth) {
1696
1697
  return lines;
1697
1698
  }
1698
1699
 
1700
+ function splashArtWidth(text) {
1701
+ return String(text || "")
1702
+ .split("\n")
1703
+ .reduce((widest, line) => Math.max(widest, stripTerminalMarkupTags(line).length), 0);
1704
+ }
1705
+
1699
1706
  function buildChatMessageLines(message, maxWidth, options = {}) {
1700
1707
  if (message?.splash) {
1701
- return [{ kind: "markup", value: message.text }];
1708
+ // Swap in the narrow-viewport variant when the main art would
1709
+ // overflow the pane (mobile portal, narrow terminals). When the
1710
+ // width metrics keep the desktop art, still hand renderers the
1711
+ // mobile variant (splashAlt) so environments with real media
1712
+ // queries (the browser) can make the final call by CSS viewport —
1713
+ // character metrics and device width can disagree.
1714
+ const useMobile = message.mobileText && splashArtWidth(message.text) > maxWidth;
1715
+ return [{
1716
+ kind: "markup",
1717
+ value: useMobile ? message.mobileText : message.text,
1718
+ splashAlt: !useMobile && message.mobileText ? message.mobileText : null,
1719
+ }];
1702
1720
  }
1703
1721
 
1704
1722
  if (message?.thinking) {
@@ -2838,6 +2856,15 @@ function formatCompactBytes(value) {
2838
2856
  return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`;
2839
2857
  }
2840
2858
 
2859
+ /** "12.4×" compression ratio (raw / stored); null when either is missing. */
2860
+ function formatCompressionRatio(rawBytes, storedBytes) {
2861
+ const raw = Number(rawBytes);
2862
+ const stored = Number(storedBytes);
2863
+ if (!Number.isFinite(raw) || !Number.isFinite(stored) || raw <= 0 || stored <= 0) return null;
2864
+ const ratio = raw / stored;
2865
+ return `${ratio >= 10 ? ratio.toFixed(0) : ratio.toFixed(1)}×`;
2866
+ }
2867
+
2841
2868
  function summarizeEventPreview(text, maxLength = 18) {
2842
2869
  const normalized = String(text || "")
2843
2870
  .replace(/\s+/g, " ")
@@ -4453,6 +4480,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
4453
4480
  title: "Persistence",
4454
4481
  body: formatKeyValueTable([
4455
4482
  ["Snapshot", formatCompactBytes(summary.snapshotSizeBytes)],
4483
+ ["Uncompressed", summary.rawSizeBytes ? formatCompactBytes(summary.rawSizeBytes) : null],
4484
+ ["Compression", formatCompressionRatio(summary.rawSizeBytes, summary.snapshotSizeBytes)],
4456
4485
  ["Dehydrations", String(summary.dehydrationCount)],
4457
4486
  ["Hydrations", String(summary.hydrationCount)],
4458
4487
  ["Lossy Handoffs", String(summary.lossyHandoffCount)],
@@ -4482,6 +4511,8 @@ function buildSessionStatsLines(state, session, maxWidth) {
4482
4511
  ["Cache Write", formatCompactNumber(tree.totalTokensCacheWrite)],
4483
4512
  ["Hit Ratio", treeHitLabel],
4484
4513
  ["Snapshot", formatCompactBytes(tree.totalSnapshotSizeBytes)],
4514
+ ["Uncompressed", tree.totalRawSizeBytes ? formatCompactBytes(tree.totalRawSizeBytes) : null],
4515
+ ["Compression", formatCompressionRatio(tree.totalRawSizeBytes, tree.totalSnapshotSizeBytes)],
4485
4516
  ["Dehydrations", String(tree.totalDehydrationCount)],
4486
4517
  ["Hydrations", String(tree.totalHydrationCount)],
4487
4518
  ]),
@@ -150,6 +150,9 @@ export function createInitialState({ mode = "local", branding = null, themeId =
150
150
  activity: 0,
151
151
  filePreview: 0,
152
152
  },
153
+ // Per-session chat scroll memory (rows from bottom), restored on
154
+ // sessions/selected and evicted with the session's history.
155
+ chatScrollBySession: {},
153
156
  followBottom: {
154
157
  inspector: true,
155
158
  activity: true,
@@ -55,6 +55,13 @@ const PORTAL_SESSION_HIDDEN_COLUMN_PX = 48;
55
55
  const SCROLL_ROW_HEIGHT = 16;
56
56
  const SCROLL_BOTTOM_EPSILON_PX = 0.5;
57
57
  const PROGRAMMATIC_SCROLL_TOLERANCE_PX = SCROLL_BOTTOM_EPSILON_PX;
58
+ // Minimum downward finger travel (px) while at the top of the chat pane before
59
+ // a touch pull counts as a load-older-history request.
60
+ const TOUCH_TOP_PULL_THRESHOLD_PX = 24;
61
+ // How long after the last user-driven scroll event the pane still counts as
62
+ // momentum-scrolling (native flick glide), during which programmatic scrollTop
63
+ // restores are suppressed so they don't kill the glide.
64
+ const TOUCH_MOMENTUM_GRACE_MS = 700;
58
65
  const PROFILE_SETTINGS_POLL_MS = 5000;
59
66
  const REASONING_EFFORT_LABELS = new Set(["low", "medium", "high", "xhigh"]);
60
67
  const LEGACY_BROWSER_PREFERENCE_STORAGE_KEYS = [
@@ -335,9 +342,17 @@ function normalizeLines(lines) {
335
342
  // Markup lines carry preformatted art (splash screens). Tag them
336
343
  // so the chat renderer keeps each line intact: the pane's
337
344
  // pre-wrap/overflow-wrap rules would rewrap wide art lines at
338
- // arbitrary points and scramble the image.
339
- for (const parsedLine of parseTerminalMarkupRuns(line.value || "")) {
340
- normalized.push({ kind: "runs", runs: parsedLine, preserve: true });
345
+ // arbitrary points and scramble the image. When a splashAlt is
346
+ // present, emit BOTH variants tagged by name — CSS shows exactly
347
+ // one based on the real device viewport, which beats character
348
+ // metrics at deciding what actually fits.
349
+ const variants = line.splashAlt
350
+ ? [{ text: line.value, variant: "desktop" }, { text: line.splashAlt, variant: "mobile" }]
351
+ : [{ text: line.value, variant: null }];
352
+ for (const v of variants) {
353
+ for (const parsedLine of parseTerminalMarkupRuns(v.text || "")) {
354
+ normalized.push({ kind: "runs", runs: parsedLine, preserve: true, splashVariant: v.variant });
355
+ }
341
356
  }
342
357
  continue;
343
358
  }
@@ -515,11 +530,32 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
515
530
  scrollMode,
516
531
  scrollOffset,
517
532
  });
533
+ // Touch scrolling relies on native momentum for speed: a hard flick keeps
534
+ // scrolling long after the finger lifts. Re-asserting scrollTop from state
535
+ // on every render (live events, status updates) kills that momentum at the
536
+ // first re-render, so flicks degrade to slow drags. While a touch gesture
537
+ // or its momentum is in flight, the DOM is the source of truth and state
538
+ // echoes of our own scroll dispatches must not snap the pane back.
539
+ const userScrollRef = React.useRef({ touching: false, lastUserScrollAt: 0, lastDispatchedOffset: null });
518
540
 
519
541
  React.useLayoutEffect(() => {
520
542
  const node = ref.current;
521
543
  if (!node) return;
522
544
  const previousViewportState = previousViewportStateRef.current;
545
+ const interaction = userScrollRef.current;
546
+ const now = typeof performance !== "undefined" ? performance.now() : Date.now();
547
+ const interacting = interaction.touching
548
+ || (now - (interaction.lastUserScrollAt || 0)) < TOUCH_MOMENTUM_GRACE_MS;
549
+ const isEchoOffset = interaction.lastDispatchedOffset != null
550
+ && Math.abs((Number(scrollOffset) || 0) - interaction.lastDispatchedOffset) < 2;
551
+ if (
552
+ interacting
553
+ && scrollMode === previousViewportState?.scrollMode
554
+ && (scrollOffset === previousViewportState?.scrollOffset || isEchoOffset)
555
+ ) {
556
+ previousViewportStateRef.current = { scrollMode, scrollOffset };
557
+ return;
558
+ }
523
559
  const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
524
560
  const preservePausedStickyScroll = stickyBottom
525
561
  && scrollMode === "top"
@@ -559,6 +595,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
559
595
  }
560
596
  programmaticScrollRef.current = null;
561
597
  }
598
+ userScrollRef.current.lastUserScrollAt = typeof performance !== "undefined" ? performance.now() : Date.now();
562
599
  const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
563
600
  // When the pane has no scrollable content (transient loading state
564
601
  // that briefly collapses the body to one line), the browser auto-
@@ -568,9 +605,11 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
568
605
  // the desired scroll position from preserved state.
569
606
  if (maxScroll <= 0) return;
570
607
  if (stickyBottom && typeof controller.updatePaneScrollFromViewport === "function") {
608
+ const rowOffset = Math.max(0, node.scrollTop) / SCROLL_ROW_HEIGHT;
609
+ userScrollRef.current.lastDispatchedOffset = rowOffset;
571
610
  controller.updatePaneScrollFromViewport(
572
611
  paneKey,
573
- Math.max(0, node.scrollTop) / SCROLL_ROW_HEIGHT,
612
+ rowOffset,
574
613
  { atBottom: isScrollViewportAtBottom(node) },
575
614
  );
576
615
  return;
@@ -579,6 +618,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
579
618
  const pixels = scrollMode === "bottom"
580
619
  ? Math.max(0, maxScroll - node.scrollTop)
581
620
  : Math.max(0, node.scrollTop);
621
+ userScrollRef.current.lastDispatchedOffset = pixels / SCROLL_ROW_HEIGHT;
582
622
  controller.dispatch({
583
623
  type: "ui/scroll",
584
624
  pane: paneKey,
@@ -597,7 +637,42 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
597
637
  controller.handleChatTopHistoryScrollIntent?.(maxScroll / SCROLL_ROW_HEIGHT);
598
638
  }, [controller, paneKey, ref, scrollMode]);
599
639
 
600
- return { normalizedLines, onScroll, onWheel };
640
+ // Touch equivalent of the wheel-at-top gesture: touch devices never fire
641
+ // wheel events, and once scrollTop sits at 0 a further swipe-down emits no
642
+ // scroll events either — so mobile had no way to request older history.
643
+ // A downward pull that starts while the pane is at (or near) the top fires
644
+ // the same top-history intent, once per gesture.
645
+ const touchPullRef = React.useRef({ startY: null, fired: false });
646
+ const onTouchStart = React.useCallback((event) => {
647
+ userScrollRef.current.touching = true;
648
+ if (paneKey !== "chat" || scrollMode !== "bottom") return;
649
+ touchPullRef.current = { startY: event.touches?.[0]?.clientY ?? null, fired: false };
650
+ }, [paneKey, scrollMode]);
651
+ const onTouchEnd = React.useCallback(() => {
652
+ userScrollRef.current.touching = false;
653
+ // Momentum continues past the finger lift; onScroll keeps refreshing
654
+ // lastUserScrollAt for as long as the glide emits scroll events.
655
+ userScrollRef.current.lastUserScrollAt = typeof performance !== "undefined" ? performance.now() : Date.now();
656
+ }, []);
657
+ const onTouchMove = React.useCallback((event) => {
658
+ const node = ref.current;
659
+ const pull = touchPullRef.current;
660
+ if (!node || paneKey !== "chat" || scrollMode !== "bottom") return;
661
+ if (pull.fired || pull.startY == null) return;
662
+ if (node.scrollTop > PROGRAMMATIC_SCROLL_TOLERANCE_PX) return;
663
+ const y = event.touches?.[0]?.clientY;
664
+ if (y == null || y - pull.startY < TOUCH_TOP_PULL_THRESHOLD_PX) return;
665
+ pull.fired = true;
666
+ // A deliberate pull at the top of the pane is an unambiguous request:
667
+ // force the load, bypassing both the arm/load two-stage handshake and
668
+ // the DOM-vs-render-metric offset gate (the two measurements disagree
669
+ // on narrow viewports). The wheel path keeps the handshake — one
670
+ // physical scroll emits MANY wheel events, so it needs debouncing.
671
+ const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
672
+ controller.handleChatTopHistoryScrollIntent?.(maxScroll / SCROLL_ROW_HEIGHT + 1, { force: true });
673
+ }, [controller, paneKey, ref, scrollMode]);
674
+
675
+ return { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd };
601
676
  }
602
677
 
603
678
  function Runs({ runs, theme }) {
@@ -1255,12 +1330,13 @@ function parseStructuredChatBlocks(lines = []) {
1255
1330
  // detection entirely and render as one preserve block, so no line
1256
1331
  // ever rewraps or gets reinterpreted as a structured element.
1257
1332
  if (currentLine?.preserve) {
1333
+ const variant = currentLine.splashVariant || null;
1258
1334
  const preserveLines = [];
1259
- while (index < lines.length && lines[index]?.preserve) {
1335
+ while (index < lines.length && lines[index]?.preserve && (lines[index].splashVariant || null) === variant) {
1260
1336
  preserveLines.push(lines[index]);
1261
1337
  index += 1;
1262
1338
  }
1263
- blocks.push({ type: "preserve", lines: preserveLines });
1339
+ blocks.push({ type: "preserve", lines: preserveLines, splashVariant: variant });
1264
1340
  continue;
1265
1341
  }
1266
1342
 
@@ -1416,7 +1492,8 @@ function StructuredChatBlocks({ lines, theme }) {
1416
1492
  return React.createElement(React.Fragment, null,
1417
1493
  blocks.map((block, index) => {
1418
1494
  if (block.type === "preserve") {
1419
- return React.createElement("div", { key: `preserve:${index}`, className: "ps-chat-preserve-block" },
1495
+ const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
1496
+ return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
1420
1497
  block.lines.map((line, lineIndex) => React.createElement(Line, {
1421
1498
  key: `preserve:${index}:${lineIndex}`,
1422
1499
  line,
@@ -1643,7 +1720,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1643
1720
  const ref = React.useRef(null);
1644
1721
  const stickyRef = React.useRef(null);
1645
1722
  const syncingHorizontalRef = React.useRef(false);
1646
- const { normalizedLines, onScroll, onWheel } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
1723
+ const { normalizedLines, onScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd } = useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom });
1647
1724
  const normalizedSticky = React.useMemo(() => normalizeLines(stickyLines), [stickyLines]);
1648
1725
  const normalizedBottomSticky = React.useMemo(() => normalizeLines(bottomStickyLines), [bottomStickyLines]);
1649
1726
  const preserveHorizontalScroll = className.includes("is-preserve") && panelClassName.includes("has-preserved-sticky");
@@ -1680,7 +1757,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
1680
1757
  normalizedSticky.map((line, index) => React.createElement(Line, { key: `sticky:${index}`, line, theme })),
1681
1758
  )
1682
1759
  : null,
1683
- React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel },
1760
+ React.createElement("div", { ref, className: `ps-scroll-panel ${className}`.trim(), onScroll: handleBodyScroll, onWheel, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: onTouchEnd },
1684
1761
  typeof renderBody === "function"
1685
1762
  ? renderBody(normalizedLines, theme)
1686
1763
  : structuredBlocks
@@ -4078,6 +4155,41 @@ export function PilotSwarmWebApp({ controller }) {
4078
4155
  const viewport = useMeasuredViewport(viewportRef);
4079
4156
  const mainGridViewport = useMeasuredViewport(mainGridRef);
4080
4157
  const gridViewport = computeGridViewport(viewport);
4158
+
4159
+ // Publish the real viewport in character cells. Without this the ui-core
4160
+ // layout model runs on its 120×40 fallback everywhere in the browser, so
4161
+ // width-derived behavior (markdown wrap width, render metrics, the
4162
+ // splashMobile width swap) assumed a ~120-column terminal regardless of
4163
+ // device — phones kept getting desktop splash art narrower than ~76 cols.
4164
+ React.useEffect(() => {
4165
+ const publish = () => {
4166
+ const probe = document.createElement("span");
4167
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
4168
+ // Measure with the font real lines render in — an off-context
4169
+ // probe can inherit a different size and skew the column count.
4170
+ const sample = document.querySelector(".ps-scroll-panel .ps-line") || document.querySelector(".ps-line");
4171
+ if (sample) probe.style.font = window.getComputedStyle(sample).font;
4172
+ probe.textContent = "0".repeat(100);
4173
+ (sample?.parentElement || document.body).appendChild(probe);
4174
+ const charWidth = (probe.getBoundingClientRect().width / 100) || 8;
4175
+ probe.remove();
4176
+ controller.dispatch({
4177
+ type: "ui/viewport",
4178
+ width: Math.floor(window.innerWidth / charWidth),
4179
+ height: Math.floor(window.innerHeight / SCROLL_ROW_HEIGHT),
4180
+ });
4181
+ };
4182
+ publish();
4183
+ let timer;
4184
+ const onResize = () => { clearTimeout(timer); timer = setTimeout(publish, 150); };
4185
+ window.addEventListener("resize", onResize);
4186
+ window.addEventListener("orientationchange", onResize);
4187
+ return () => {
4188
+ clearTimeout(timer);
4189
+ window.removeEventListener("resize", onResize);
4190
+ window.removeEventListener("orientationchange", onResize);
4191
+ };
4192
+ }, [controller]);
4081
4193
  const [chatFocusMode, setChatFocusMode] = React.useState(false);
4082
4194
  const [chatFocusPane, setChatFocusPane] = React.useState(null);
4083
4195
  const state = useControllerSelector(controller, (rootState) => ({