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
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Read/modify/write a Claude Code `settings.json`, in place.
3
+ *
4
+ * Two rules, and both exist because of damage they prevent:
5
+ *
6
+ * 1. **Write IN PLACE, never remove-and-recreate.** `.claude/settings.json` is
7
+ * a SYMLINK into `.claude/_profiles/<name>/` whenever a profile is active,
8
+ * and `writeFile` follows it — which is what we want. Unlinking the file
9
+ * first would break the link and silently detach the project from its
10
+ * profile, leaving the next read looking at a fresh, empty file.
11
+ * 2. **Unparseable is an ERROR, not an empty object.** A settings file that
12
+ * fails to parse is a file we do not understand, usually because a human is
13
+ * mid-edit. Treating it as `{}` and writing our one key back is how a
14
+ * user's whole configuration disappears. We refuse and say so.
15
+ *
16
+ * A MISSING file is different from an unparseable one: nothing is lost by
17
+ * starting from `{}`, so that is what a missing (or empty) file reads as.
18
+ */
19
+
20
+ import path from "node:path";
21
+ import fs from "fs-extra";
22
+
23
+ /**
24
+ * Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws.
25
+ *
26
+ * Exported for callers that only need the read half — the refusal wording is
27
+ * the same either way, so nothing has to reimplement it.
28
+ */
29
+ export async function readSettingsFile(
30
+ settingsPath: string,
31
+ ): Promise<Record<string, unknown>> {
32
+ if (!(await fs.pathExists(settingsPath))) return {};
33
+
34
+ const raw = (await fs.readFile(settingsPath, "utf8")).trim();
35
+ if (!raw) return {};
36
+
37
+ try {
38
+ return JSON.parse(raw) as Record<string, unknown>;
39
+ } catch (error) {
40
+ throw new Error(
41
+ `${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read, apply `mutate`, write back — preserving every key we did not touch.
48
+ *
49
+ * `mutate` may edit the object in place and return nothing, or return a
50
+ * replacement object. Nothing is written when the read throws, so a settings
51
+ * file we cannot parse is left exactly as the user left it.
52
+ */
53
+ export async function updateSettingsFile(
54
+ settingsPath: string,
55
+ // biome-ignore lint/suspicious/noConfusingVoidType: the union IS the contract — mutate in place and return nothing, or return a replacement
56
+ mutate: (settings: Record<string, unknown>) => Record<string, unknown> | void,
57
+ ): Promise<void> {
58
+ const settings = await readSettingsFile(settingsPath);
59
+ const next =
60
+ (mutate(settings) as Record<string, unknown> | undefined) ?? settings;
61
+
62
+ await fs.ensureDir(path.dirname(settingsPath));
63
+ // writeFile, never remove-then-create: see rule 1 above.
64
+ await fs.writeFile(
65
+ settingsPath,
66
+ `${JSON.stringify(next, null, 2)}\n`,
67
+ "utf8",
68
+ );
69
+ }
@@ -37,7 +37,13 @@ import path from "node:path";
37
37
  import fs from "fs-extra";
38
38
  import { findCommunityStyle } from "../data/community-styles.js";
39
39
  import { EMBEDDED_PRESETS } from "../data/styles/index.js";
40
- import { getManifestPath, readManifest, writeManifest } from "./manifest.js";
40
+ import {
41
+ getManifestPath,
42
+ mergeManifestSettings,
43
+ readManifest,
44
+ writeManifest,
45
+ } from "./manifest.js";
46
+ import { updateSettingsFile } from "./settings-file.js";
41
47
  import { activeProfile } from "./symlink-manager.js";
42
48
 
43
49
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -1051,58 +1057,35 @@ export function composeStyleFile(
1051
1057
  /**
1052
1058
  * Set `outputStyle` in settings.json, preserving everything else.
1053
1059
  *
1054
- * Writes in place. `.claude/settings.json` may be a symlink into an active
1055
- * profile's materialized directory `writeFile` follows it, which is what we
1056
- * want. Removing and recreating the file would break the link and silently
1057
- * detach the project from its profile.
1060
+ * Writes in place, and refuses a file it cannot parse — both are
1061
+ * `updateSettingsFile`'s contract, and both are documented there.
1058
1062
  */
1059
1063
  async function setOutputStyle(
1060
1064
  settingsPath: string,
1061
1065
  styleName: string,
1062
1066
  ): Promise<void> {
1063
- let settings: Record<string, unknown> = {};
1064
- if (await fs.pathExists(settingsPath)) {
1065
- const raw = (await fs.readFile(settingsPath, "utf8")).trim();
1066
- if (raw) {
1067
- try {
1068
- settings = JSON.parse(raw) as Record<string, unknown>;
1069
- } catch (error) {
1070
- throw new Error(
1071
- `${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
1072
- );
1073
- }
1074
- }
1075
- }
1076
- settings.outputStyle = styleName;
1077
- await fs.ensureDir(path.dirname(settingsPath));
1078
- await fs.writeFile(
1079
- settingsPath,
1080
- `${JSON.stringify(settings, null, 2)}\n`,
1081
- "utf8",
1082
- );
1067
+ await updateSettingsFile(settingsPath, (settings) => {
1068
+ settings.outputStyle = styleName;
1069
+ });
1083
1070
  }
1084
1071
 
1085
1072
  /**
1086
1073
  * Record the style in the committed manifest so it survives re-materialization.
1087
1074
  * Returns false when there is no active profile or no manifest entry for it —
1088
1075
  * both are normal, and neither is an error.
1076
+ *
1077
+ * `mergeManifestSettings` is shared with model routing, which writes the same
1078
+ * `profiles.<name>.settings` for the same reason. It throws on a manifest it
1079
+ * cannot parse rather than reporting a success it did not achieve.
1089
1080
  */
1090
1081
  async function recordInManifest(
1091
1082
  projectPath: string,
1092
1083
  profile: string | null,
1093
1084
  styleName: string,
1094
1085
  ): Promise<boolean> {
1095
- if (!profile) return false;
1096
- if (!(await fs.pathExists(getManifestPath(projectPath)))) return false;
1097
-
1098
- const manifest = await readManifest(projectPath);
1099
- const entry = manifest.profiles[profile];
1100
- if (!entry) return false;
1101
-
1102
- entry.settings = { ...(entry.settings ?? {}), outputStyle: styleName };
1103
- entry.updatedAt = new Date().toISOString();
1104
- await writeManifest(manifest, projectPath);
1105
- return true;
1086
+ return mergeManifestSettings(projectPath, profile, {
1087
+ outputStyle: styleName,
1088
+ });
1106
1089
  }
1107
1090
 
1108
1091
  export interface ApplyStylesArgs {
@@ -1285,20 +1268,15 @@ export async function createTeamStyle(
1285
1268
  */
1286
1269
  export async function clearStyle(projectPath: string): Promise<void> {
1287
1270
  const settingsPath = settingsPathFor(projectPath);
1271
+ // The guard stays: a project that never had a style must not gain a
1272
+ // settings.json just because the style was cleared.
1288
1273
  if (await fs.pathExists(settingsPath)) {
1289
- const raw = (await fs.readFile(settingsPath, "utf8")).trim();
1290
- if (raw) {
1291
- const settings = JSON.parse(raw) as Record<string, unknown>;
1274
+ await updateSettingsFile(settingsPath, (settings) => {
1292
1275
  // The key must be ABSENT, not present-and-undefined: Claude Code reads
1293
1276
  // presence, and an `= undefined` assignment still answers `in` checks.
1294
1277
  // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1295
1278
  delete settings.outputStyle;
1296
- await fs.writeFile(
1297
- settingsPath,
1298
- `${JSON.stringify(settings, null, 2)}\n`,
1299
- "utf8",
1300
- );
1301
- }
1279
+ });
1302
1280
  }
1303
1281
 
1304
1282
  const profile = await activeProfile(projectPath);
@@ -8,10 +8,17 @@
8
8
  * .claude/settings.json -> .claude/_profiles/<name>/settings.json
9
9
  * .claude/skills -> .claude/_profiles/<name>/skills
10
10
  * .mcp.json -> .claude/_profiles/<name>/mcp.json
11
+ * .claude/models.json -> .claude/_profiles/<name>/models.json
11
12
  *
12
13
  * Switching profiles just repoints these links — no network, no reinstall.
13
14
  * settings.local.json is NEVER linked: it holds personal credentials and must
14
15
  * survive switches untouched.
16
+ *
17
+ * models.json is the one link whose target is OPTIONAL: a profile that declares
18
+ * no routing has none, and the rule below (remove a link into _profiles/ whose
19
+ * target is gone) is what keeps a dangling `.claude/models.json` out of the
20
+ * repo. A dangling one would not be harmless — `.claude/` is committed, so it
21
+ * would ship to every teammate.
15
22
  */
16
23
 
17
24
  import path from "node:path";
@@ -27,6 +34,7 @@ export const PROFILE_GITIGNORE_ENTRIES = [
27
34
  ".claude/_profiles/",
28
35
  ".claude/settings.json",
29
36
  ".claude/skills",
37
+ ".claude/models.json",
30
38
  ".mcp.json",
31
39
  ];
32
40
 
@@ -41,26 +49,37 @@ export function profileDir(name: string, projectPath?: string): string {
41
49
  return path.join(profilesRoot(projectPath), name);
42
50
  }
43
51
 
52
+ /** The artifacts an active profile links into place. */
53
+ export type ProfileLinkKey = "settings" | "skills" | "mcp" | "models";
54
+
44
55
  /** The set of links an active profile owns, as [linkPath, targetPath] pairs. */
45
- function linkTargets(
56
+ export function linkTargets(
46
57
  name: string,
47
58
  projectPath?: string,
48
- ): Array<{ link: string; target: string }> {
59
+ ): Array<{ key: ProfileLinkKey; link: string; target: string }> {
49
60
  const base = projectPath ?? process.cwd();
50
61
  const dir = profileDir(name, projectPath);
51
62
  return [
52
63
  {
64
+ key: "settings",
53
65
  link: path.join(base, ".claude", "settings.json"),
54
66
  target: path.join(dir, "settings.json"),
55
67
  },
56
68
  {
69
+ key: "skills",
57
70
  link: path.join(base, ".claude", "skills"),
58
71
  target: path.join(dir, "skills"),
59
72
  },
60
73
  {
74
+ key: "mcp",
61
75
  link: path.join(base, ".mcp.json"),
62
76
  target: path.join(dir, "mcp.json"),
63
77
  },
78
+ {
79
+ key: "models",
80
+ link: path.join(base, ".claude", "models.json"),
81
+ target: path.join(dir, "models.json"),
82
+ },
64
83
  ];
65
84
  }
66
85
 
@@ -93,16 +112,43 @@ export async function activateProfile(
93
112
  );
94
113
  }
95
114
  for (const { link, target } of linkTargets(name, projectPath)) {
96
- if (await fs.pathExists(target)) {
97
- await relink(link, target);
98
- } else {
99
- // Profile doesn't provide this artifact — remove a stale link only if
100
- // it currently points into a profile dir (don't clobber a real file).
101
- if (await isProfileLink(link, projectPath)) {
102
- await fs.remove(link);
103
- }
104
- }
115
+ await syncLink(link, target, projectPath);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Point one link at its target, or remove it when the profile has no such
121
+ * artifact. A link is only removed when it points into `_profiles/` — a real
122
+ * file the user put there is never clobbered.
123
+ */
124
+ async function syncLink(
125
+ link: string,
126
+ target: string,
127
+ projectPath?: string,
128
+ ): Promise<void> {
129
+ if (await fs.pathExists(target)) {
130
+ await relink(link, target);
131
+ return;
105
132
  }
133
+ if (await isProfileLink(link, projectPath)) await fs.remove(link);
134
+ }
135
+
136
+ /**
137
+ * Sync ONE of the active profile's links, leaving the others alone.
138
+ *
139
+ * `activateProfile` repoints all four, which is right when switching profiles
140
+ * and wrong when only one artifact changed: `claudeup models use` would replace
141
+ * a project's real `.mcp.json` with a symlink as a side effect of choosing a
142
+ * model. This is the narrow door for that case.
143
+ */
144
+ export async function syncProfileLink(
145
+ name: string,
146
+ key: ProfileLinkKey,
147
+ projectPath?: string,
148
+ ): Promise<void> {
149
+ const entry = linkTargets(name, projectPath).find((t) => t.key === key);
150
+ if (!entry) return;
151
+ await syncLink(entry.link, entry.target, projectPath);
106
152
  }
107
153
 
108
154
  /** Is `link` a symlink pointing into `.claude/_profiles/`? */
package/src/tui.tsx ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The interactive TUI: renderer creation, theme resolution, and the React root.
3
+ *
4
+ * Split out of `main.tsx` so that nothing on a non-interactive path — above all
5
+ * `claudeup hook …`, which Claude Code runs on every matching tool call — has
6
+ * to load `@opentui/*` or `src/ui/` to reach its own code. `main.tsx` imports
7
+ * this module dynamically, at the last possible moment.
8
+ */
9
+
10
+ import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
11
+ import { createRoot } from "@opentui/react";
12
+ import { App } from "./ui/App.js";
13
+ import { setThemeMode } from "./ui/theme-mode.js";
14
+ import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
15
+
16
+ export interface StartTuiOptions {
17
+ /** From `--theme`, already validated by the router. */
18
+ theme?: ThemeMode;
19
+ /** The theme variables, snapshotted in main.tsx BEFORE any .env merge. */
20
+ env: ThemeEnv;
21
+ /** stdin AND stdout are TTYs. Decided by the caller. */
22
+ isInteractive: boolean;
23
+ }
24
+
25
+ /**
26
+ * Start the TUI. Never resolves — the app runs until the user exits.
27
+ */
28
+ export async function startTui({
29
+ theme,
30
+ env,
31
+ isInteractive,
32
+ }: StartTuiOptions): Promise<void> {
33
+ // Create OpenTUI renderer (handles alternate screen buffer automatically).
34
+ //
35
+ // The background is the terminal's own, not a colour of ours: painting an
36
+ // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
37
+ // built-in default for unstyled cells is truecolor white, which is why every
38
+ // element now names an adaptive colour explicitly — see src/ui/theme.ts.
39
+ // The mouse is ON, everywhere — it carries real features now: the wheel
40
+ // scrolls the pane under the cursor, and a drag selects text from ONE pane
41
+ // at a time (terminal-native selection is a screen-wide rectangle that
42
+ // grabs both columns), OSC 52-copied on release below.
43
+ //
44
+ // This deliberately includes tmux. Two earlier revisions got this wrong in
45
+ // opposite directions: one disabled the mouse everywhere on the theory that
46
+ // capture breaks tmux-level pane clicking, the next kept it off only inside
47
+ // tmux. Both dated from when claudeup had no mouse features, so disabling
48
+ // cost nothing. MEASURED on a heavily tmux'd machine: tmux forwards mouse
49
+ // to a pane application that asks for it (htop in the same session took
50
+ // clicks and wheel fine while claudeup sat inert), and tmux's own `mouse`
51
+ // option keeps governing pane management. Terminal-native selection stays
52
+ // reachable via Shift+drag in most terminals.
53
+ //
54
+ // enableMouseMovement (mode-1003 "report all motion") stays off — selection
55
+ // needs only button-held drag events, which button reporting delivers.
56
+ const renderer = await createCliRenderer({
57
+ backgroundColor: RGBA.defaultBackground(),
58
+ useMouse: true,
59
+ enableMouseMovement: false,
60
+ });
61
+
62
+ // Mouse selection → system clipboard, on release. OSC 52 survives SSH;
63
+ // terminals that block it simply ignore the sequence. Never fires where the
64
+ // mouse is off, so the tmux path pays nothing.
65
+ renderer.on("selection", (selection: { getSelectedText(): string }) => {
66
+ const text = selection?.getSelectedText() ?? "";
67
+ if (text.length > 0) renderer.copyToClipboardOSC52(text);
68
+ });
69
+
70
+ // Resolve light vs dark once: --theme, CLAUDEUP_THEME, TERM_THEME, the OSC
71
+ // probe, COLORFGBG, dark — see src/ui/theme-resolve.ts. Only the disabled-row
72
+ // tint and the chips need this (src/ui/theme-mode.ts explains why that one
73
+ // case cannot be solved the way the rest of the palette is).
74
+ //
75
+ // The renderer sends OSC 10/11 at creation regardless: @opentui/core 0.1.107
76
+ // has no option to suppress it. So the rule "TERM_THEME skips the probe" is
77
+ // satisfied by NOT AWAITING — when steps 1-3 answer, `probe` is never called,
78
+ // `waitForThemeMode(250)` never runs, first paint is not delayed, and
79
+ // `renderer.themeMode` is never read anywhere in claudeup. Any late reply the
80
+ // terminal sends is consumed by OpenTUI's own handleSequence and ignored here.
81
+ const { mode } = await resolveTheme({
82
+ flag: theme,
83
+ env,
84
+ isInteractive,
85
+ probe: () => renderer.waitForThemeMode(250).catch(() => null),
86
+ });
87
+ setThemeMode(mode);
88
+
89
+ const root = createRoot(renderer);
90
+
91
+ // Cleanup function to restore terminal
92
+ const cleanup = () => {
93
+ root.unmount();
94
+ renderer.destroy(); // CRITICAL: Never use process.exit() directly
95
+ };
96
+
97
+ // Handle cleanup on exit signals
98
+ const handleExit = () => {
99
+ cleanup();
100
+ // Exit after cleanup completes
101
+ process.exit(0);
102
+ };
103
+
104
+ process.on("SIGINT", handleExit);
105
+ process.on("SIGTERM", handleExit);
106
+
107
+ // Render the OpenTUI app with exit handler
108
+ root.render(<App onExit={handleExit} />);
109
+
110
+ // Wait indefinitely (app runs until user exits)
111
+ await new Promise(() => {});
112
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The sliver of Bun's global this project actually uses.
3
+ *
4
+ * `@types/bun` is deliberately NOT a dependency — `utils/command-utils.ts` says
5
+ * so where it reaches for Node's `fs` instead of `Bun.which` — and the whole
6
+ * package would be pulled in to type two properties. `cli/hook.ts` reads its
7
+ * payload from `Bun.stdin.stream()` because that is the fastest path on the
8
+ * hottest code claudeup has (a PreToolUse hook runs on every Agent call), so
9
+ * the global is declared here rather than the call being rewritten.
10
+ *
11
+ * Keep this MINIMAL. Every member added here is a member nobody checks against
12
+ * the real runtime; a full mirror of Bun's API would be a second, worse copy of
13
+ * `@types/bun` that drifts silently. If this grows past a few members, take the
14
+ * dependency instead.
15
+ */
16
+
17
+ declare namespace Bun {
18
+ const stdin: {
19
+ stream(): AsyncIterable<Uint8Array>;
20
+ };
21
+ }
@@ -1,3 +1,5 @@
1
+ import type { ModelsConfig } from "../services/models-core.js";
2
+
1
3
  export interface McpServer {
2
4
  name: string;
3
5
  description: string;
@@ -366,6 +368,12 @@ export interface ProfileManifestEntry {
366
368
  cliTools?: Record<string, string>;
367
369
  skills?: ProfileSkillRef[];
368
370
  settings?: Record<string, unknown>;
371
+ /**
372
+ * Per-subagent model routing. A PROFILE property, so switching profiles
373
+ * switches routing, and materialization writes it to
374
+ * `_profiles/<name>/models.json` for `.claude/models.json` to link at.
375
+ */
376
+ models?: ModelsConfig;
369
377
  env?: ProfileEnvRequirements;
370
378
  /** Optional bookkeeping carried over from v1 saved profiles. */
371
379
  createdAt?: string;
@@ -427,6 +435,12 @@ export interface ResolvedClosure {
427
435
  bins: ResolvedBin[];
428
436
  skills: ProfileSkillRef[];
429
437
  settings: Record<string, unknown>;
438
+ /**
439
+ * The profile's model routing, if it declares any. Absent means the
440
+ * materialized profile carries no models.json — and materialization DELETES
441
+ * a stale one, which is what makes `claudeup models off` take effect.
442
+ */
443
+ models?: ModelsConfig;
430
444
  env: { required: string[]; optional: string[] };
431
445
  /** Cross-profile version conflicts detected while unioning profiles. */
432
446
  conflicts?: string[];
package/src/ui/App.tsx CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  GitignoreScreen,
34
34
  McpRegistryScreen,
35
35
  McpScreen,
36
+ ModelsScreen,
36
37
  PluginsScreen,
37
38
  ProfilesScreen,
38
39
  SettingsScreen,
@@ -83,6 +84,8 @@ function Router() {
83
84
  return <GitignoreScreen />;
84
85
  case "alias":
85
86
  return <AliasScreen />;
87
+ case "models":
88
+ return <ModelsScreen />;
86
89
  default:
87
90
  return <PluginsScreen />;
88
91
  }
@@ -133,7 +136,8 @@ function GlobalKeyHandler({
133
136
  // Don't handle keys when modal is open or searching
134
137
  if (state.modal || state.isSearching) return;
135
138
 
136
- // Global navigation shortcuts (1-9) - include mcp-registry as it's a sub-screen of mcp
139
+ // Global navigation shortcuts (1-9, then 0) - include mcp-registry as it's
140
+ // a sub-screen of mcp
137
141
  const isTopLevel = [
138
142
  "plugins",
139
143
  "mcp",
@@ -145,6 +149,7 @@ function GlobalKeyHandler({
145
149
  "styles",
146
150
  "gitignore",
147
151
  "alias",
152
+ "models",
148
153
  ].includes(state.currentRoute.screen);
149
154
 
150
155
  if (isTopLevel) {
@@ -157,6 +162,7 @@ function GlobalKeyHandler({
157
162
  else if (input === "7") navigateToScreen("gitignore");
158
163
  else if (input === "8") navigateToScreen("alias");
159
164
  else if (input === "9") navigateToScreen("styles");
165
+ else if (input === "0") navigateToScreen("models");
160
166
 
161
167
  // Tab navigation cycling
162
168
  if (key.tab) {
@@ -170,6 +176,7 @@ function GlobalKeyHandler({
170
176
  "gitignore",
171
177
  "alias",
172
178
  "styles",
179
+ "models",
173
180
  ];
174
181
  const currentIndex = screens.indexOf(
175
182
  state.currentRoute.screen as Screen,
@@ -217,7 +224,7 @@ function GlobalKeyHandler({
217
224
  ? This help
218
225
 
219
226
  Quick Navigation
220
- 1 Plugins 4 Settings 7 Git State
227
+ 1 Plugins 4 Settings 7 Git State 0 Models
221
228
  2 Skills 5 Profiles 8 Alias
222
229
  3 MCP Servers 6 CLI Tools 9 Styles
223
230
 
@@ -233,7 +240,12 @@ Styles
233
240
  Space Tick / untick a style
234
241
  a Apply the selection to this project
235
242
  x Reset the selection to what is live
236
- c Clear the active output style`,
243
+ c Clear the active output style
244
+
245
+ Models
246
+ a / Enter Route this project through the selected preset
247
+ c Turn model tiers off
248
+ r Re-read the config`,
237
249
  "info",
238
250
  );
239
251
  }