@saadnvd1/agent-os 0.3.470 → 0.3.472

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 (95) hide show
  1. package/README.md +139 -36
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/models/route.ts +34 -0
  4. package/app/api/models/test/route.ts +17 -0
  5. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  6. package/app/api/tasks/[id]/model/route.ts +23 -0
  7. package/app/api/tasks/route.ts +5 -1
  8. package/app/api/usage/compare/route.ts +21 -0
  9. package/app/api/usage/route.ts +8 -3
  10. package/app/api/workspaces/[id]/models/route.ts +27 -0
  11. package/components/Chat/ChatPanel.tsx +5 -5
  12. package/components/Chat/StaleChip.tsx +7 -6
  13. package/components/CommandPalette/useAppCommands.ts +19 -1
  14. package/components/Draft/DraftChips.tsx +28 -0
  15. package/components/Draft/DraftPanel.tsx +1 -1
  16. package/components/Models/ModelSelect.tsx +67 -0
  17. package/components/Models/ModelsSection.tsx +199 -0
  18. package/components/Models/ProviderCard.tsx +271 -0
  19. package/components/SessionList/SidebarHeader.tsx +5 -1
  20. package/components/Settings/SettingsDialog.test.ts +3 -0
  21. package/components/Settings/SettingsDialog.tsx +9 -0
  22. package/components/Settings/commands.ts +19 -0
  23. package/components/Settings/entry-points.test.ts +1 -0
  24. package/components/Tasks/ResumeOnModel.tsx +72 -0
  25. package/components/Tasks/TaskRow.tsx +4 -0
  26. package/components/Usage/CompareView.tsx +211 -0
  27. package/components/Usage/ModelSpend.tsx +133 -0
  28. package/components/Usage/UsageDialog.tsx +69 -5
  29. package/components/Usage/format.ts +9 -0
  30. package/components/Workspaces/FreshStartForm.tsx +13 -13
  31. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  32. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  33. package/data/models/index.ts +125 -0
  34. package/data/usage/index.ts +5 -0
  35. package/lib/agents/launch.ts +13 -2
  36. package/lib/chat/context.ts +1 -1
  37. package/lib/chat/refresh.ts +31 -13
  38. package/lib/chat/runner.ts +5 -0
  39. package/lib/chat/stale.ts +25 -16
  40. package/lib/chat/worker/extras.ts +12 -0
  41. package/lib/chat/worker/host.ts +70 -24
  42. package/lib/chat/worker/main.ts +5 -2
  43. package/lib/db/migrations.ts +70 -0
  44. package/lib/db/types.ts +6 -0
  45. package/lib/drafts.ts +3 -0
  46. package/lib/models/config.ts +218 -0
  47. package/lib/models/launch.ts +93 -0
  48. package/lib/models/plan-step.ts +94 -0
  49. package/lib/models/providers.ts +163 -0
  50. package/lib/models/resolve.ts +246 -0
  51. package/lib/models/resume.ts +99 -0
  52. package/lib/models/roles.ts +98 -0
  53. package/lib/models/secrets.ts +86 -0
  54. package/lib/models/session-model.ts +40 -0
  55. package/lib/models/test-provider.ts +116 -0
  56. package/lib/orchestrator/act.ts +13 -1
  57. package/lib/orchestrator/armed.ts +1 -2
  58. package/lib/orchestrator/brief.ts +5 -4
  59. package/lib/orchestrator/claude-cli.ts +48 -14
  60. package/lib/orchestrator/diff.ts +1 -1
  61. package/lib/orchestrator/external-gates.ts +8 -43
  62. package/lib/orchestrator/gates.ts +10 -10
  63. package/lib/orchestrator/home.ts +2 -2
  64. package/lib/orchestrator/pr-wake.ts +2 -2
  65. package/lib/orchestrator/rebase.ts +6 -45
  66. package/lib/orchestrator/review.ts +20 -5
  67. package/lib/orchestrator/scope.ts +11 -0
  68. package/lib/orchestrator/serve.ts +13 -0
  69. package/lib/orchestrator/signoff.ts +13 -8
  70. package/lib/orchestrator/tool-names.ts +1 -0
  71. package/lib/orchestrator/tool-schemas.ts +38 -0
  72. package/lib/orchestrator/tools.ts +3 -1
  73. package/lib/pr-generation.ts +5 -2
  74. package/lib/providers/registry.ts +1 -1
  75. package/lib/session-audit/render.ts +1 -1
  76. package/lib/session-titles.ts +1 -1
  77. package/lib/sessions/launch.ts +19 -4
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/stacks/land.ts +23 -16
  80. package/lib/tasks/brief.ts +3 -14
  81. package/lib/tasks/code-review.ts +69 -107
  82. package/lib/tasks/gh.ts +3 -2
  83. package/lib/tasks/index.ts +60 -9
  84. package/lib/tasks/queue.ts +14 -5
  85. package/lib/tasks/start.ts +7 -2
  86. package/lib/tasks/state.ts +4 -4
  87. package/lib/usage/by-model.ts +105 -0
  88. package/lib/usage/check-runs.ts +170 -0
  89. package/lib/usage/compare.ts +280 -0
  90. package/lib/usage/cost.ts +71 -0
  91. package/lib/usage/report.ts +51 -0
  92. package/lib/usage/turns.ts +107 -18
  93. package/package.json +1 -1
  94. package/stores/usageUi.ts +11 -2
  95. package/scripts/check-code-review.ts +0 -21
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectGroup,
7
+ SelectItem,
8
+ SelectLabel,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "@/components/ui/select";
12
+ import { modelChoices } from "@/data/models";
13
+ import type { EffectiveModels } from "@/lib/models/config";
14
+
15
+ const INHERIT = "__inherit";
16
+
17
+ // A "provider/model" picker over every enabled provider's models, grouped
18
+ // by provider. With `inheritLabel`, a first option that clears it.
19
+ export function ModelSelect({
20
+ id,
21
+ value,
22
+ effective,
23
+ inheritLabel,
24
+ disabled,
25
+ onChange,
26
+ }: {
27
+ id?: string;
28
+ value: string | null;
29
+ effective: EffectiveModels | undefined;
30
+ inheritLabel?: string;
31
+ disabled?: boolean;
32
+ onChange: (ref: string | null) => void;
33
+ }) {
34
+ const choices = modelChoices(effective);
35
+ const groups = [...new Set(choices.map((c) => c.provider))];
36
+ // A saved model no provider lists any more still shows as itself.
37
+ const missing = value && !choices.some((c) => c.ref === value);
38
+ return (
39
+ <Select
40
+ value={value ?? INHERIT}
41
+ onValueChange={(v) => onChange(v === INHERIT ? null : v)}
42
+ disabled={disabled}
43
+ >
44
+ <SelectTrigger id={id} className="h-11 md:h-9">
45
+ <SelectValue />
46
+ </SelectTrigger>
47
+ <SelectContent>
48
+ {inheritLabel && (
49
+ <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
50
+ )}
51
+ {missing && <SelectItem value={value}>{value}</SelectItem>}
52
+ {groups.map((g) => (
53
+ <SelectGroup key={g}>
54
+ <SelectLabel>{g}</SelectLabel>
55
+ {choices
56
+ .filter((c) => c.provider === g)
57
+ .map((c) => (
58
+ <SelectItem key={c.ref} value={c.ref}>
59
+ {c.label}
60
+ </SelectItem>
61
+ ))}
62
+ </SelectGroup>
63
+ ))}
64
+ </SelectContent>
65
+ </Select>
66
+ );
67
+ }
@@ -0,0 +1,199 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { SettingToggle } from "@/components/Settings/SettingToggle";
6
+ import { useModelsQuery, useUpdateModels } from "@/data/models";
7
+ import type { ModelSettings } from "@/lib/models/config";
8
+ import {
9
+ PROVIDER_PRESETS,
10
+ type ModelPrice,
11
+ type ModelProvider,
12
+ } from "@/lib/models/providers";
13
+ import { MODEL_ROLES, ROLE_LABELS } from "@/lib/models/roles";
14
+ import { ModelSelect } from "./ModelSelect";
15
+ import { ProviderCard } from "./ProviderCard";
16
+
17
+ const PRICE_FIELDS: { key: keyof ModelPrice; label: string }[] = [
18
+ { key: "input", label: "Input" },
19
+ { key: "cacheWrite", label: "Cache write" },
20
+ { key: "cacheRead", label: "Cache read" },
21
+ { key: "output", label: "Output" },
22
+ ];
23
+
24
+ // Settings > Models: the model each kind of work runs on, the providers
25
+ // they can come from, and the prices usage is estimated at.
26
+ export function ModelsSection() {
27
+ const { data, isPending, isError, error } = useModelsQuery();
28
+ const update = useUpdateModels();
29
+ if (isPending)
30
+ return <div className="bg-muted/40 h-64 animate-pulse rounded-lg" />;
31
+ if (isError)
32
+ return <p className="text-destructive text-sm">{error.message}</p>;
33
+ const { saved, effective, defaults } = data;
34
+ const save = (patch: Partial<ModelSettings>) =>
35
+ update.mutate({ ...saved, ...patch });
36
+
37
+ const presetIds = new Set(PROVIDER_PRESETS.map((p) => p.id));
38
+ const saveProvider = (p: ModelProvider) =>
39
+ save({
40
+ providers: [...(saved.providers ?? []).filter((x) => x.id !== p.id), p],
41
+ });
42
+ const addProvider = () => {
43
+ const taken = new Set(effective.providers.map((p) => p.id));
44
+ let n = 1;
45
+ while (taken.has(`custom-${n}`)) n++;
46
+ saveProvider({
47
+ id: `custom-${n}`,
48
+ label: `Custom ${n}`,
49
+ api: "anthropic",
50
+ authHeader: "bearer",
51
+ models: [],
52
+ enabled: false,
53
+ });
54
+ };
55
+
56
+ return (
57
+ <div className="space-y-6">
58
+ <section className="space-y-2">
59
+ <p className="text-muted-foreground text-sm">
60
+ Which model each kind of work runs on. A workspace can set its own
61
+ (Settings › Workspaces), and a task its own when it starts or resumes.
62
+ </p>
63
+ <ul className="space-y-2">
64
+ {MODEL_ROLES.map((role) => (
65
+ <li
66
+ key={role}
67
+ className="bg-muted/40 space-y-1.5 rounded-lg px-3 py-2.5"
68
+ >
69
+ <label htmlFor={`role-${role}`} className="block">
70
+ <span className="text-sm font-medium">
71
+ {ROLE_LABELS[role].label}
72
+ </span>
73
+ <span className="text-muted-foreground block text-xs">
74
+ {ROLE_LABELS[role].hint}
75
+ </span>
76
+ </label>
77
+ <ModelSelect
78
+ id={`role-${role}`}
79
+ value={saved.roles?.[role] ?? null}
80
+ effective={effective}
81
+ inheritLabel={`Default (${defaults[role].split("/").slice(1).join("/")})`}
82
+ disabled={update.isPending}
83
+ onChange={(ref) => {
84
+ const roles = { ...saved.roles };
85
+ if (ref) roles[role] = ref;
86
+ else delete roles[role];
87
+ save({ roles });
88
+ }}
89
+ />
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ <SettingToggle
94
+ title="Tasks plan first"
95
+ detail="A chat task's first turn writes its plan to .agentos/plan.md on the planning model; the implementation model then follows it in the same conversation."
96
+ checked={effective.planStep}
97
+ onChange={(planStep) => save({ planStep })}
98
+ />
99
+ </section>
100
+
101
+ <section className="space-y-2">
102
+ <h3 className="text-sm font-medium">Providers</h3>
103
+ <p className="text-muted-foreground text-xs">
104
+ Claude stays the default; another provider runs only where a role or a
105
+ task picks it. Keys are never stored here: only where to read them
106
+ from.
107
+ </p>
108
+ <ul className="space-y-2">
109
+ {effective.providers.map((p) => (
110
+ <ProviderCard
111
+ key={`${p.id}-${JSON.stringify(p)}`}
112
+ provider={p}
113
+ saving={update.isPending}
114
+ onSave={saveProvider}
115
+ onRemove={
116
+ presetIds.has(p.id)
117
+ ? undefined
118
+ : () =>
119
+ save({
120
+ providers: (saved.providers ?? []).filter(
121
+ (x) => x.id !== p.id
122
+ ),
123
+ })
124
+ }
125
+ />
126
+ ))}
127
+ </ul>
128
+ <Button
129
+ variant="outline"
130
+ size="sm"
131
+ className="h-11 md:h-8"
132
+ onClick={addProvider}
133
+ disabled={update.isPending}
134
+ >
135
+ Add provider
136
+ </Button>
137
+ </section>
138
+
139
+ <section className="space-y-2">
140
+ <h3 className="text-sm font-medium">Prices</h3>
141
+ <p className="text-muted-foreground text-xs">
142
+ Dollars per million tokens, for Usage&apos;s estimates. Claude&apos;s
143
+ are API list prices: the plan itself is measured by its window.
144
+ </p>
145
+ <div className="overflow-x-auto">
146
+ <table className="w-full text-xs">
147
+ <thead className="text-muted-foreground text-left">
148
+ <tr>
149
+ <th className="py-1 pr-2 font-normal">Model</th>
150
+ {PRICE_FIELDS.map((f) => (
151
+ <th key={f.key} className="py-1 pr-2 font-normal">
152
+ {f.label}
153
+ </th>
154
+ ))}
155
+ </tr>
156
+ </thead>
157
+ <tbody>
158
+ {Object.entries(effective.prices).map(([ref, price]) => (
159
+ <tr key={ref} className="border-border border-t">
160
+ <td className="py-1 pr-2 font-mono">{ref}</td>
161
+ {PRICE_FIELDS.map((f) => (
162
+ <td key={f.key} className="py-1 pr-2">
163
+ <Input
164
+ type="number"
165
+ min={0}
166
+ step="0.001"
167
+ defaultValue={price[f.key]}
168
+ aria-label={`${ref} ${f.label}`}
169
+ className="h-11 w-20 tabular-nums md:h-8"
170
+ onBlur={(e) => {
171
+ const v = Number(e.target.value);
172
+ if (
173
+ !Number.isFinite(v) ||
174
+ v < 0 ||
175
+ v === price[f.key]
176
+ )
177
+ return;
178
+ save({
179
+ prices: {
180
+ ...saved.prices,
181
+ [ref]: { ...price, [f.key]: v },
182
+ },
183
+ });
184
+ }}
185
+ />
186
+ </td>
187
+ ))}
188
+ </tr>
189
+ ))}
190
+ </tbody>
191
+ </table>
192
+ </div>
193
+ </section>
194
+ {update.isError && (
195
+ <p className="text-destructive text-sm">{update.error.message}</p>
196
+ )}
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,271 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Input } from "@/components/ui/input";
6
+ import { Switch } from "@/components/ui/switch";
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import { useTestProvider } from "@/data/models";
15
+ import {
16
+ describeSecretRef,
17
+ type ModelProvider,
18
+ type SecretRef,
19
+ } from "@/lib/models/providers";
20
+ import { ANTHROPIC } from "@/lib/models/roles";
21
+ import { cn } from "@/lib/utils";
22
+
23
+ const KINDS: {
24
+ value: SecretRef["kind"];
25
+ label: string;
26
+ a: string;
27
+ b?: string;
28
+ }[] = [
29
+ { value: "tokenvault", label: "tokenvault", a: "Project", b: "Description" },
30
+ { value: "keychain", label: "macOS Keychain", a: "Service", b: "Account" },
31
+ { value: "env", label: "Environment variable", a: "Variable (…_API_KEY)" },
32
+ ];
33
+
34
+ function refFields(ref: SecretRef | undefined): [string, string] {
35
+ if (!ref) return ["", ""];
36
+ if (ref.kind === "tokenvault") return [ref.project, ref.desc ?? ""];
37
+ if (ref.kind === "keychain") return [ref.service, ref.account ?? ""];
38
+ return [ref.name, ""];
39
+ }
40
+
41
+ function toRef(
42
+ kind: SecretRef["kind"],
43
+ a: string,
44
+ b: string
45
+ ): SecretRef | undefined {
46
+ const x = a.trim();
47
+ const y = b.trim() || undefined;
48
+ if (!x) return undefined;
49
+ if (kind === "tokenvault") return { kind, project: x, desc: y };
50
+ if (kind === "keychain") return { kind, service: x, account: y };
51
+ return { kind, name: x };
52
+ }
53
+
54
+ const list = (s: string) =>
55
+ s
56
+ .split(/[\s,]+/)
57
+ .map((x) => x.trim())
58
+ .filter(Boolean);
59
+
60
+ // One provider: on or off, its endpoint, where its key lives (never the
61
+ // key), its models and Claude Code's tiers mapped onto them, and a test.
62
+ export function ProviderCard({
63
+ provider,
64
+ saving,
65
+ onSave,
66
+ onRemove,
67
+ }: {
68
+ provider: ModelProvider;
69
+ saving: boolean;
70
+ onSave: (p: ModelProvider) => void;
71
+ onRemove?: () => void;
72
+ }) {
73
+ const builtIn = provider.id === ANTHROPIC;
74
+ const [open, setOpen] = useState(false);
75
+ const [kind, setKind] = useState<SecretRef["kind"]>(
76
+ provider.auth?.kind ?? "tokenvault"
77
+ );
78
+ const [a0, b0] = refFields(provider.auth);
79
+ const [a, setA] = useState(a0);
80
+ const [b, setB] = useState(b0);
81
+ const [baseUrl, setBaseUrl] = useState(provider.baseUrl ?? "");
82
+ const [models, setModels] = useState(provider.models.join(", "));
83
+ const [tiers, setTiers] = useState(provider.tiers ?? {});
84
+ const test = useTestProvider();
85
+ const kindInfo = KINDS.find((k) => k.value === kind)!;
86
+ const ready = builtIn || (!!provider.baseUrl && !!provider.auth);
87
+
88
+ const draft = (): ModelProvider => ({
89
+ ...provider,
90
+ baseUrl: baseUrl.trim() || undefined,
91
+ auth: toRef(kind, a, b),
92
+ models: list(models),
93
+ tiers: Object.fromEntries(
94
+ Object.entries(tiers).filter(([, v]) => v)
95
+ ) as ModelProvider["tiers"],
96
+ });
97
+
98
+ return (
99
+ <li className="bg-muted/40 space-y-3 rounded-lg px-3 py-2.5">
100
+ <div className="flex items-center gap-3">
101
+ <div className="min-w-0 flex-1">
102
+ <p className="text-sm font-medium">{provider.label}</p>
103
+ <p className="text-muted-foreground truncate text-xs">
104
+ {builtIn
105
+ ? "Your Claude login; no key"
106
+ : `${provider.baseUrl ?? "no base URL"} · key: ${describeSecretRef(provider.auth)}`}
107
+ </p>
108
+ </div>
109
+ {!builtIn && (
110
+ <Switch
111
+ checked={provider.enabled}
112
+ disabled={saving || (!ready && !provider.enabled)}
113
+ title={ready ? undefined : "Set its base URL and key first"}
114
+ onCheckedChange={(enabled) => onSave({ ...provider, enabled })}
115
+ aria-label={`Use ${provider.label}`}
116
+ />
117
+ )}
118
+ </div>
119
+ <div className="flex flex-wrap gap-2">
120
+ <Button
121
+ variant="outline"
122
+ size="sm"
123
+ className="h-11 md:h-8"
124
+ disabled={test.isPending}
125
+ onClick={() => test.mutate({ provider: provider.id })}
126
+ >
127
+ {test.isPending ? "Testing…" : "Test provider"}
128
+ </Button>
129
+ {!builtIn && (
130
+ <Button
131
+ variant="ghost"
132
+ size="sm"
133
+ className="h-11 md:h-8"
134
+ onClick={() => setOpen((o) => !o)}
135
+ aria-expanded={open}
136
+ >
137
+ {open ? "Close" : "Edit"}
138
+ </Button>
139
+ )}
140
+ {onRemove && (
141
+ <Button
142
+ variant="ghost"
143
+ size="sm"
144
+ className="text-destructive h-11 md:h-8"
145
+ onClick={onRemove}
146
+ >
147
+ Remove
148
+ </Button>
149
+ )}
150
+ </div>
151
+ {test.data && (
152
+ <p
153
+ className={cn(
154
+ "text-xs",
155
+ test.data.ok
156
+ ? "text-emerald-700 dark:text-emerald-400"
157
+ : "text-destructive"
158
+ )}
159
+ >
160
+ {test.data.ok ? "Works: " : "Failed: "}
161
+ {test.data.message} ({Math.round(test.data.ms)} ms)
162
+ </p>
163
+ )}
164
+ {test.isError && (
165
+ <p className="text-destructive text-xs">{test.error.message}</p>
166
+ )}
167
+ {open && !builtIn && (
168
+ <form
169
+ className="space-y-2"
170
+ onSubmit={(e) => {
171
+ e.preventDefault();
172
+ onSave(draft());
173
+ }}
174
+ >
175
+ <Field label="Base URL">
176
+ <Input
177
+ value={baseUrl}
178
+ onChange={(e) => setBaseUrl(e.target.value)}
179
+ placeholder="https://…/anthropic"
180
+ className="h-11 md:h-9"
181
+ />
182
+ </Field>
183
+ <Field label="Key from">
184
+ <Select
185
+ value={kind}
186
+ onValueChange={(v) => setKind(v as SecretRef["kind"])}
187
+ >
188
+ <SelectTrigger className="h-11 md:h-9">
189
+ <SelectValue />
190
+ </SelectTrigger>
191
+ <SelectContent>
192
+ {KINDS.map((k) => (
193
+ <SelectItem key={k.value} value={k.value}>
194
+ {k.label}
195
+ </SelectItem>
196
+ ))}
197
+ </SelectContent>
198
+ </Select>
199
+ </Field>
200
+ <div className="grid grid-cols-2 gap-2">
201
+ <Field label={kindInfo.a}>
202
+ <Input
203
+ value={a}
204
+ onChange={(e) => setA(e.target.value)}
205
+ className="h-11 md:h-9"
206
+ autoComplete="off"
207
+ />
208
+ </Field>
209
+ {kindInfo.b && (
210
+ <Field label={`${kindInfo.b} (optional)`}>
211
+ <Input
212
+ value={b}
213
+ onChange={(e) => setB(e.target.value)}
214
+ className="h-11 md:h-9"
215
+ autoComplete="off"
216
+ />
217
+ </Field>
218
+ )}
219
+ </div>
220
+ <p className="text-muted-foreground text-xs">
221
+ Where the key lives, not the key: it&apos;s fetched when an agent
222
+ starts and goes only into that agent&apos;s environment.
223
+ </p>
224
+ <Field label="Models (comma-separated)">
225
+ <Input
226
+ value={models}
227
+ onChange={(e) => setModels(e.target.value)}
228
+ className="h-11 md:h-9"
229
+ />
230
+ </Field>
231
+ <div className="grid grid-cols-3 gap-2">
232
+ {(["opus", "sonnet", "haiku"] as const).map((t) => (
233
+ <Field key={t} label={`${t} tier`}>
234
+ <Input
235
+ value={tiers[t] ?? ""}
236
+ onChange={(e) =>
237
+ setTiers((x) => ({ ...x, [t]: e.target.value.trim() }))
238
+ }
239
+ className="h-11 md:h-9"
240
+ />
241
+ </Field>
242
+ ))}
243
+ </div>
244
+ <Button
245
+ type="submit"
246
+ size="sm"
247
+ className="h-11 md:h-8"
248
+ disabled={saving}
249
+ >
250
+ Save
251
+ </Button>
252
+ </form>
253
+ )}
254
+ </li>
255
+ );
256
+ }
257
+
258
+ function Field({
259
+ label,
260
+ children,
261
+ }: {
262
+ label: string;
263
+ children: React.ReactNode;
264
+ }) {
265
+ return (
266
+ <label className="block space-y-1">
267
+ <span className="text-muted-foreground text-xs">{label}</span>
268
+ {children}
269
+ </label>
270
+ );
271
+ }
@@ -18,6 +18,7 @@ import {
18
18
  Settings,
19
19
  SquareTerminal,
20
20
  Trash2,
21
+ Scale,
21
22
  } from "lucide-react";
22
23
  import {
23
24
  ADropdownMenu,
@@ -112,7 +113,10 @@ export function SidebarHeader(props: SidebarHeaderProps) {
112
113
  menuItem("Archived", () => archivedUiActions.open(), {
113
114
  icon: Archive,
114
115
  }),
115
- menuItem("Usage", usageUiActions.open, { icon: Gauge }),
116
+ menuItem("Usage", () => usageUiActions.open(), { icon: Gauge }),
117
+ menuItem("Model comparison", () => usageUiActions.open("compare"), {
118
+ icon: Scale,
119
+ }),
116
120
  separator(),
117
121
  menuItem("Machines", props.onManageHosts, { icon: Server }),
118
122
  menuItem("Settings", () => settingsUiActions.open(), {
@@ -31,6 +31,9 @@ vi.mock("@/components/Merge/GlobalMergeSection", () => ({
31
31
  vi.mock("@/components/Workspaces", () => ({
32
32
  WorkspaceSettings: stub("workspaces"),
33
33
  }));
34
+ vi.mock("@/components/Models/ModelsSection", () => ({
35
+ ModelsSection: stub("models"),
36
+ }));
34
37
  vi.mock("@/components/Devices", () => ({ DevicesPanel: stub("devices") }));
35
38
  vi.mock("@/components/Schedules", () => ({
36
39
  SchedulesPanel: stub("schedules"),
@@ -8,6 +8,7 @@ import {
8
8
  ChevronRight,
9
9
  ClipboardList,
10
10
  Clock,
11
+ Cpu,
11
12
  GitMerge,
12
13
  LayoutGrid,
13
14
  Smartphone,
@@ -25,6 +26,7 @@ import { Button } from "@/components/ui/button";
25
26
  import { AuditsPanel } from "@/components/Audits/AuditsPanel";
26
27
  import { DevicesPanel } from "@/components/Devices";
27
28
  import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
29
+ import { ModelsSection } from "@/components/Models/ModelsSection";
28
30
  import { SchedulesPanel } from "@/components/Schedules";
29
31
  import { WorkspaceSettings } from "@/components/Workspaces";
30
32
  import { useSettingsUrl } from "@/hooks/useSettingsUrl";
@@ -56,6 +58,11 @@ export const SECTION_INFO: Record<
56
58
  hint: "Pairing, passkeys, network",
57
59
  icon: Smartphone,
58
60
  },
61
+ models: {
62
+ label: "Models",
63
+ hint: "Which model each kind of work runs on, and providers",
64
+ icon: Cpu,
65
+ },
59
66
  notifications: {
60
67
  label: "Notifications",
61
68
  hint: "Sound, browser alerts, your phone",
@@ -85,6 +92,8 @@ function SectionBody({
85
92
  return <GlobalMergeSection />;
86
93
  case "workspaces":
87
94
  return <WorkspaceSettings />;
95
+ case "models":
96
+ return <ModelsSection />;
88
97
  case "devices":
89
98
  return <DevicesPanel />;
90
99
  case "notifications":
@@ -2,6 +2,7 @@ import {
2
2
  Bell,
3
3
  ClipboardList,
4
4
  Clock,
5
+ Cpu,
5
6
  GitMerge,
6
7
  LayoutGrid,
7
8
  Settings,
@@ -43,6 +44,24 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
43
44
  icon: LayoutGrid,
44
45
  run: () => settingsUiActions.open("workspaces"),
45
46
  },
47
+ {
48
+ id: "app.models",
49
+ title: "Model settings",
50
+ group,
51
+ keywords: [
52
+ "model",
53
+ "routing",
54
+ "provider",
55
+ "opus",
56
+ "sonnet",
57
+ "haiku",
58
+ "deepseek",
59
+ "glm",
60
+ "prices",
61
+ ],
62
+ icon: Cpu,
63
+ run: () => settingsUiActions.open("models"),
64
+ },
46
65
  {
47
66
  id: "app.devices",
48
67
  title: "Devices",
@@ -21,6 +21,7 @@ describe("⌘K entry points", () => {
21
21
  ["app.settings", null],
22
22
  ["app.merging", "merging"],
23
23
  ["app.workspace-settings", "workspaces"],
24
+ ["app.models", "models"],
24
25
  ["app.devices", "devices"],
25
26
  ["app.notifications", "notifications"],
26
27
  ["app.phone-notifications", "notifications"],