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.
- package/package.json +4 -4
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/manifest.test.ts +134 -0
- package/src/__tests__/model-visuals.test.tsx +789 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/profile-materializer.test.ts +46 -0
- package/src/__tests__/resolver.test.ts +36 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/models-presets.ts +281 -0
- package/src/data/predefined-profiles.ts +9 -0
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +11 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- 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 ?
|
|
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}>
|
|
317
|
-
|
|
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 ?
|
|
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 ?
|
|
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
|
-
/^[
|
|
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
|
-
//
|
|
936
|
-
//
|
|
937
|
-
|
|
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
|
);
|
package/src/ui/screens/index.ts
CHANGED
package/src/ui/state/reducer.ts
CHANGED
|
@@ -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
|
// =========================================================================
|