zidane 5.0.4 → 5.0.5

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/dist/tui.js CHANGED
@@ -2,7 +2,7 @@ import { d as createAgent } from "./tools-CLazLRb4.js";
2
2
  import { n as formatTokenUsage } from "./stats-DZIsGqzu.js";
3
3
  import { n as loadSession, t as createSession } from "./session-791hhrFa.js";
4
4
  import { createTuiStore } from "./session/sqlite.js";
5
- import { $t as piIdOf, C as useSafeModeQueue, Ct as findGitRoot, D as isOnSafelist, E as getSafelist, Et as uniqueSkillNamesFromReferences, F as supportsOAuth, Ft as detectAuth, G as shortId, H as ageString, I as buildMcpServers, J as DEFAULT_SETTINGS, K as listProjectFiles, N as splitPromptSegments, Ot as createFilesCompletionProvider, P as runOAuthLogin, Pt as useCompletion, Q as useSettings, R as discoverProjectMcps, S as useSafeModeActions, St as toolResultText, T as addToSafelist, Tt as createSkillsCompletionProvider, U as compactPath, V as generateSessionTitle, Vt as setProviderCredential, W as fmtTokens, X as SETTINGS_TOGGLES, Y as SETTINGS_CHOICES, Yt as modelSupportsReasoning, Z as SettingsProvider, _ as discoverProjectSkills, a as ThemeProvider, b as writeSessionExport, c as useSurfaces, ct as ConfigProvider, d as finalizeStreamingMarkdown, f as finalizeStreamingMarkdownForOwner, ft as deriveSessionTitle, g as defaultSkillScanPaths, h as buildSkillsConfig, ht as listSessionMeta, i as turnAsText, j as suggestSafelistEntry, lt as useConfig, m as useStreamBuffer, mt as lastContextSizeFromTurns, n as deleteTurnSafely, nt as resolveTheme, o as useColors, p as turnContextSize, pt as eventsFromTurns, q as useEnabledToggleSet, qt as getContextWindow, r as truncateTurnsAt, s as useSelectStyle, tt as resolveChipColor, u as useTheme, ut as resolveConfig, vt as selectableTurnIds, x as SafeModeProvider, xt as toolCallPreview, yt as stripSpawnTokensLine } from "./turn-operations-BF3hMNgo.js";
5
+ import { $ as SETTINGS_TOGGLES, C as useSafeModeQueue, D as isOnSafelist, E as getSafelist, Et as findGitRoot, F as supportsOAuth, G as ageString, I as buildModelCatalog, J as shortId, K as compactPath, L as filterModelCatalog, Lt as useCompletion, N as splitPromptSegments, Ot as createSkillsCompletionProvider, P as runOAuthLogin, Q as SETTINGS_CHOICES, Qt as modelSupportsReasoning, R as indexOfEntry, Rt as detectAuth, S as useSafeModeActions, St as stripSpawnTokensLine, T as addToSafelist, Tt as toolResultText, V as discoverProjectMcps, W as generateSessionTitle, Wt as setProviderCredential, X as useEnabledToggleSet, Xt as getContextWindow, Y as listProjectFiles, Z as DEFAULT_SETTINGS, _ as discoverProjectSkills, _t as lastContextSizeFromTurns, a as ThemeProvider, at as resolveTheme, b as writeSessionExport, c as useSurfaces, d as finalizeStreamingMarkdown, dt as ConfigProvider, et as SettingsProvider, f as finalizeStreamingMarkdownForOwner, ft as useConfig, g as defaultSkillScanPaths, gt as eventsFromTurns, h as buildSkillsConfig, ht as deriveSessionTitle, i as turnAsText, it as resolveChipColor, j as suggestSafelistEntry, jt as createFilesCompletionProvider, kt as uniqueSkillNamesFromReferences, m as useStreamBuffer, n as deleteTurnSafely, nn as piIdOf, o as useColors, p as turnContextSize, pt as resolveConfig, q as fmtTokens, r as truncateTurnsAt, s as useSelectStyle, tt as useSettings, u as useTheme, vt as listSessionMeta, wt as toolCallPreview, x as SafeModeProvider, xt as selectableTurnIds, z as buildMcpServers } from "./turn-operations-DZ3TrljX.js";
6
6
  import { Buffer } from "node:buffer";
7
7
  import { createContext, createElement, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { RGBA, SyntaxStyle, addDefaultParsers, createCliRenderer, defaultTextareaKeyBindings, getTreeSitterClient } from "@opentui/core";
@@ -108,7 +108,7 @@ function Modal({ title, bottomTitle, onClose, disableEscape = false, children, m
108
108
  //#endregion
109
109
  //#region src/tui/agent-picker.tsx
110
110
  /** Cap the scroll window — a long custom registry shouldn't push the modal off-screen. */
111
- const VISIBLE_ROW_CAP$1 = 10;
111
+ const VISIBLE_ROW_CAP = 10;
112
112
  /**
113
113
  * Modal that lists the registered {@link AgentProfile}s and lets the user
114
114
  * pick one. Rows show: `● selected · label description`.
@@ -130,8 +130,8 @@ function AgentPickerModal({ agents, currentAgentId, onPick }) {
130
130
  description: p.description,
131
131
  value: p.id
132
132
  })), [profiles, currentAgentId]);
133
- if (profiles.length === 0) return /* @__PURE__ */ jsx(EmptyState$2, {});
134
- const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP$1);
133
+ if (profiles.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
134
+ const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP);
135
135
  const currentMissing = initialIndex < 0;
136
136
  const safeIndex = currentMissing ? 0 : initialIndex;
137
137
  return /* @__PURE__ */ jsxs(Modal, {
@@ -176,7 +176,7 @@ function AgentPickerModal({ agents, currentAgentId, onPick }) {
176
176
  ]
177
177
  });
178
178
  }
179
- function EmptyState$2() {
179
+ function EmptyState$1() {
180
180
  const COLOR = useColors();
181
181
  return /* @__PURE__ */ jsxs(Modal, {
182
182
  title: "select agent",
@@ -733,7 +733,7 @@ function Transcript({ events, settings, selectedTurnId = null }) {
733
733
  });
734
734
  return () => cancelAnimationFrame(handle);
735
735
  }, [selectedTurnId, anchors]);
736
- if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState$1, {});
736
+ if (items.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
737
737
  return /* @__PURE__ */ jsx("scrollbox", {
738
738
  ref: scrollboxRef,
739
739
  focusable: false,
@@ -905,7 +905,7 @@ function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds }) {
905
905
  }, i))
906
906
  });
907
907
  }
908
- function EmptyState$1() {
908
+ function EmptyState() {
909
909
  return /* @__PURE__ */ jsx("box", {
910
910
  style: {
911
911
  flexGrow: 1,
@@ -1216,12 +1216,6 @@ function ToolResultBlock({ text, indent }) {
1216
1216
  }
1217
1217
  //#endregion
1218
1218
  //#region src/tui/effort-picker.tsx
1219
- /**
1220
- * Reasoning effort options surfaced in the picker. Mirrors {@link ThinkingLevel}
1221
- * minus `'adaptive'` (Anthropic-only — opt in via {@link EffortPickerModal}'s
1222
- * `supportsAdaptive` flag rather than confusing OpenAI / OpenRouter users with
1223
- * a level their model silently treats as `'off'`).
1224
- */
1225
1219
  const BASE_LEVELS = [
1226
1220
  {
1227
1221
  id: "off",
@@ -1248,58 +1242,162 @@ const ADAPTIVE_LEVEL = {
1248
1242
  id: "adaptive",
1249
1243
  description: "model decides per-turn (Anthropic)"
1250
1244
  };
1251
- /**
1252
- * Modal that lets the user pick a reasoning effort for the active model.
1253
- * Only surfaced for models whose registry entry reports
1254
- * `reasoning: true` — see `modelSupportsReasoning`.
1255
- *
1256
- * `'adaptive'` is Anthropic-only; pass `supportsAdaptive` to surface it.
1257
- */
1258
1245
  function EffortPickerModal({ current, supportsAdaptive, onPick }) {
1259
1246
  const COLOR = useColors();
1260
- const SELECT_THEME = useSelectStyle();
1261
- const levels = useMemo(() => supportsAdaptive ? [...BASE_LEVELS, ADAPTIVE_LEVEL] : BASE_LEVELS, [supportsAdaptive]);
1262
- const options = useMemo(() => levels.map((l) => ({
1263
- name: `${l.id === current ? "● " : " "}${l.id}`,
1264
- description: l.description,
1265
- value: l.id
1266
- })), [levels, current]);
1267
- const initialIndex = useMemo(() => {
1247
+ const SURFACE = useSurfaces();
1248
+ const inputRef = useRef(null);
1249
+ const [query, setQuery] = useState("");
1250
+ const levels = useMemo(() => {
1251
+ return (supportsAdaptive ? [...BASE_LEVELS, ADAPTIVE_LEVEL] : BASE_LEVELS).map((l) => ({
1252
+ ...l,
1253
+ searchCorpus: `${l.id} ${l.description}`.toLowerCase()
1254
+ }));
1255
+ }, [supportsAdaptive]);
1256
+ const filtered = useMemo(() => {
1257
+ const trimmed = query.trim().toLowerCase();
1258
+ if (!trimmed) return levels;
1259
+ const terms = trimmed.split(/\s+/);
1260
+ return levels.filter((l) => terms.every((t) => l.searchCorpus.includes(t)));
1261
+ }, [levels, query]);
1262
+ const [selectedIdx, setSelectedIdx] = useState(() => {
1268
1263
  const idx = levels.findIndex((l) => l.id === current);
1269
- return idx < 0 ? levels.findIndex((l) => l.id === "medium") : idx;
1270
- }, [levels, current]);
1264
+ if (idx >= 0) return idx;
1265
+ const fallback = levels.findIndex((l) => l.id === "medium");
1266
+ return fallback < 0 ? 0 : fallback;
1267
+ });
1268
+ const handleQueryChange = useCallback((next) => {
1269
+ setQuery(next);
1270
+ setSelectedIdx(0);
1271
+ }, []);
1272
+ const safeIndex = filtered.length === 0 ? 0 : Math.min(selectedIdx, filtered.length - 1);
1273
+ const commit = () => {
1274
+ const row = filtered[safeIndex];
1275
+ if (row) onPick(row.id);
1276
+ };
1277
+ useEffect(() => {
1278
+ inputRef.current?.focus();
1279
+ }, []);
1280
+ useKeyboard((key) => {
1281
+ if (key.name === "up") {
1282
+ setSelectedIdx((i) => Math.max(0, i - 1));
1283
+ return;
1284
+ }
1285
+ if (key.name === "down") {
1286
+ setSelectedIdx((i) => Math.min(filtered.length - 1, i + 1));
1287
+ return;
1288
+ }
1289
+ if (key.name === "return") commit();
1290
+ });
1271
1291
  return /* @__PURE__ */ jsxs(Modal, {
1272
1292
  title: "select reasoning effort",
1273
- children: [/* @__PURE__ */ jsx("select", {
1274
- ...SELECT_THEME,
1275
- focused: true,
1276
- options,
1277
- wrapSelection: true,
1278
- selectedIndex: Math.max(0, initialIndex),
1279
- style: { height: levels.length },
1280
- onSelect: (_idx, option) => {
1281
- if (option) onPick(option.value);
1282
- }
1283
- }), /* @__PURE__ */ jsxs("text", {
1284
- fg: COLOR.mute,
1293
+ maxWidth: 80,
1294
+ children: [
1295
+ /* @__PURE__ */ jsx("box", {
1296
+ style: {
1297
+ border: true,
1298
+ borderColor: COLOR.borderActive,
1299
+ paddingLeft: 1,
1300
+ paddingRight: 1,
1301
+ height: 3
1302
+ },
1303
+ children: /* @__PURE__ */ jsx("input", {
1304
+ ref: inputRef,
1305
+ focused: true,
1306
+ placeholder: "search effort levels…",
1307
+ onInput: handleQueryChange,
1308
+ onSubmit: () => {},
1309
+ style: { flexGrow: 1 }
1310
+ })
1311
+ }),
1312
+ /* @__PURE__ */ jsx("box", {
1313
+ style: {
1314
+ flexDirection: "column",
1315
+ flexShrink: 0
1316
+ },
1317
+ children: filtered.length === 0 ? /* @__PURE__ */ jsxs("text", {
1318
+ fg: COLOR.dim,
1319
+ children: [/* @__PURE__ */ jsx("span", {
1320
+ fg: COLOR.mute,
1321
+ children: "no levels match "
1322
+ }), /* @__PURE__ */ jsx("span", {
1323
+ fg: COLOR.warn,
1324
+ children: query.trim()
1325
+ })]
1326
+ }) : filtered.map((level, i) => /* @__PURE__ */ jsx(EffortRow, {
1327
+ level,
1328
+ isCurrent: level.id === current,
1329
+ isFocused: i === safeIndex,
1330
+ highlightBg: SURFACE.selection
1331
+ }, level.id))
1332
+ }),
1333
+ /* @__PURE__ */ jsxs("text", {
1334
+ fg: COLOR.dim,
1335
+ children: [
1336
+ /* @__PURE__ */ jsx("span", {
1337
+ fg: COLOR.warn,
1338
+ children: "↑↓"
1339
+ }),
1340
+ " navigate · ",
1341
+ /* @__PURE__ */ jsx("span", {
1342
+ fg: COLOR.warn,
1343
+ children: "↵"
1344
+ }),
1345
+ " select · ",
1346
+ /* @__PURE__ */ jsx("span", {
1347
+ fg: COLOR.warn,
1348
+ children: "esc"
1349
+ }),
1350
+ " close · ",
1351
+ /* @__PURE__ */ jsx("span", {
1352
+ fg: COLOR.mute,
1353
+ children: `${filtered.length} / ${levels.length} level${levels.length === 1 ? "" : "s"}`
1354
+ })
1355
+ ]
1356
+ })
1357
+ ]
1358
+ });
1359
+ }
1360
+ /**
1361
+ * Single row in the picker. Mirrors `ModelRow` in `model-picker.tsx`:
1362
+ * `●` marker for the current pick, single-space middle-dot separators,
1363
+ * focused row gets the `surfaces.selection` background lift.
1364
+ */
1365
+ function EffortRow({ level, isCurrent, isFocused, highlightBg }) {
1366
+ const COLOR = useColors();
1367
+ const marker = isCurrent ? "●" : " ";
1368
+ return /* @__PURE__ */ jsx("box", {
1369
+ style: {
1370
+ height: 1,
1371
+ paddingLeft: 1,
1372
+ paddingRight: 1,
1373
+ flexShrink: 0,
1374
+ backgroundColor: isFocused ? highlightBg : void 0
1375
+ },
1376
+ children: /* @__PURE__ */ jsxs("text", {
1377
+ wrapMode: "none",
1285
1378
  children: [
1286
1379
  /* @__PURE__ */ jsx("span", {
1287
- fg: COLOR.warn,
1288
- children: "↑↓"
1380
+ fg: isCurrent ? COLOR.brand : COLOR.mute,
1381
+ children: marker
1289
1382
  }),
1290
- " navigate · ",
1291
1383
  /* @__PURE__ */ jsx("span", {
1292
- fg: COLOR.warn,
1293
- children: ""
1384
+ fg: COLOR.mute,
1385
+ children: " "
1294
1386
  }),
1295
- " select · ",
1296
1387
  /* @__PURE__ */ jsx("span", {
1297
- fg: COLOR.warn,
1298
- children: "esc"
1388
+ fg: isFocused ? COLOR.brand : COLOR.dim,
1389
+ children: level.id
1299
1390
  }),
1300
- " close"
1391
+ /* @__PURE__ */ jsx("span", {
1392
+ fg: COLOR.mute,
1393
+ children: " · "
1394
+ }),
1395
+ /* @__PURE__ */ jsx("span", {
1396
+ fg: COLOR.mute,
1397
+ children: level.description
1398
+ })
1301
1399
  ]
1302
- })]
1400
+ })
1303
1401
  });
1304
1402
  }
1305
1403
  //#endregion
@@ -1465,50 +1563,134 @@ function McpsSettingsModal({ catalog }) {
1465
1563
  }
1466
1564
  //#endregion
1467
1565
  //#region src/tui/model-picker.tsx
1468
- /** Cap the visible scroll window so a 30-model list doesn't push the modal off-screen. */
1469
- const VISIBLE_ROW_CAP = 12;
1470
1566
  /**
1471
- * Modal that lists the available models for the current provider and lets
1472
- * the user pick one. Options come from the active `ProviderDescriptor` —
1473
- * either its declared `models` list or, when absent, pi-ai's built-in
1474
- * registry looked up via `piProviderId`.
1567
+ * Cross-provider, searchable model picker.
1568
+ *
1569
+ * The picker unions every available provider's models into one flat
1570
+ * catalog, lets the user filter with a typed query, and returns both
1571
+ * the chosen provider and model id on commit. Pick a model from a
1572
+ * different provider than the current one and the caller is expected
1573
+ * to swap the active `ProviderAuth` + rebuild the agent — see
1574
+ * `app.tsx`'s `onPickModel` for the canonical wiring.
1475
1575
  *
1476
- * Each row shows: `● selected · name (ctx N · reasoning · vision)`.
1576
+ * Geometry:
1577
+ * - Top: single-row search input. The input owns focus so the user
1578
+ * can type immediately; ↑/↓/↵/⎋ are intercepted before the input's
1579
+ * text handler sees them so navigation works without losing focus.
1580
+ * - Middle: windowed list of catalog rows around the current
1581
+ * selection. Each row shows the model name (or id) in brand color,
1582
+ * the provider label in dim, and a compact capability suffix
1583
+ * (`ctx 200k · reasoning · vision`).
1584
+ * - Bottom: shortcut hint row.
1585
+ *
1586
+ * Empty states:
1587
+ * - No available providers → "no providers configured" notice.
1588
+ * - Query has no matches → "no matches" row, keep the search input
1589
+ * live so the user can backspace out.
1477
1590
  */
1478
- function ModelPickerModal({ models, currentModelId, onPick }) {
1591
+ /** Visible rows in the windowed list. Keeps the modal a stable size on long catalogs. */
1592
+ const VISIBLE_ROWS = 10;
1593
+ function ModelPickerModal({ providers, modelsFor, current, onPick }) {
1479
1594
  const COLOR = useColors();
1480
- const SELECT_THEME = useSelectStyle();
1481
- const initialIndex = useMemo(() => models.findIndex((m) => m.id === currentModelId), [models, currentModelId]);
1482
- const options = useMemo(() => models.map((m) => ({
1483
- name: `${m.id === currentModelId ? "● " : " "}${m.name ?? m.id}`,
1484
- description: describeModel(m),
1485
- value: m.id
1486
- })), [models, currentModelId]);
1487
- if (models.length === 0) return /* @__PURE__ */ jsx(EmptyState, {});
1488
- const visibleRows = Math.min(options.length, VISIBLE_ROW_CAP);
1489
- const currentMissing = initialIndex < 0;
1490
- const safeIndex = currentMissing ? 0 : initialIndex;
1595
+ const SURFACE = useSurfaces();
1596
+ const inputRef = useRef(null);
1597
+ const [query, setQuery] = useState("");
1598
+ useEffect(() => {
1599
+ inputRef.current?.focus();
1600
+ }, []);
1601
+ const catalog = useMemo(() => buildModelCatalog({
1602
+ providers,
1603
+ modelsFor,
1604
+ current
1605
+ }), [
1606
+ providers,
1607
+ modelsFor,
1608
+ current
1609
+ ]);
1610
+ const filtered = useMemo(() => filterModelCatalog(catalog, query), [catalog, query]);
1611
+ const [selectedIdx, setSelectedIdx] = useState(() => Math.max(0, indexOfEntry(catalog, current)));
1612
+ const handleQueryChange = useCallback((next) => {
1613
+ setQuery(next);
1614
+ setSelectedIdx(0);
1615
+ }, []);
1616
+ const safeIndex = filtered.length === 0 ? 0 : Math.min(selectedIdx, filtered.length - 1);
1617
+ const commit = () => {
1618
+ const row = filtered[safeIndex];
1619
+ if (row) onPick({
1620
+ providerKey: row.providerKey,
1621
+ modelId: row.model.id
1622
+ });
1623
+ };
1624
+ const viewport = useMemo(() => {
1625
+ if (filtered.length <= VISIBLE_ROWS) return {
1626
+ start: 0,
1627
+ slice: filtered
1628
+ };
1629
+ const half = Math.floor(VISIBLE_ROWS / 2);
1630
+ let start = Math.max(0, safeIndex - half);
1631
+ if (start + VISIBLE_ROWS > filtered.length) start = filtered.length - VISIBLE_ROWS;
1632
+ return {
1633
+ start,
1634
+ slice: filtered.slice(start, start + VISIBLE_ROWS)
1635
+ };
1636
+ }, [filtered, safeIndex]);
1637
+ useKeyboard((key) => {
1638
+ if (key.name === "up") {
1639
+ setSelectedIdx((i) => Math.max(0, i - 1));
1640
+ return;
1641
+ }
1642
+ if (key.name === "down") {
1643
+ setSelectedIdx((i) => Math.min(filtered.length - 1, i + 1));
1644
+ return;
1645
+ }
1646
+ if (key.name === "return") commit();
1647
+ });
1648
+ if (providers.length === 0) return /* @__PURE__ */ jsx(EmptyProvidersState, {});
1491
1649
  return /* @__PURE__ */ jsxs(Modal, {
1492
1650
  title: "select model",
1651
+ maxWidth: 100,
1493
1652
  children: [
1494
- currentMissing && /* @__PURE__ */ jsx("text", {
1495
- fg: COLOR.warn,
1496
- children: `Current model "${currentModelId}" is not in this registry — pick one below to switch.`
1653
+ /* @__PURE__ */ jsx("box", {
1654
+ style: {
1655
+ border: true,
1656
+ borderColor: COLOR.borderActive,
1657
+ paddingLeft: 1,
1658
+ paddingRight: 1,
1659
+ height: 3
1660
+ },
1661
+ children: /* @__PURE__ */ jsx("input", {
1662
+ ref: inputRef,
1663
+ focused: true,
1664
+ placeholder: "search models — provider, name, capability…",
1665
+ onInput: handleQueryChange,
1666
+ onSubmit: () => {},
1667
+ style: { flexGrow: 1 }
1668
+ })
1497
1669
  }),
1498
- /* @__PURE__ */ jsx("select", {
1499
- ...SELECT_THEME,
1500
- focused: true,
1501
- options,
1502
- wrapSelection: true,
1503
- selectedIndex: safeIndex,
1504
- showScrollIndicator: options.length > visibleRows,
1505
- style: { height: visibleRows },
1506
- onSelect: (_idx, option) => {
1507
- if (option) onPick(option.value);
1508
- }
1670
+ /* @__PURE__ */ jsx("box", {
1671
+ style: {
1672
+ flexDirection: "column",
1673
+ height: VISIBLE_ROWS,
1674
+ flexShrink: 0
1675
+ },
1676
+ children: filtered.length === 0 ? /* @__PURE__ */ jsxs("text", {
1677
+ fg: COLOR.dim,
1678
+ children: [/* @__PURE__ */ jsx("span", {
1679
+ fg: COLOR.mute,
1680
+ children: "no models match "
1681
+ }), /* @__PURE__ */ jsx("span", {
1682
+ fg: COLOR.warn,
1683
+ children: query.trim()
1684
+ })]
1685
+ }) : viewport.slice.map((entry, i) => /* @__PURE__ */ jsx(ModelRow, {
1686
+ entry,
1687
+ isCurrent: entry.providerKey === current.providerKey && entry.model.id === current.modelId,
1688
+ isFocused: viewport.start + i === safeIndex,
1689
+ highlightBg: SURFACE.selection
1690
+ }, `${entry.providerKey}:${entry.model.id}`))
1509
1691
  }),
1510
1692
  /* @__PURE__ */ jsxs("text", {
1511
- fg: COLOR.mute,
1693
+ fg: COLOR.dim,
1512
1694
  children: [
1513
1695
  /* @__PURE__ */ jsx("span", {
1514
1696
  fg: COLOR.warn,
@@ -1524,38 +1706,94 @@ function ModelPickerModal({ models, currentModelId, onPick }) {
1524
1706
  fg: COLOR.warn,
1525
1707
  children: "esc"
1526
1708
  }),
1527
- " close"
1709
+ " close · ",
1710
+ /* @__PURE__ */ jsx("span", {
1711
+ fg: COLOR.mute,
1712
+ children: `${filtered.length} / ${catalog.length} model${catalog.length === 1 ? "" : "s"}`
1713
+ })
1528
1714
  ]
1529
1715
  })
1530
1716
  ]
1531
1717
  });
1532
1718
  }
1533
- function EmptyState() {
1719
+ /**
1720
+ * Single row in the picker. Renders the model name in `brand`, the
1721
+ * provider tag in `dim`, and the capability suffix in `mute`. The
1722
+ * focused row gets a subtle selection background (same surface as
1723
+ * select-turn mode in the transcript) so it pops without a separate
1724
+ * marker glyph competing with the `●` "current" indicator.
1725
+ */
1726
+ function ModelRow({ entry, isCurrent, isFocused, highlightBg }) {
1727
+ const COLOR = useColors();
1728
+ const marker = isCurrent ? "●" : " ";
1729
+ return /* @__PURE__ */ jsx("box", {
1730
+ style: {
1731
+ height: 1,
1732
+ paddingLeft: 1,
1733
+ paddingRight: 1,
1734
+ flexShrink: 0,
1735
+ backgroundColor: isFocused ? highlightBg : void 0
1736
+ },
1737
+ children: /* @__PURE__ */ jsxs("text", {
1738
+ wrapMode: "none",
1739
+ children: [
1740
+ /* @__PURE__ */ jsx("span", {
1741
+ fg: isCurrent ? COLOR.brand : COLOR.mute,
1742
+ children: marker
1743
+ }),
1744
+ /* @__PURE__ */ jsx("span", {
1745
+ fg: COLOR.mute,
1746
+ children: " "
1747
+ }),
1748
+ /* @__PURE__ */ jsx("span", {
1749
+ fg: isFocused ? COLOR.brand : COLOR.dim,
1750
+ children: entry.model.name ?? entry.model.id
1751
+ }),
1752
+ /* @__PURE__ */ jsx("span", {
1753
+ fg: COLOR.mute,
1754
+ children: " · "
1755
+ }),
1756
+ /* @__PURE__ */ jsx("span", {
1757
+ fg: COLOR.model,
1758
+ children: entry.providerLabel
1759
+ }),
1760
+ /* @__PURE__ */ jsx("span", {
1761
+ fg: COLOR.mute,
1762
+ children: " · "
1763
+ }),
1764
+ /* @__PURE__ */ jsx("span", {
1765
+ fg: COLOR.mute,
1766
+ children: describeModel(entry.model)
1767
+ })
1768
+ ]
1769
+ })
1770
+ });
1771
+ }
1772
+ function EmptyProvidersState() {
1534
1773
  const COLOR = useColors();
1535
1774
  return /* @__PURE__ */ jsxs(Modal, {
1536
1775
  title: "select model",
1537
1776
  children: [/* @__PURE__ */ jsx("text", {
1538
1777
  fg: COLOR.dim,
1539
- children: "No models available for this provider."
1778
+ children: "No authed providers configure one via"
1540
1779
  }), /* @__PURE__ */ jsxs("text", {
1541
- fg: COLOR.mute,
1780
+ fg: COLOR.dim,
1542
1781
  children: [
1543
- "Set",
1544
1782
  /* @__PURE__ */ jsx("span", {
1545
1783
  fg: COLOR.model,
1546
- children: " models "
1784
+ children: " settings → re-configure providers"
1547
1785
  }),
1548
- "on the provider descriptor (or a",
1786
+ " or ",
1549
1787
  /* @__PURE__ */ jsx("span", {
1550
1788
  fg: COLOR.model,
1551
- children: " piProviderId "
1789
+ children: "esc sessions → settings"
1552
1790
  }),
1553
- "that pi-ai recognizes) to populate this list."
1791
+ " first."
1554
1792
  ]
1555
1793
  })]
1556
1794
  });
1557
1795
  }
1558
- /** "ctx 200k · reasoning · vision" — compact per-model description. */
1796
+ /** "ctx 200k · reasoning · vision" — compact capability blurb. */
1559
1797
  function describeModel(m) {
1560
1798
  const parts = [`ctx ${fmtTokens(m.contextWindow)}`];
1561
1799
  if (m.reasoning) parts.push("reasoning");
@@ -4131,6 +4369,7 @@ function AppShell() {
4131
4369
  const SURFACE = useSurfaces();
4132
4370
  const queue = useSafeModeQueue();
4133
4371
  const { requestApproval, resolveHead, denyAll } = useSafeModeActions();
4372
+ const pendingApproval = queue[0] ?? null;
4134
4373
  const { providers: providerRegistry, agents: agentRegistry, initialAgentId, store, stateStore, modelsFor, resumeProvider, initialPicked, initialState } = config;
4135
4374
  const lastResumedSessionId = initialState.lastSessionId;
4136
4375
  const dataDir = config.paths.userDir;
@@ -4497,6 +4736,13 @@ function AppShell() {
4497
4736
  settings.showAllProjects,
4498
4737
  sessionProjectRoot
4499
4738
  ]);
4739
+ const [availableProviders, setAvailableProviders] = useState([]);
4740
+ const refreshAvailableProviders = useCallback(() => {
4741
+ setAvailableProviders(detectAuth(config.paths.userDir, providerRegistry).filter((p) => p.available));
4742
+ }, [config.paths.userDir, providerRegistry]);
4743
+ useEffect(() => {
4744
+ refreshAvailableProviders();
4745
+ }, [refreshAvailableProviders]);
4500
4746
  const onPickProvider = useCallback(async (p) => {
4501
4747
  const next = makePicked(p);
4502
4748
  if (!next) return;
@@ -4505,13 +4751,15 @@ function AppShell() {
4505
4751
  ...stateStore.load(),
4506
4752
  lastProvider: p.key
4507
4753
  });
4754
+ refreshAvailableProviders();
4508
4755
  if ((await refreshSessions()).length === 0) await activateSession(null, p.key);
4509
4756
  else setScreen("sessions");
4510
4757
  }, [
4511
4758
  refreshSessions,
4512
4759
  activateSession,
4513
4760
  makePicked,
4514
- stateStore
4761
+ stateStore,
4762
+ refreshAvailableProviders
4515
4763
  ]);
4516
4764
  const onCreateSession = useCallback(async () => {
4517
4765
  if (picked) await activateSession(null, picked.provider.key);
@@ -4532,33 +4780,63 @@ function AppShell() {
4532
4780
  denyAll();
4533
4781
  agentRef.current?.abort();
4534
4782
  }, [denyAll]);
4535
- const onPickModel = useCallback((modelId) => {
4536
- setPicked((prev) => {
4537
- if (!prev) return prev;
4538
- const descriptor = providerRegistry[prev.provider.key];
4539
- const prior = stateStore.load();
4540
- stateStore.save({
4541
- ...prior,
4542
- lastModelByProvider: {
4543
- ...prior.lastModelByProvider,
4544
- [prev.provider.key]: modelId
4545
- }
4546
- });
4547
- const nextEffort = descriptor ? effortForModel(descriptor, modelId, prior.lastEffortByModel) : void 0;
4548
- return nextEffort ? {
4549
- ...prev,
4550
- model: modelId,
4551
- effort: nextEffort
4552
- } : {
4553
- provider: prev.provider,
4554
- model: modelId
4555
- };
4783
+ /**
4784
+ * Pick a `{ providerKey, modelId }` tuple from the cross-provider
4785
+ * model picker. Two branches:
4786
+ *
4787
+ * - **Same provider, different model** — update `picked.model` +
4788
+ * remember it in `lastModelByProvider`. The active agent keeps
4789
+ * running; `agent.run({ model })` accepts the model per-call so
4790
+ * no session rebuild is needed.
4791
+ * - **Different provider** — swap the active `ProviderAuth`,
4792
+ * persist BOTH `lastProvider` + `lastModelByProvider`, then
4793
+ * re-activate the current session against the new provider's
4794
+ * factory. The session id (and conversation history) is
4795
+ * preserved; only the bound agent + provider instance change.
4796
+ *
4797
+ * Either branch re-resolves the reasoning effort: a non-reasoning
4798
+ * model loses its `effort`, a reasoning model gets the remembered
4799
+ * per-model value (falling back to a sensible default).
4800
+ */
4801
+ const onPickModel = useCallback(async (next) => {
4802
+ const nextProvider = availableProviders.find((p) => p.key === next.providerKey);
4803
+ if (!nextProvider) {
4804
+ debugLog("onPickModel: unknown provider key", next.providerKey);
4805
+ modal.close();
4806
+ return;
4807
+ }
4808
+ const descriptor = providerRegistry[nextProvider.key];
4809
+ const prior = stateStore.load();
4810
+ const providerChanged = picked?.provider.key !== nextProvider.key;
4811
+ stateStore.save({
4812
+ ...prior,
4813
+ ...providerChanged ? { lastProvider: nextProvider.key } : {},
4814
+ lastModelByProvider: {
4815
+ ...prior.lastModelByProvider,
4816
+ [nextProvider.key]: next.modelId
4817
+ }
4818
+ });
4819
+ const nextEffort = descriptor ? effortForModel(descriptor, next.modelId, prior.lastEffortByModel) : void 0;
4820
+ setPicked(nextEffort ? {
4821
+ provider: nextProvider,
4822
+ model: next.modelId,
4823
+ effort: nextEffort
4824
+ } : {
4825
+ provider: nextProvider,
4826
+ model: next.modelId
4556
4827
  });
4557
4828
  modal.close();
4829
+ if (providerChanged && currentSession && !busy && !pendingApproval) await activateSession(currentSession.id, nextProvider.key);
4558
4830
  }, [
4559
- modal,
4831
+ availableProviders,
4832
+ providerRegistry,
4560
4833
  stateStore,
4561
- providerRegistry
4834
+ modal,
4835
+ picked,
4836
+ currentSession,
4837
+ busy,
4838
+ pendingApproval,
4839
+ activateSession
4562
4840
  ]);
4563
4841
  const onPickEffort = useCallback((effort) => {
4564
4842
  setPicked((prev) => {
@@ -4661,7 +4939,6 @@ function AppShell() {
4661
4939
  setBusy(false);
4662
4940
  }
4663
4941
  }, [picked, stream]);
4664
- const pendingApproval = queue[0] ?? null;
4665
4942
  const onReauth = useMemo(() => {
4666
4943
  if (busy || pendingApproval) return void 0;
4667
4944
  return () => {
@@ -4932,7 +5209,7 @@ function AppShell() {
4932
5209
  }
4933
5210
  return;
4934
5211
  }
4935
- if (key.ctrl && key.name === "," && screen !== "auth") {
5212
+ if (key.ctrl && key.name === "o" && screen !== "auth") {
4936
5213
  modal.open(/* @__PURE__ */ jsx(SettingsModal, { actions: {
4937
5214
  onReauth,
4938
5215
  onOpenSkills: onOpenSkillsSettings,
@@ -4952,8 +5229,12 @@ function AppShell() {
4952
5229
  }
4953
5230
  if (key.ctrl && key.name === "m" && screen === "chat" && picked && !busy) {
4954
5231
  modal.open(/* @__PURE__ */ jsx(ModelPickerModal, {
4955
- models: modelsFor(picked.provider.key),
4956
- currentModelId: picked.model,
5232
+ providers: availableProviders,
5233
+ modelsFor,
5234
+ current: {
5235
+ providerKey: picked.provider.key,
5236
+ modelId: picked.model
5237
+ },
4957
5238
  onPick: onPickModel
4958
5239
  }));
4959
5240
  return;
@@ -5159,7 +5440,7 @@ function buildHints({ screen, busy, pending, currentSession, hasMultipleAgents,
5159
5440
  label: "session"
5160
5441
  },
5161
5442
  {
5162
- key: "ctrl+,",
5443
+ key: "ctrl+o",
5163
5444
  label: "settings"
5164
5445
  },
5165
5446
  {
@@ -5190,7 +5471,7 @@ function buildHints({ screen, busy, pending, currentSession, hasMultipleAgents,
5190
5471
  label: "session"
5191
5472
  }] : [],
5192
5473
  {
5193
- key: "ctrl+,",
5474
+ key: "ctrl+o",
5194
5475
  label: "settings"
5195
5476
  },
5196
5477
  {