claudeup 6.3.2 → 6.4.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 (58) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +36 -0
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/cli/doctor.ts +90 -0
  22. package/src/cli/hook.ts +129 -0
  23. package/src/cli/models.ts +214 -0
  24. package/src/cli/router.ts +12 -0
  25. package/src/data/gitignore-defaults.ts +4 -0
  26. package/src/data/models-presets.ts +281 -0
  27. package/src/data/predefined-profiles.ts +9 -0
  28. package/src/data/settings-catalog.ts +11 -4
  29. package/src/main.tsx +51 -82
  30. package/src/services/hook-registration.ts +218 -0
  31. package/src/services/manifest.ts +84 -0
  32. package/src/services/models-core.ts +628 -0
  33. package/src/services/models-manager.ts +606 -0
  34. package/src/services/profile-materializer.ts +17 -0
  35. package/src/services/resolver.ts +11 -0
  36. package/src/services/settings-file.ts +69 -0
  37. package/src/services/styles-manager.ts +23 -45
  38. package/src/services/symlink-manager.ts +57 -11
  39. package/src/tui.tsx +112 -0
  40. package/src/types/bun.d.ts +21 -0
  41. package/src/types/index.ts +14 -0
  42. package/src/ui/App.tsx +15 -3
  43. package/src/ui/adapters/modelsAdapter.ts +170 -0
  44. package/src/ui/components/TabBar.tsx +9 -4
  45. package/src/ui/components/layout/FooterHints.tsx +20 -3
  46. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  47. package/src/ui/components/primitives/MetaText.tsx +27 -1
  48. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  49. package/src/ui/renderers/modelVisuals.tsx +853 -0
  50. package/src/ui/renderers/skillRenderers.tsx +13 -3
  51. package/src/ui/renderers/styleRenderers.tsx +7 -3
  52. package/src/ui/screens/ModelsScreen.tsx +478 -0
  53. package/src/ui/screens/StylesScreen.tsx +8 -13
  54. package/src/ui/screens/index.ts +1 -0
  55. package/src/ui/state/reducer.ts +94 -0
  56. package/src/ui/state/types.ts +65 -2
  57. package/src/ui/theme-mode.ts +116 -0
  58. package/src/ui/theme.ts +26 -0
@@ -126,7 +126,9 @@ const skillRenderer: ItemRenderer<SkillSkillItem> = {
126
126
  >
127
127
  {displayName}
128
128
  </span>
129
- {skill.hasUpdate ? <MetaText text=" ⬆" tone="warning" /> : null}
129
+ {skill.hasUpdate ? (
130
+ <MetaText text=" ⬆" tone="warning" selected={isSelected} />
131
+ ) : null}
130
132
  {starsStr ? <span fg={sColor}>{` ${starsStr}`}</span> : null}
131
133
  </SelectableRow>
132
134
  );
@@ -313,8 +315,16 @@ const skillSetRenderer: ItemRenderer<SkillSetItem> = {
313
315
  <span fg={isSelected ? theme.colors.text : theme.colors.info}>
314
316
  <strong>{displayName}</strong>
315
317
  </span>
316
- <span fg={theme.colors.muted}> {countStr}</span>
317
- {starsStr ? <MetaText text={` ${starsStr}`} tone="warning" /> : null}
318
+ <span fg={isSelected ? theme.selection.dim : theme.colors.muted}>
319
+ {` ${countStr}`}
320
+ </span>
321
+ {starsStr ? (
322
+ <MetaText
323
+ text={` ${starsStr}`}
324
+ tone="warning"
325
+ selected={isSelected}
326
+ />
327
+ ) : null}
318
328
  <span> </span>
319
329
  <ScopeSquares
320
330
  user={hasUser}
@@ -348,12 +348,14 @@ const styleRenderer: ItemRenderer<StyleEntryItem> = {
348
348
  {/* ASCII on purpose. U+26A0 carries emoji presentation, so terminals
349
349
  draw it two cells wide while the layout measures it as one — the
350
350
  row's next character was overprinted. */}
351
- {item.conflictsWith ? <MetaText text=" (!)" tone="danger" /> : null}
351
+ {item.conflictsWith ? (
352
+ <MetaText text=" (!)" tone="danger" selected={isSelected} />
353
+ ) : null}
352
354
  {/* A pending update has to be visible from the LIST. Nobody opens the
353
355
  detail panel on a style they are happy with, which is exactly the
354
356
  style an update lands on. */}
355
357
  {item.upstream?.state === "update-available" ? (
356
- <MetaText text=" (new)" tone="warning" />
358
+ <MetaText text=" (new)" tone="warning" selected={isSelected} />
357
359
  ) : null}
358
360
  {summaryRoom > 6 ? (
359
361
  <span fg={isSelected ? theme.selection.fg : theme.colors.muted}>
@@ -659,7 +661,9 @@ const offerRenderer: ItemRenderer<StyleOfferItem> = {
659
661
  {/* The project asked for this one and this machine does not have it.
660
662
  Worth flagging on the row: it is the difference between "you could
661
663
  have this" and "your project is waiting for this". */}
662
- {item.declared ? <MetaText text=" (needed)" tone="warning" /> : null}
664
+ {item.declared ? (
665
+ <MetaText text=" (needed)" tone="warning" selected={isSelected} />
666
+ ) : null}
663
667
  {summaryRoom > 6 ? (
664
668
  <span fg={isSelected ? theme.selection.fg : theme.colors.muted}>
665
669
  {` ${truncate(item.entry.summary, summaryRoom)}`}
@@ -0,0 +1,478 @@
1
+ import React, { useCallback, useEffect, useMemo } from "react";
2
+ import {
3
+ applyModelPreset,
4
+ clearModels,
5
+ readModelsConfig,
6
+ readModelsStatus,
7
+ } from "../../services/models-manager.js";
8
+ import {
9
+ type ModelsBrowserItem,
10
+ buildModelsItems,
11
+ firstSelectableIndex,
12
+ } from "../adapters/modelsAdapter.js";
13
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
14
+ import { ScrollableList } from "../components/ScrollableList.js";
15
+ import { ScreenLayout } from "../components/layout/index.js";
16
+ import { useKeyboard } from "../hooks/useKeyboard.js";
17
+ import {
18
+ fitPresetList,
19
+ renderModelDetail,
20
+ renderModelRow,
21
+ renderModelsSummary,
22
+ } from "../renderers/modelRenderers.js";
23
+ import { useApp } from "../state/AppContext.js";
24
+ import { useDimensions } from "../state/DimensionsContext.js";
25
+ import { asyncValue } from "../state/types.js";
26
+ import { theme } from "../theme.js";
27
+
28
+ export function ModelsScreen() {
29
+ const { state, dispatch } = useApp();
30
+ const { models: modelsState } = state;
31
+ const dimensions = useDimensions();
32
+
33
+ const isSearchActive =
34
+ state.isSearching && state.currentRoute.screen === "models" && !state.modal;
35
+
36
+ // ── Data ──────────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Both reads, in one dispatch.
40
+ *
41
+ * `readModelsStatus` answers "is this routing in force"; `readModelsConfig`
42
+ * is the routing itself, which the list needs in full to give a hand-edited
43
+ * config a row of its own. Neither is derivable from the other — a `status`
44
+ * of `invalid` reports no preset at all, and an `off` project still has a
45
+ * path worth naming.
46
+ */
47
+ const fetchData = useCallback(async () => {
48
+ dispatch({ type: "MODELS_DATA_LOADING" });
49
+ try {
50
+ const [status, config] = await Promise.all([
51
+ readModelsStatus(state.projectPath),
52
+ readModelsConfig(state.projectPath),
53
+ ]);
54
+ dispatch({
55
+ type: "MODELS_DATA_SUCCESS",
56
+ snapshot: { status, config: config.config, path: config.path },
57
+ });
58
+ } catch (error) {
59
+ dispatch({
60
+ type: "MODELS_DATA_ERROR",
61
+ error: error instanceof Error ? error : new Error(String(error)),
62
+ });
63
+ }
64
+ }, [dispatch, state.projectPath]);
65
+
66
+ // dataRefreshVersion is a refetch signal, not a value this effect reads:
67
+ // bumping it is how the app tells every screen its data changed.
68
+ // biome-ignore lint/correctness/useExhaustiveDependencies: signal, not a read
69
+ useEffect(() => {
70
+ fetchData();
71
+ }, [fetchData, state.dataRefreshVersion]);
72
+
73
+ const snapshot = asyncValue(modelsState.data) ?? null;
74
+
75
+ // ── Status line ───────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Set the status line.
79
+ *
80
+ * No timer, matching the Styles screen: a message that erased itself after a
81
+ * few seconds was usually gone by the time anyone looked. It is cleared when
82
+ * the NEXT action starts instead.
83
+ */
84
+ const showStatus = useCallback(
85
+ (text: string, tone: "success" | "error" = "success") => {
86
+ dispatch({ type: "MODELS_STATUS_SET", status: { text, tone } });
87
+ },
88
+ [dispatch],
89
+ );
90
+ const statusMsg = modelsState.status;
91
+
92
+ // ── Derived ───────────────────────────────────────────────────────────────
93
+
94
+ const allItems = useMemo(
95
+ () =>
96
+ buildModelsItems({
97
+ status: snapshot?.status ?? null,
98
+ config: snapshot?.config ?? null,
99
+ query: modelsState.searchQuery,
100
+ }),
101
+ [snapshot, modelsState.searchQuery],
102
+ );
103
+
104
+ // Keep the cursor off the status header — it is a label, not a row.
105
+ useEffect(() => {
106
+ const item = allItems[modelsState.selectedIndex];
107
+ if (allItems.length > 0 && (!item || item.kind === "status")) {
108
+ dispatch({
109
+ type: "MODELS_SELECT",
110
+ index: firstSelectableIndex(allItems),
111
+ });
112
+ }
113
+ }, [allItems, modelsState.selectedIndex, dispatch]);
114
+
115
+ const selectedItem: ModelsBrowserItem | undefined =
116
+ allItems[modelsState.selectedIndex];
117
+
118
+ // ── Actions ───────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Write the selected preset's routing to this project.
122
+ *
123
+ * A hand-edited config is re-applied as an OBJECT rather than by name —
124
+ * `applyModelPreset` resolves a string against the built-ins, and this one is
125
+ * by definition not among them. Re-applying it is also the fix for `stale`:
126
+ * it rewrites the settings the config implies.
127
+ *
128
+ * No `ensureManifest` is passed. That helper prompts on the terminal, and
129
+ * this process's terminal belongs to OpenTUI — a project with no profiles
130
+ * gets a plain `.claude/models.json`, and the status line says so.
131
+ */
132
+ const handleApply = useCallback(async () => {
133
+ if (modelsState.isApplying) {
134
+ showStatus("Already writing the routing — one at a time", "error");
135
+ return;
136
+ }
137
+ if (!selectedItem || selectedItem.kind !== "preset") {
138
+ showStatus("Move to a preset row and press a", "error");
139
+ return;
140
+ }
141
+ const { config, custom } = selectedItem;
142
+
143
+ dispatch({ type: "MODELS_APPLY_START" });
144
+ try {
145
+ const result = await applyModelPreset({
146
+ // `config.preset`, NOT `label`. The row is titled with a human name now
147
+ // ("Opus with Fable help"), and `applyModelPreset` resolves an id against the
148
+ // built-ins — so passing the label refused every apply with `Unknown preset
149
+ // "Opus with Fable help"`. Found by pressing the key, not by a test: the
150
+ // types agree, both are strings.
151
+ projectPath: state.projectPath,
152
+ preset: custom ? config : config.preset,
153
+ });
154
+ await fetchData();
155
+ const where = result.profile
156
+ ? `profile "${result.profile}"`
157
+ : "this project";
158
+ // Say plainly when the manifest was NOT updated: without that record a
159
+ // `claudeup install` rewrites the profile and the routing disappears.
160
+ const durability =
161
+ result.profile && !result.recordedInManifest
162
+ ? " — not in profiles.json, so `claudeup install` will drop it"
163
+ : "";
164
+ showStatus(
165
+ `Applied ${selectedItem.label} in ${where}, hook ${result.hook}${durability}`,
166
+ result.profile && !result.recordedInManifest ? "error" : "success",
167
+ );
168
+ } catch (error) {
169
+ showStatus(
170
+ error instanceof Error ? error.message : String(error),
171
+ "error",
172
+ );
173
+ } finally {
174
+ dispatch({ type: "MODELS_APPLY_END" });
175
+ }
176
+ }, [
177
+ modelsState.isApplying,
178
+ selectedItem,
179
+ state.projectPath,
180
+ fetchData,
181
+ showStatus,
182
+ dispatch,
183
+ ]);
184
+
185
+ const handleClear = useCallback(async () => {
186
+ if (modelsState.isApplying) {
187
+ showStatus("Already writing the routing — one at a time", "error");
188
+ return;
189
+ }
190
+ dispatch({ type: "MODELS_APPLY_START" });
191
+ try {
192
+ await clearModels(state.projectPath);
193
+ await fetchData();
194
+ showStatus(
195
+ "Model tiers off — subagents inherit the session model again. The hook stays registered and is a no-op without a config.",
196
+ );
197
+ } catch (error) {
198
+ showStatus(
199
+ error instanceof Error ? error.message : String(error),
200
+ "error",
201
+ );
202
+ } finally {
203
+ dispatch({ type: "MODELS_APPLY_END" });
204
+ }
205
+ }, [
206
+ modelsState.isApplying,
207
+ state.projectPath,
208
+ fetchData,
209
+ showStatus,
210
+ dispatch,
211
+ ]);
212
+
213
+ // ── Keyboard ──────────────────────────────────────────────────────────────
214
+
215
+ const move = useCallback(
216
+ (delta: -1 | 1) => {
217
+ let index = modelsState.selectedIndex + delta;
218
+ while (index >= 0 && index < allItems.length) {
219
+ if (allItems[index] && allItems[index].kind !== "status") {
220
+ dispatch({ type: "MODELS_SELECT", index });
221
+ return;
222
+ }
223
+ index += delta;
224
+ }
225
+ },
226
+ [modelsState.selectedIndex, allItems, dispatch],
227
+ );
228
+
229
+ useKeyboard((event) => {
230
+ if (state.modal) return;
231
+
232
+ // The previous message belongs to the previous ACTION, so clearing it here
233
+ // covers every action key in one place. Navigation is excluded: a digit or
234
+ // Tab is handled by the global handler, and clearing on it wiped the
235
+ // message on the way OUT of the tab, losing it on a round trip.
236
+ const isNavigation =
237
+ /^[0-9]$/.test(event.name ?? "") || event.name === "tab";
238
+ if (modelsState.status && !isNavigation) {
239
+ dispatch({ type: "MODELS_STATUS_CLEAR" });
240
+ }
241
+
242
+ const hasQuery = modelsState.searchQuery.length > 0;
243
+
244
+ if (event.name === "escape") {
245
+ if (hasQuery || isSearchActive) {
246
+ dispatch({ type: "MODELS_SET_SEARCH", query: "" });
247
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
248
+ dispatch({ type: "MODELS_SELECT", index: 0 });
249
+ }
250
+ return;
251
+ }
252
+
253
+ if (event.name === "backspace" || event.name === "delete") {
254
+ if (hasQuery) {
255
+ dispatch({ type: "MODELS_SEARCH_BACKSPACE" });
256
+ // Driven by what was there BEFORE this keystroke — the one thing the
257
+ // closure knows reliably.
258
+ if (modelsState.searchQuery.length <= 1) {
259
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
260
+ }
261
+ }
262
+ return;
263
+ }
264
+
265
+ if (event.name === "up") {
266
+ if (isSearchActive)
267
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
268
+ move(-1);
269
+ return;
270
+ }
271
+ if (event.name === "down") {
272
+ if (isSearchActive)
273
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
274
+ move(1);
275
+ return;
276
+ }
277
+
278
+ if (event.name === "return" || event.name === "enter") {
279
+ if (isSearchActive) {
280
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
281
+ return;
282
+ }
283
+ // Enter and `a` are the same action here. There is nothing to "open" on
284
+ // this screen — a preset row has one thing it can do — so binding Enter
285
+ // to anything else would leave the most obvious key doing nothing.
286
+ handleApply();
287
+ return;
288
+ }
289
+
290
+ // While typing a filter, letters are text — not commands.
291
+ if (isSearchActive) {
292
+ if (
293
+ event.name &&
294
+ event.name.length === 1 &&
295
+ !event.ctrl &&
296
+ !event.meta &&
297
+ !/[0-9]/.test(event.name)
298
+ ) {
299
+ dispatch({ type: "MODELS_SEARCH_APPEND", char: event.name });
300
+ }
301
+ return;
302
+ }
303
+
304
+ if (event.name === "k") {
305
+ move(-1);
306
+ } else if (event.name === "j") {
307
+ move(1);
308
+ } else if (event.name === "a") {
309
+ handleApply();
310
+ } else if (event.name === "c") {
311
+ handleClear();
312
+ } else if (event.name === "r") {
313
+ fetchData();
314
+ } else if (event.name === "/") {
315
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
316
+ }
317
+ });
318
+
319
+ // ── Chrome ────────────────────────────────────────────────────────────────
320
+
321
+ // Transient feedback takes the full-width notice row under the header, never
322
+ // the header itself: a long message beside the title shrinks the title away
323
+ // and the two read as one garbled sentence.
324
+ const noticeContent = statusMsg ? (
325
+ <text
326
+ fg={
327
+ statusMsg.tone === "success"
328
+ ? theme.colors.success
329
+ : theme.colors.danger
330
+ }
331
+ >
332
+ {statusMsg.text}
333
+ </text>
334
+ ) : undefined;
335
+
336
+ const routingState = snapshot?.status.state ?? null;
337
+ const statusContent = (
338
+ <text fg={theme.colors.text}>
339
+ <span fg={theme.colors.muted}>Model tiers: </span>
340
+ <span
341
+ fg={
342
+ routingState === "on"
343
+ ? theme.colors.success
344
+ : routingState === "stale"
345
+ ? theme.colors.warning
346
+ : routingState === "off" || routingState === null
347
+ ? theme.colors.muted
348
+ : theme.colors.danger
349
+ }
350
+ >
351
+ {routingState ?? "reading…"}
352
+ </span>
353
+ {snapshot?.status.preset ? (
354
+ <span fg={theme.colors.info}>{` │ ${snapshot.status.preset}`}</span>
355
+ ) : null}
356
+ {modelsState.isApplying ? (
357
+ <span fg={theme.colors.warning}> │ writing…</span>
358
+ ) : null}
359
+ </text>
360
+ );
361
+
362
+ // ── Render ────────────────────────────────────────────────────────────────
363
+
364
+ // The panel widths come from ScreenLayout, which owns the split, the padding
365
+ // and the scrollbar column. This screen used to re-derive them from
366
+ // `terminalWidth` with a copy of the formula, and the copy was wrong.
367
+ const query = modelsState.searchQuery.trim();
368
+
369
+ return (
370
+ <ScreenLayout
371
+ title="claudeup Models"
372
+ currentScreen="models"
373
+ statusLine={statusContent}
374
+ notice={noticeContent}
375
+ search={
376
+ modelsState.searchQuery || isSearchActive
377
+ ? {
378
+ isActive: isSearchActive,
379
+ query: modelsState.searchQuery,
380
+ placeholder: "type to filter",
381
+ }
382
+ : undefined
383
+ }
384
+ footerHints={
385
+ isSearchActive
386
+ ? [
387
+ { keys: ["type"], label: "filter" },
388
+ { keys: ["Enter"], label: "done" },
389
+ { keys: ["Esc"], label: "clear" },
390
+ ]
391
+ : [
392
+ { keys: ["Enter", "a"], label: "apply" },
393
+ { keys: ["c"], label: "off" },
394
+ { keys: ["r"], label: "reload" },
395
+ { keys: ["/"], label: "filter" },
396
+ ]
397
+ }
398
+ listPanel={(listWidth) => {
399
+ // ONE layout for every row, measured from the widest of them.
400
+ // Rows used to fit themselves, so at 120×30 the selected row
401
+ // dropped its `main` / `critical` labels while its neighbours kept
402
+ // theirs — four rows in two formats, nothing lining up.
403
+ const rowLayout = fitPresetList(allItems, listWidth);
404
+ return (
405
+ <box flexDirection="column">
406
+ {modelsState.data.status === "loading" && !snapshot && (
407
+ <box paddingLeft={2}>
408
+ <text fg={theme.colors.warning}>Reading the routing…</text>
409
+ </box>
410
+ )}
411
+
412
+ {modelsState.data.status === "error" && (
413
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
414
+ <text fg={theme.colors.danger}>
415
+ Could not read the model routing.
416
+ </text>
417
+ <box marginTop={1}>
418
+ <text fg={theme.colors.muted}>
419
+ {modelsState.data.error.message}
420
+ </text>
421
+ </box>
422
+ </box>
423
+ )}
424
+
425
+ {allItems.length > 0 && (
426
+ <ScrollableList
427
+ items={allItems}
428
+ selectedIndex={modelsState.selectedIndex}
429
+ renderItem={(item, index, isSelected) =>
430
+ renderModelRow(item, index, isSelected, rowLayout)
431
+ }
432
+ maxHeight={dimensions.listPanelHeight}
433
+ getKey={(item, index) => `${index}:${item.id}`}
434
+ />
435
+ )}
436
+
437
+ {query.length > 0 &&
438
+ allItems.length === 0 &&
439
+ modelsState.data.status !== "error" && (
440
+ <EmptyFilterState
441
+ query={modelsState.searchQuery}
442
+ entityName="presets"
443
+ />
444
+ )}
445
+
446
+ {/* Under the list, not in the detail pane. Four preset rows left
447
+ the rest of this panel unpainted, and the screen's one graphic
448
+ was buried below the fold of a pane nobody had scrolled. It
449
+ follows the cursor, so it answers "what would THIS preset do"
450
+ while you are choosing. */}
451
+ {allItems.length > 0 &&
452
+ renderModelsSummary({
453
+ items: allItems,
454
+ selected: selectedItem,
455
+ status: snapshot?.status ?? null,
456
+ configPath: snapshot?.path ?? null,
457
+ width: listWidth,
458
+ // Rows the list did not use. One held back because the
459
+ // search bar and the notice each take a row from the panel
460
+ // without taking one from `listPanelHeight` — and an
461
+ // over-tall panel overprints rather than clipping.
462
+ available:
463
+ dimensions.listPanelHeight -
464
+ Math.min(allItems.length, dimensions.listPanelHeight) -
465
+ 1,
466
+ })}
467
+ </box>
468
+ );
469
+ }}
470
+ detailPanel={(detailWidth) =>
471
+ renderModelDetail(selectedItem, snapshot?.status ?? null, detailWidth)
472
+ }
473
+ detailKey={selectedItem?.id ?? ""}
474
+ />
475
+ );
476
+ }
477
+
478
+ export default ModelsScreen;
@@ -760,7 +760,7 @@ export function StylesScreen() {
760
760
  // the way OUT of the tab, so a round trip lost it even though the state
761
761
  // now survives the unmount.
762
762
  const isNavigation =
763
- /^[1-9]$/.test(event.name ?? "") || event.name === "tab";
763
+ /^[0-9]$/.test(event.name ?? "") || event.name === "tab";
764
764
  if (stylesState.status && !isNavigation) {
765
765
  dispatch({ type: "STYLES_STATUS_CLEAR" });
766
766
  }
@@ -932,16 +932,9 @@ export function StylesScreen() {
932
932
 
933
933
  // ── Render ────────────────────────────────────────────────────────────────
934
934
 
935
- // ScreenLayout gives the list 49% and the detail 50%, inside a container
936
- // padded by 1 each side, and the detail panel adds 1 more of left padding.
937
- const listWidth = Math.max(
938
- 20,
939
- Math.floor(dimensions.terminalWidth * 0.49) - 3,
940
- );
941
- const detailWidth = Math.max(
942
- 24,
943
- Math.floor(dimensions.terminalWidth * 0.5) - 4,
944
- );
935
+ // The panel widths come from ScreenLayout, which owns the split, the padding
936
+ // and the scrollbar column. This screen used to re-derive them from
937
+ // `terminalWidth` with a copy of the formula, and the copy was wrong.
945
938
  const query = stylesState.searchQuery.trim();
946
939
  // `t` only means anything on a template preset, so it is only advertised
947
940
  // there — a footer full of keys that do nothing on the current row teaches
@@ -1019,7 +1012,7 @@ export function StylesScreen() {
1019
1012
  { keys: ["/"], label: "filter" },
1020
1013
  ]
1021
1014
  }
1022
- listPanel={
1015
+ listPanel={(listWidth) => (
1023
1016
  <box flexDirection="column">
1024
1017
  {stylesState.snapshot.status === "loading" && (
1025
1018
  <box paddingLeft={2}>
@@ -1059,8 +1052,10 @@ export function StylesScreen() {
1059
1052
  />
1060
1053
  )}
1061
1054
  </box>
1055
+ )}
1056
+ detailPanel={(detailWidth) =>
1057
+ renderStyleDetail(selectedItem, detailWidth)
1062
1058
  }
1063
- detailPanel={renderStyleDetail(selectedItem, detailWidth)}
1064
1059
  detailKey={selectedItem?.id ?? ""}
1065
1060
  />
1066
1061
  );
@@ -8,3 +8,4 @@ export { SkillsScreen } from "./SkillsScreen.js";
8
8
  export { StylesScreen } from "./StylesScreen.js";
9
9
  export { GitignoreScreen } from "./GitignoreScreen.js";
10
10
  export { AliasScreen } from "./AliasScreen.js";
11
+ export { ModelsScreen } from "./ModelsScreen.js";
@@ -73,6 +73,18 @@ export const initialState: AppState = {
73
73
  status: null,
74
74
  isFilling: false,
75
75
  },
76
+
77
+ models: {
78
+ // The status header sits at 0 and the cursor cannot land on it. The screen
79
+ // moves off it on first render; starting at 1 here would be a second place
80
+ // encoding the list's shape, and it would be wrong the moment the header
81
+ // gained a row.
82
+ selectedIndex: 0,
83
+ searchQuery: "",
84
+ data: { status: "idle" },
85
+ status: null,
86
+ isApplying: false,
87
+ },
76
88
  };
77
89
 
78
90
  export function appReducer(state: AppState, action: AppAction): AppState {
@@ -654,6 +666,88 @@ export function appReducer(state: AppState, action: AppAction): AppState {
654
666
  styles: { ...state.styles, selected: new Set(action.ids) },
655
667
  };
656
668
 
669
+ // =========================================================================
670
+ // Models screen
671
+ // =========================================================================
672
+ case "MODELS_SELECT":
673
+ return {
674
+ ...state,
675
+ models: { ...state.models, selectedIndex: action.index },
676
+ };
677
+
678
+ case "MODELS_SET_SEARCH":
679
+ return {
680
+ ...state,
681
+ models: { ...state.models, searchQuery: action.query },
682
+ };
683
+
684
+ case "MODELS_SEARCH_APPEND":
685
+ return {
686
+ ...state,
687
+ models: {
688
+ ...state.models,
689
+ searchQuery: state.models.searchQuery + action.char,
690
+ selectedIndex: 0,
691
+ },
692
+ };
693
+
694
+ case "MODELS_SEARCH_BACKSPACE":
695
+ return {
696
+ ...state,
697
+ models: {
698
+ ...state.models,
699
+ searchQuery: state.models.searchQuery.slice(0, -1),
700
+ selectedIndex: 0,
701
+ },
702
+ };
703
+
704
+ case "MODELS_DATA_LOADING":
705
+ return {
706
+ ...state,
707
+ models: {
708
+ ...state.models,
709
+ // Carry the last good snapshot through the reload. The router
710
+ // unmounts this screen on every tab switch, so without it a return
711
+ // visit blanks the list to "Reading…" and the cursor resets — and
712
+ // reading the routing is not instant: it scans session transcripts
713
+ // to resolve the live model id behind each alias.
714
+ data: {
715
+ status: "loading",
716
+ previous: asyncValue(state.models.data),
717
+ },
718
+ },
719
+ };
720
+
721
+ case "MODELS_DATA_SUCCESS":
722
+ return {
723
+ ...state,
724
+ models: {
725
+ ...state.models,
726
+ data: { status: "success", data: action.snapshot },
727
+ },
728
+ };
729
+
730
+ case "MODELS_DATA_ERROR":
731
+ return {
732
+ ...state,
733
+ models: {
734
+ ...state.models,
735
+ data: { status: "error", error: action.error },
736
+ },
737
+ };
738
+
739
+ case "MODELS_STATUS_SET":
740
+ return { ...state, models: { ...state.models, status: action.status } };
741
+
742
+ case "MODELS_STATUS_CLEAR":
743
+ return { ...state, models: { ...state.models, status: null } };
744
+
745
+ case "MODELS_APPLY_START":
746
+ return { ...state, models: { ...state.models, isApplying: true } };
747
+
748
+ case "MODELS_APPLY_END":
749
+ return { ...state, models: { ...state.models, isApplying: false } };
750
+
657
751
  // =========================================================================
658
752
  // Modals
659
753
  // =========================================================================