pilotswarm 0.5.15 → 0.5.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "description": "PilotSwarm application package: terminal UI, browser portal + Web API server, and MCP server — one install, three bins.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -78,7 +78,7 @@
78
78
  "hono": "^4.12.10",
79
79
  "ink": "^6.8.0",
80
80
  "jose": "^6.2.2",
81
- "pilotswarm-sdk": "^0.5.15",
81
+ "pilotswarm-sdk": "^0.5.17",
82
82
  "react": "^19.2.4",
83
83
  "react-dom": "^19.2.4",
84
84
  "ws": "^8.18.2"
@@ -1 +1,7 @@
1
- {}
1
+ {
2
+ "deepwiki": {
3
+ "type": "http",
4
+ "url": "https://mcp.deepwiki.com/mcp",
5
+ "tools": ["*"]
6
+ }
7
+ }
@@ -0,0 +1,44 @@
1
+ ---
2
+ schemaVersion: 2
3
+ version: 1.0.0
4
+ name: deepwiki
5
+ title: DeepWiki Code Explorer
6
+ description: Answers questions about any public GitHub repository using the DeepWiki MCP server. Loads ONLY the DeepWiki MCP — no other servers — demonstrating per-agent MCP scoping.
7
+ mcpServers:
8
+ - deepwiki
9
+ inheritDefaultMcpServers: false
10
+ initialPrompt: >
11
+ Introduce yourself as the DeepWiki Code Explorer. Ask the user which public
12
+ GitHub repository (owner/repo) they want to explore and what they want to know.
13
+ ---
14
+
15
+ # DeepWiki Code Explorer
16
+
17
+ You answer questions about **public GitHub codebases** using the **DeepWiki**
18
+ MCP server — the only MCP server available to you. DeepWiki serves
19
+ AI-generated documentation grounded in a repository's code.
20
+
21
+ Your DeepWiki tools:
22
+ - `read_wiki_structure` — list the documentation topics/sections for a repo.
23
+ - `read_wiki_contents` — read the full generated documentation for a repo.
24
+ - `ask_question` — ask a natural-language question about a repo and get an
25
+ answer grounded in its code and docs.
26
+
27
+ All of these take a repository as `owner/repo` (for example `facebook/react`).
28
+
29
+ How to work:
30
+ 1. Identify the target repository as `owner/repo`. If the user names a project
31
+ without the owner, or it's ambiguous, ask which `owner/repo` they mean
32
+ before calling a tool.
33
+ 2. For a specific question, prefer `ask_question`. For an overview or to find
34
+ the right area first, start with `read_wiki_structure`, then drill in with
35
+ `read_wiki_contents` or `ask_question`.
36
+ 3. Ground every answer in what DeepWiki returns and name the repo (and the
37
+ topic/section) you drew from. If DeepWiki has no coverage for a repo, say so
38
+ plainly instead of guessing.
39
+ 4. Keep answers focused and technical; quote identifiers and file/module names
40
+ when they help.
41
+
42
+ You have no web browsing and cannot clone or read repositories directly —
43
+ everything comes through the DeepWiki MCP server. If a question can't be
44
+ answered from DeepWiki, say what's missing.
@@ -1037,8 +1037,20 @@ export class NodeSdkTransport {
1037
1037
  : null;
1038
1038
  if (profile?.githubCopilotKeySet === true) return effectiveModel;
1039
1039
 
1040
- throw new Error(
1041
- "GitHub Copilot key not configured. Set GITHUB_TOKEN on the worker or set your per-user GitHub Copilot key in Admin before creating GitHub Copilot model sessions.",
1040
+ // The caller never picked this Copilot model — it came from the
1041
+ // catalog default. Fall back to the first non-Copilot model instead
1042
+ // of failing a session the user has no key for.
1043
+ if (!model && typeof this.mgmt.listModels === "function") {
1044
+ const fallback = (this.mgmt.listModels() || [])
1045
+ .find((entry) => entry?.providerType !== "github" && entry?.qualifiedName);
1046
+ if (fallback) return fallback.qualifiedName;
1047
+ }
1048
+
1049
+ throw Object.assign(
1050
+ new Error(
1051
+ "GitHub Copilot key missing or invalid. Set GITHUB_TOKEN on the worker or configure your per-user GitHub Copilot key in Admin before using GitHub Copilot models.",
1052
+ ),
1053
+ { code: "GHCP_KEY_MISSING", status: 400 },
1042
1054
  );
1043
1055
  }
1044
1056
 
@@ -216,6 +216,22 @@ const CONTEXT_TIER_LABELS = {
216
216
  long_context: "Long context (larger window, higher cost)",
217
217
  };
218
218
 
219
+ function formatContextWindowSize(value) {
220
+ const tokens = Number(value);
221
+ if (!Number.isSafeInteger(tokens) || tokens <= 0) return null;
222
+ if (tokens % 1_000_000 === 0) return `${tokens / 1_000_000}M`;
223
+ if (tokens % 1_000 === 0) return `${tokens / 1_000}K`;
224
+ return tokens.toLocaleString("en-US");
225
+ }
226
+
227
+ function formatContextTierLabel(tier, tokenLimit) {
228
+ const size = formatContextWindowSize(tokenLimit);
229
+ if (!size) return CONTEXT_TIER_LABELS[tier] || tier;
230
+ return tier === "long_context"
231
+ ? `Long context (${size} tokens, higher cost)`
232
+ : `Default (${size} tokens)`;
233
+ }
234
+
219
235
  function extractSessionModelFromEvents(events = []) {
220
236
  // Only explicit model-change events may update the session's model.
221
237
  // Deriving it from any event that happens to carry a `model` field lets
@@ -1103,6 +1119,7 @@ export class PilotSwarmUiController {
1103
1119
  text: String(prompt || ""),
1104
1120
  createdAt: Date.now(),
1105
1121
  phase: normalizedPhase,
1122
+ attempted: false,
1106
1123
  clientMessageIds: [id],
1107
1124
  };
1108
1125
  }
@@ -1122,9 +1139,9 @@ export class PilotSwarmUiController {
1122
1139
  }
1123
1140
 
1124
1141
  getEditableOutboxItems(sessionId) {
1125
- // Only pending items are editable/cancelable on the client; queued items
1126
- // are durable and need a durable cancel API to remove.
1127
- return this.getPendingOutboxItems(sessionId);
1142
+ // Attempted envelopes are immutable because the server may already
1143
+ // have accepted their IDs even when the client saw a transport error.
1144
+ return this.getPendingOutboxItems(sessionId).filter((item) => item?.attempted !== true);
1128
1145
  }
1129
1146
 
1130
1147
  getPromptEditSessionMatch(sessionId = this.getState().sessions.activeSessionId) {
@@ -1412,7 +1429,11 @@ export class PilotSwarmUiController {
1412
1429
  return false;
1413
1430
  }
1414
1431
 
1415
- const pendingItems = this.getPendingOutboxItems(sessionId);
1432
+ const allPendingItems = this.getPendingOutboxItems(sessionId);
1433
+ const attemptedItem = allPendingItems.find((item) => item?.attempted === true);
1434
+ const pendingItems = attemptedItem
1435
+ ? [attemptedItem]
1436
+ : allPendingItems.filter((item) => item?.attempted !== true);
1416
1437
  if (pendingItems.length === 0) return false;
1417
1438
 
1418
1439
  // Merge all current pending items into a single durable envelope.
@@ -1430,6 +1451,7 @@ export class PilotSwarmUiController {
1430
1451
  text: mergedText,
1431
1452
  createdAt: pendingItems[0].createdAt || Date.now(),
1432
1453
  phase: "pending",
1454
+ attempted: true,
1433
1455
  clientMessageIds: mergedClientMessageIds,
1434
1456
  };
1435
1457
  const pendingIdSet = new Set(pendingItems.map((item) => item.id));
@@ -1480,10 +1502,11 @@ export class PilotSwarmUiController {
1480
1502
  }
1481
1503
  }, 6000);
1482
1504
  } else {
1483
- // Transient failure: revert the merged envelope back to the
1484
- // original pending items so the user can edit/retry them.
1485
- const reverted = items.flatMap((item) => (
1486
- item.id === mergedItem.id ? pendingItems : [item]
1505
+ // Transient failure: preserve the exact attempted envelope.
1506
+ // Re-merging it with fresh messages could make server-side
1507
+ // duplicate suppression drop the fresh content too.
1508
+ const reverted = items.map((item) => (
1509
+ item.id === mergedItem.id ? { ...mergedItem, phase: "pending", attempted: true } : item
1487
1510
  ));
1488
1511
  this.setSessionOutboxItems(sessionId, reverted);
1489
1512
  }
@@ -3763,7 +3786,8 @@ export class PilotSwarmUiController {
3763
3786
  const items = supported.map((tier) => ({
3764
3787
  id: tier,
3765
3788
  tier,
3766
- label: CONTEXT_TIER_LABELS[tier] || tier,
3789
+ tokenLimit: modelItem?.contextWindowSizes?.[tier] || null,
3790
+ label: formatContextTierLabel(tier, modelItem?.contextWindowSizes?.[tier]),
3767
3791
  isDefault: selectedTier === tier,
3768
3792
  }));
3769
3793
  const selectedIndex = Math.max(0, items.findIndex((item) => item.id === selectedTier));
@@ -3800,25 +3824,38 @@ export class PilotSwarmUiController {
3800
3824
  const groupedModels = typeof this.transport.getModelsByProvider === "function"
3801
3825
  ? this.transport.getModelsByProvider()
3802
3826
  : groupModelsByProvider(models);
3827
+ // Whether the current user has a per-user GitHub Copilot key:
3828
+ // true/false when known, null when the transport can't tell (then
3829
+ // no model gets disabled — never lock models out on a guess).
3830
+ const ghcpUserKeySet = await this._resolveGhcpUserKeySet();
3803
3831
  const items = [];
3804
3832
  const groups = groupedModels
3805
3833
  .map((group) => ({
3806
3834
  providerId: group.providerId,
3807
3835
  providerType: group.type || group.providerType,
3808
3836
  models: (group.models || []).map((model) => {
3837
+ const providerType = model.providerType || group.type || group.providerType;
3838
+ // Copilot models with no worker-level token are unusable
3839
+ // for users who haven't stored their own GitHub key.
3840
+ const ghcpKeyMissing = providerType === "github"
3841
+ && model.credentialAvailable === false
3842
+ && ghcpUserKeySet === false;
3809
3843
  const item = {
3810
3844
  id: model.qualifiedName,
3811
3845
  qualifiedName: model.qualifiedName,
3812
3846
  modelName: model.modelName || model.qualifiedName,
3813
3847
  providerId: model.providerId || group.providerId,
3814
- providerType: model.providerType || group.type || group.providerType,
3848
+ providerType,
3815
3849
  description: model.description || "",
3816
3850
  cost: model.cost || null,
3817
3851
  supportedReasoningEfforts: normalizeReasoningEfforts(model.supportedReasoningEfforts),
3818
3852
  defaultReasoningEffort: model.defaultReasoningEffort || null,
3819
3853
  supportedContextTiers: normalizeContextTiers(model.supportedContextTiers),
3820
3854
  defaultContextTier: model.defaultContextTier || null,
3855
+ contextWindowSizes: model.contextWindowSizes || null,
3821
3856
  isDefault: defaultModel === model.qualifiedName,
3857
+ ghcpKeyMissing,
3858
+ disabled: ghcpKeyMissing,
3822
3859
  };
3823
3860
  items.push(item);
3824
3861
  return item;
@@ -3827,7 +3864,12 @@ export class PilotSwarmUiController {
3827
3864
  .filter((group) => group.models.length > 0);
3828
3865
 
3829
3866
  const preferredModel = sessionOptions?.model || defaultModel;
3830
- const selectedIndex = Math.max(0, items.findIndex((model) => model.qualifiedName === preferredModel));
3867
+ // Never preselect a key-blocked Copilot model: when the preferred
3868
+ // model (often the catalog default) is unusable, land on the first
3869
+ // usable model instead.
3870
+ let selectedIndex = items.findIndex((model) => model.qualifiedName === preferredModel && !model.disabled);
3871
+ if (selectedIndex < 0) selectedIndex = items.findIndex((model) => !model.disabled);
3872
+ if (selectedIndex < 0) selectedIndex = 0;
3831
3873
  this.dispatch({
3832
3874
  type: "ui/modal",
3833
3875
  modal: {
@@ -3843,6 +3885,27 @@ export class PilotSwarmUiController {
3843
3885
  this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
3844
3886
  }
3845
3887
 
3888
+ async _resolveGhcpUserKeySet() {
3889
+ // Freshest signal first: the profile the Admin console loaded in this
3890
+ // session (updates immediately after the user saves a key there).
3891
+ const adminProfile = this.getState().admin?.profile;
3892
+ if (typeof adminProfile?.githubCopilotKeySet === "boolean") {
3893
+ return adminProfile.githubCopilotKeySet;
3894
+ }
3895
+ if (typeof this.transport.getCurrentUserProfile !== "function") return null;
3896
+ try {
3897
+ const profile = await this.transport.getCurrentUserProfile();
3898
+ if (profile && typeof profile.githubCopilotKeySet === "boolean") {
3899
+ return profile.githubCopilotKeySet;
3900
+ }
3901
+ // Profile fetch succeeded but no stored flag — a user with no
3902
+ // profile row has no key.
3903
+ return false;
3904
+ } catch {
3905
+ return null;
3906
+ }
3907
+ }
3908
+
3846
3909
  async openSwitchModelPicker(onApplied = null) {
3847
3910
  // Callers (e.g. the portal Manage modal) can be notified when a switch
3848
3911
  // is actually applied — distinct from cancelling the picker — so they
@@ -4903,6 +4966,16 @@ export class PilotSwarmUiController {
4903
4966
  }
4904
4967
  if (modal.type === "modelPicker") {
4905
4968
  const item = modal.items?.[modal.selectedIndex || 0];
4969
+ if (item?.disabled) {
4970
+ // Keep the picker open — the selection is unusable, not wrong.
4971
+ this.dispatch({
4972
+ type: "ui/status",
4973
+ text: item.ghcpKeyMissing
4974
+ ? "This model needs a GitHub Copilot key — add yours in the Admin console first"
4975
+ : "This model is not available",
4976
+ });
4977
+ return;
4978
+ }
4906
4979
  const previousFocus = modal.previousFocus;
4907
4980
  this.dispatch({ type: "ui/modal", modal: null });
4908
4981
  if (previousFocus) {
@@ -5243,6 +5316,7 @@ export class PilotSwarmUiController {
5243
5316
  const items = this.getSessionOutbox(currentUi.promptEdit.sessionId);
5244
5317
  const nextItems = items.map((item) => (
5245
5318
  item.id === currentUi.promptEdit.itemId && item.phase === "pending"
5319
+ && item.attempted !== true
5246
5320
  ? { ...item, text: nextPrompt }
5247
5321
  : item
5248
5322
  ));
@@ -567,8 +567,11 @@ function splitMarkdownTableCells(line) {
567
567
  return cells;
568
568
  }
569
569
 
570
+ // GFM delimiter cells are `:?-+:?` — one dash is enough, so `--:` and `-` are
571
+ // as valid as `---`. Requiring three dashes silently demoted whole tables to
572
+ // raw pipe text whenever a model emitted the shorter alignment form.
570
573
  function isMarkdownTableSeparatorRow(cells) {
571
- return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(String(cell || "").replace(/\s+/g, "")));
574
+ return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(String(cell || "").replace(/\s+/g, "")));
572
575
  }
573
576
 
574
577
  function displayWidth(value) {
@@ -992,6 +995,7 @@ export function buildMessageCardLines({
992
995
  borderColor = "gray",
993
996
  bodyColor = null,
994
997
  fitToContent = false,
998
+ tableMode = null,
995
999
  } = {}) {
996
1000
  const maxWidth = Math.max(fitToContent ? 12 : 20, Number(width) || (fitToContent ? 12 : 20));
997
1001
  const maxContentWidth = Math.max(1, maxWidth - 4);
@@ -999,6 +1003,30 @@ export function buildMessageCardLines({
999
1003
  { text: ` ${String(title || "SYSTEM").toUpperCase()} `, color: titleColor, bold: true },
1000
1004
  ...(timestamp ? [{ text: ` ${String(timestamp)} `, color: "gray" }] : []),
1001
1005
  ];
1006
+
1007
+ // Sentinel mode (browser portal): the card is laid out with CSS, so
1008
+ // emit cardStart/cardEnd bounds around the UNWRAPPED markdown body
1009
+ // instead of drawing box art. Hard-wrapping here would mangle
1010
+ // preformatted content (box-drawn tables, ASCII art) that the browser
1011
+ // renders as real HTML tables or scrollable lines.
1012
+ if (tableMode === "sentinel") {
1013
+ const sentinelBodyLines = parseMarkdownLines(String(body || ""), {
1014
+ width: maxContentWidth,
1015
+ tableMode,
1016
+ });
1017
+ const sentinelTinted = bodyColor
1018
+ ? sentinelBodyLines.map((lineRuns) => (Array.isArray(lineRuns)
1019
+ ? recolorRuns(lineRuns, bodyColor)
1020
+ : lineRuns))
1021
+ : sentinelBodyLines;
1022
+ return [
1023
+ { kind: "cardStart", runs: titleRuns, borderColor },
1024
+ ...sentinelTinted,
1025
+ { kind: "cardEnd" },
1026
+ [{ text: "", color: null }],
1027
+ ];
1028
+ }
1029
+
1002
1030
  const bodyLines = parseMarkdownLines(String(body || ""), { width: maxContentWidth });
1003
1031
  const normalizedBodyLines = bodyLines.length > 0
1004
1032
  ? bodyLines.flatMap((lineRuns) => wrapRunsToDisplayWidth(lineRuns, maxContentWidth))
@@ -186,10 +186,61 @@ function areStructuredValuesEqual(left, right) {
186
186
  return true;
187
187
  }
188
188
 
189
+ function sessionUpdateTimestampMs(session) {
190
+ const value = session?.updatedAt;
191
+ if (typeof value === "number" && Number.isFinite(value)) return value;
192
+ if (value instanceof Date) return value.getTime();
193
+ const parsed = Date.parse(value || "");
194
+ return Number.isFinite(parsed) ? parsed : 0;
195
+ }
196
+
197
+ // True when `nextSession` carries no newer information than what we already
198
+ // hold. Both timestamps must be present — an update with no `updatedAt` is
199
+ // treated as newer, because we cannot prove otherwise.
200
+ function isSameOrOlderSessionUpdate(previousSession, nextSession) {
201
+ const previousAt = sessionUpdateTimestampMs(previousSession);
202
+ const nextAt = sessionUpdateTimestampMs(nextSession);
203
+ return previousAt > 0 && nextAt > 0 && nextAt <= previousAt;
204
+ }
205
+
206
+ // A run that has ended for good. These are authoritative and must always land,
207
+ // even from an update that looks stale — stranding a finished session as
208
+ // "running" is far worse than a brief wrong status.
209
+ const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "cancelled", "error"]);
210
+
211
+ // Session state is written from several concurrent sources: the session-list
212
+ // poll, live events, and a per-session detail fetch. They do not agree
213
+ // instant-to-instant, and the list poll in particular can report a status the
214
+ // orchestration has already moved on from — observed live as:
215
+ //
216
+ // sessions/loaded running + waiting -> waiting prevAt == incomingAt
217
+ // sessions/merged waiting + running -> running (105ms later)
218
+ //
219
+ // while the server's own session row and orchestration both read "running"
220
+ // throughout. Consumers gated on status === "running" — the live-activity
221
+ // strip, the composer's Stop button — blink off for the width of that gap.
222
+ //
223
+ // Refuse ANY non-terminal downgrade out of "running" unless the incoming
224
+ // update is genuinely newer. Deliberately not restricted to idle-like
225
+ // statuses: the status actually observed clobbering a live run was "waiting",
226
+ // and an earlier version of this guard missed the bug by excluding it.
227
+ // Terminal statuses are exempt so a finished run can never be stranded, and
228
+ // the timestamp test means a real transition (which carries a newer
229
+ // updatedAt) still lands immediately.
230
+ function shouldPreserveRunningStatus(previousSession, nextSession) {
231
+ const nextStatus = String(nextSession?.status ?? "").toLowerCase();
232
+ return previousSession?.status === "running"
233
+ && nextStatus !== "running"
234
+ && !TERMINAL_SESSION_STATUSES.has(nextStatus)
235
+ && isSameOrOlderSessionUpdate(previousSession, nextSession);
236
+ }
237
+
189
238
  function mergeDefinedSessionFields(previousSession = {}, nextSession = {}) {
190
239
  let merged = previousSession || {};
240
+ const preserveRunning = shouldPreserveRunningStatus(previousSession, nextSession);
191
241
  for (const [key, value] of Object.entries(nextSession || {})) {
192
242
  if (value === undefined) continue;
243
+ if (key === "status" && preserveRunning) continue;
193
244
  if (key === "pendingQuestion" && isAnsweredPendingQuestion(previousSession, value)) {
194
245
  if (merged === previousSession) {
195
246
  merged = { ...(previousSession || {}) };
@@ -1923,6 +1923,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
1923
1923
  width: Math.max(20, maxWidth),
1924
1924
  titleColor: USER_CHAT_COLOR,
1925
1925
  borderColor: USER_CHAT_COLOR,
1926
+ tableMode: options.tableMode,
1926
1927
  }),
1927
1928
  ...buildChatMessageLines({
1928
1929
  ...message,
@@ -2000,6 +2001,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
2000
2001
  width: Math.max(20, maxWidth),
2001
2002
  titleColor: message?.cardTitleColor || (message?.role === "system" ? "yellow" : "cyan"),
2002
2003
  borderColor: message?.cardBorderColor || "gray",
2004
+ tableMode: options.tableMode,
2003
2005
  ...(isSystemCard ? { bodyColor: "gray", fitToContent: true } : {}),
2004
2006
  });
2005
2007
  }
@@ -3778,7 +3780,7 @@ function buildNodeMapHeaderLine(nodeLabels, colWidth) {
3778
3780
  return runs;
3779
3781
  }
3780
3782
 
3781
- const SEQ_REASONING_EFFORTS = new Set(["low", "medium", "high", "xhigh", "minimal", "none"]);
3783
+ const SEQ_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
3782
3784
 
3783
3785
  function shortModelForSequence(value) {
3784
3786
  const model = String(value || "").trim();
@@ -4086,10 +4088,12 @@ export function selectModelPickerModal(state, maxWidth = 72) {
4086
4088
  ? modal.items.findIndex((item) => item.id === model.id)
4087
4089
  : -1;
4088
4090
  const isSelected = itemIndex === selectedIndex;
4091
+ const isDisabled = Boolean(model.disabled);
4089
4092
  const labelRuns = fitRuns([
4090
4093
  { text: "· ", color: "gray" },
4091
- { text: model.modelName || model.qualifiedName || model.id, color: "white", bold: Boolean(model.isDefault) },
4094
+ { text: model.modelName || model.qualifiedName || model.id, color: isDisabled ? "gray" : "white", bold: Boolean(model.isDefault) && !isDisabled },
4092
4095
  ...(model.cost ? [{ text: ` [${model.cost}]`, color: "gray" }] : []),
4096
+ ...(isDisabled ? [{ text: " ⊘ needs GitHub key", color: "yellow" }] : []),
4093
4097
  ...(model.isDefault ? [{ text: " ← current default", color: "gray" }] : []),
4094
4098
  ], contentWidth);
4095
4099
 
@@ -4126,6 +4130,9 @@ export function selectModelPickerModal(state, maxWidth = 72) {
4126
4130
  ? [[{ text: `Reasoning: ${supportedReasoning.join(", ")}`, color: "gray" }]]
4127
4131
  : []),
4128
4132
  ...(defaultReasoning ? [[{ text: `Default reasoning: ${defaultReasoning}`, color: "gray" }]] : []),
4133
+ ...(selectedItem.disabled
4134
+ ? [[{ text: "Unavailable: requires a GitHub Copilot key. Add yours in the Admin console, then reopen this picker.", color: "yellow" }]]
4135
+ : []),
4129
4136
  [{ text: "", color: "gray" }],
4130
4137
  [{
4131
4138
  text: selectedItem.description || "No description available for this model.",
@@ -4298,11 +4305,12 @@ export function selectContextTierPickerModal(state, maxWidth = 64) {
4298
4305
 
4299
4306
  const selectedItem = items[selectedIndex] || null;
4300
4307
  const modelItem = modal.modelItem || null;
4308
+ const selectedLabel = selectedItem?.label || selectedItem?.id;
4301
4309
  const detailsLines = [
4302
4310
  [{ text: modelItem?.modelName || modelItem?.qualifiedName || "Selected model", color: "white", bold: true }],
4303
4311
  [{ text: `${modelItem?.providerId || "provider"} (${modelItem?.providerType || "provider"})`, color: "gray" }],
4304
4312
  [{ text: "", color: "gray" }],
4305
- [{ text: selectedItem?.id ? `Using context window: ${selectedItem.id}` : "Choose a context window.", color: "white" }],
4313
+ [{ text: selectedLabel ? `Using context window: ${selectedLabel}` : "Choose a context window.", color: "white" }],
4306
4314
  [{ text: "Long context costs more per token.", color: "gray" }],
4307
4315
  ];
4308
4316
 
@@ -422,8 +422,9 @@ function normalizeLines(lines) {
422
422
  continue;
423
423
  }
424
424
  // Sentinel kinds preserved as-is so parseStructuredChatBlocks can
425
- // recognize and render them (e.g. markdownTable → HTML <table>).
426
- if (line?.kind === "markdownTable") {
425
+ // recognize and render them (e.g. markdownTable → HTML <table>,
426
+ // cardStart/cardEnd styled card with structured body).
427
+ if (line?.kind === "markdownTable" || line?.kind === "cardStart" || line?.kind === "cardEnd") {
427
428
  normalized.push(line);
428
429
  continue;
429
430
  }
@@ -593,6 +594,23 @@ export function isScrollViewportAtBottom(node) {
593
594
  return getScrollDistanceToBottom(node) <= SCROLL_BOTTOM_EPSILON_PX;
594
595
  }
595
596
 
597
+ export function computeAnchoredScrollTop(
598
+ node,
599
+ scrollOffset,
600
+ scrollMode,
601
+ preservePausedStickyScroll = false,
602
+ ) {
603
+ if (!node) return 0;
604
+ const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
605
+ if (preservePausedStickyScroll) {
606
+ return Math.max(0, Math.min(node.scrollTop, maxScroll));
607
+ }
608
+ const offsetPixels = Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT;
609
+ return scrollMode === "bottom"
610
+ ? Math.max(0, maxScroll - offsetPixels)
611
+ : Math.min(maxScroll, offsetPixels);
612
+ }
613
+
596
614
  function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller, { stickyBottom = false } = {}) {
597
615
  const normalizedLines = React.useMemo(() => normalizeLines(lines), [lines]);
598
616
  // Programmatic scrollTop assignments fire a 'scroll' event that would
@@ -612,6 +630,23 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
612
630
  // or its momentum is in flight, the DOM is the source of truth and state
613
631
  // echoes of our own scroll dispatches must not snap the pane back.
614
632
  const userScrollRef = React.useRef({ touching: false, lastUserScrollAt: 0, lastDispatchedOffset: null });
633
+ const viewportSizeRef = React.useRef({ width: null, height: null });
634
+ const [viewportRevision, setViewportRevision] = React.useState(0);
635
+
636
+ React.useLayoutEffect(() => {
637
+ const node = ref.current;
638
+ if (!node || typeof ResizeObserver === "undefined") return;
639
+ viewportSizeRef.current = { width: node.clientWidth, height: node.clientHeight };
640
+ const observer = new ResizeObserver(() => {
641
+ const next = { width: node.clientWidth, height: node.clientHeight };
642
+ const previous = viewportSizeRef.current;
643
+ if (next.width === previous.width && next.height === previous.height) return;
644
+ viewportSizeRef.current = next;
645
+ setViewportRevision((revision) => revision + 1);
646
+ });
647
+ observer.observe(node);
648
+ return () => observer.disconnect();
649
+ }, [ref]);
615
650
 
616
651
  React.useLayoutEffect(() => {
617
652
  const node = ref.current;
@@ -631,17 +666,16 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
631
666
  previousViewportStateRef.current = { scrollMode, scrollOffset };
632
667
  return;
633
668
  }
634
- const maxScroll = Math.max(0, node.scrollHeight - node.clientHeight);
635
669
  const preservePausedStickyScroll = stickyBottom
636
670
  && scrollMode === "top"
637
671
  && previousViewportState?.scrollMode === "top"
638
672
  && previousViewportState?.scrollOffset === scrollOffset;
639
- const offsetPixels = Math.max(0, Number(scrollOffset) || 0) * SCROLL_ROW_HEIGHT;
640
- const nextScrollTop = preservePausedStickyScroll
641
- ? Math.max(0, Math.min(node.scrollTop, maxScroll))
642
- : scrollMode === "bottom"
643
- ? Math.max(0, maxScroll - offsetPixels)
644
- : Math.min(maxScroll, offsetPixels);
673
+ const nextScrollTop = computeAnchoredScrollTop(
674
+ node,
675
+ scrollOffset,
676
+ scrollMode,
677
+ preservePausedStickyScroll,
678
+ );
645
679
  if (Math.abs(node.scrollTop - nextScrollTop) > PROGRAMMATIC_SCROLL_TOLERANCE_PX) {
646
680
  const pendingProgrammaticScroll = { target: nextScrollTop };
647
681
  programmaticScrollRef.current = pendingProgrammaticScroll;
@@ -658,7 +692,7 @@ function useScrollSync(ref, lines, scrollOffset, scrollMode, paneKey, controller
658
692
  scrollMode,
659
693
  scrollOffset,
660
694
  };
661
- }, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom]);
695
+ }, [normalizedLines, ref, scrollMode, scrollOffset, stickyBottom, viewportRevision]);
662
696
 
663
697
  const onScroll = React.useCallback(() => {
664
698
  const node = ref.current;
@@ -1415,6 +1449,30 @@ function parseStructuredChatBlocks(lines = []) {
1415
1449
  continue;
1416
1450
  }
1417
1451
 
1452
+ // Sentinel card bounds emitted by buildMessageCardLines in sentinel
1453
+ // mode. The body lines between the bounds are UNWRAPPED, so parse
1454
+ // them recursively — box-drawn/markdown tables inside the card
1455
+ // become real HTML tables instead of hard-wrapped box art.
1456
+ if (currentLine?.kind === "cardStart") {
1457
+ const innerLines = [];
1458
+ index += 1;
1459
+ while (index < lines.length && lines[index]?.kind !== "cardEnd") {
1460
+ innerLines.push(lines[index]);
1461
+ index += 1;
1462
+ }
1463
+ if (index < lines.length) index += 1;
1464
+ if (index < lines.length && lineText(lines[index]).trim().length === 0) {
1465
+ index += 1;
1466
+ }
1467
+ blocks.push({
1468
+ type: "card",
1469
+ headerRuns: Array.isArray(currentLine.runs) ? currentLine.runs : [],
1470
+ borderColor: currentLine.borderColor || "gray",
1471
+ blocks: parseStructuredChatBlocks(innerLines),
1472
+ });
1473
+ continue;
1474
+ }
1475
+
1418
1476
  // Sentinel markdown-table line emitted by parseMarkdownLines when
1419
1477
  // tableMode === "sentinel". Carries the raw header + rows so the
1420
1478
  // portal renders a real HTML table with markdown cell content (so
@@ -1563,9 +1621,15 @@ function parseStructuredChatBlocks(lines = []) {
1563
1621
 
1564
1622
  function StructuredChatBlocks({ lines, theme }) {
1565
1623
  const blocks = React.useMemo(() => parseStructuredChatBlocks(lines), [lines]);
1624
+ return React.createElement(StructuredBlockList, { blocks, theme });
1625
+ }
1566
1626
 
1627
+ // Renders parsed chat blocks; sentinel card blocks recurse through this list
1628
+ // so structured content (box/markdown tables, code fences) inside a card
1629
+ // renders exactly the same as it does at top level.
1630
+ function StructuredBlockList({ blocks, theme }) {
1567
1631
  return React.createElement(React.Fragment, null,
1568
- blocks.map((block, index) => {
1632
+ (blocks || []).map((block, index) => {
1569
1633
  if (block.type === "preserve") {
1570
1634
  const variantClass = block.splashVariant ? ` is-splash-${block.splashVariant}` : "";
1571
1635
  return React.createElement("div", { key: `preserve:${index}`, className: `ps-chat-preserve-block${variantClass}` },
@@ -1593,10 +1657,12 @@ function StructuredChatBlocks({ lines, theme }) {
1593
1657
  React.createElement("header", { className: "ps-chat-card-header" },
1594
1658
  React.createElement(Runs, { runs: block.headerRuns, theme })),
1595
1659
  React.createElement("div", { className: "ps-chat-card-body" },
1596
- (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
1597
- key: `card:${index}:line:${bodyIndex}`,
1598
- className: "ps-chat-card-line",
1599
- }, React.createElement(Runs, { runs: bodyRuns, theme })) )));
1660
+ Array.isArray(block.blocks)
1661
+ ? React.createElement(StructuredBlockList, { blocks: block.blocks, theme })
1662
+ : (block.bodyLines || []).map((bodyRuns, bodyIndex) => React.createElement("div", {
1663
+ key: `card:${index}:line:${bodyIndex}`,
1664
+ className: "ps-chat-card-line",
1665
+ }, React.createElement(Runs, { runs: bodyRuns, theme })) )));
1600
1666
  }
1601
1667
 
1602
1668
  if (block.type === "table") {
@@ -2193,7 +2259,7 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
2193
2259
  },
2194
2260
  React.createElement("div", {
2195
2261
  className: "ps-line ps-session-row-content",
2196
- style: { paddingInlineStart: `${Math.max(0, row.depth) * 18}px` },
2262
+ style: { paddingInlineStart: `${Math.max(0, row.depth) * 4}px` },
2197
2263
  },
2198
2264
  React.createElement(SessionRowContent, { row, theme, structured: structuredRows })),
2199
2265
  )),
@@ -4119,7 +4185,7 @@ function ModalLayer({ controller }) {
4119
4185
  return React.createElement("button", {
4120
4186
  key: item?.id || `row:${rowIndex}`,
4121
4187
  type: "button",
4122
- className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}`,
4188
+ className: `ps-list-button ps-modal-list-button${itemIndex === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
4123
4189
  onClick: () => controller.dispatch({ type: "ui/modalSelection", index: itemIndex }),
4124
4190
  },
4125
4191
  React.createElement("div", { className: "ps-line ps-modal-list-line" },
@@ -4128,7 +4194,7 @@ function ModalLayer({ controller }) {
4128
4194
  : (modal.items || []).map((item, index) => React.createElement("button", {
4129
4195
  key: item.id || index,
4130
4196
  type: "button",
4131
- className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}`,
4197
+ className: `ps-list-button ps-modal-list-button${index === modal.selectedIndex ? " is-selected" : ""}${usesHangingIndent ? " is-hanging" : ""}${item?.disabled ? " is-disabled" : ""}`,
4132
4198
  onClick: () => controller.dispatch({ type: "ui/modalSelection", index }),
4133
4199
  },
4134
4200
  React.createElement("div", { className: "ps-line ps-modal-list-line" },