catherd-cli 0.2.1 → 1.1.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/README.md +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { type Profile, resolveProfile } from "../../../domain/profile.ts";
|
|
3
|
+
import { errorMessage, isCatherdError } from "../../../domain/errors.ts";
|
|
4
|
+
import { hereWord } from "../effects.ts";
|
|
5
|
+
import { useApp, useDialogHandler } from "../providers/app.tsx";
|
|
6
|
+
import { type Data, useData, useLoad } from "../providers/data.tsx";
|
|
7
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
8
|
+
import { errorToast } from "../providers/toast.tsx";
|
|
9
|
+
import { useUi } from "../providers/theme.tsx";
|
|
10
|
+
import {
|
|
11
|
+
failoverOptions,
|
|
12
|
+
numberPatch,
|
|
13
|
+
numberValue,
|
|
14
|
+
parseNumber,
|
|
15
|
+
patchFor,
|
|
16
|
+
startOptions,
|
|
17
|
+
treatLikeOptions,
|
|
18
|
+
} from "../profile-edits.ts";
|
|
19
|
+
import { buildRows, filterRows, firstMatch, type Row, type RowAction, withStaged } from "../profile-tree.ts";
|
|
20
|
+
import { currentDraft, dirtyCount } from "../state.ts";
|
|
21
|
+
import { wrap } from "../text.ts";
|
|
22
|
+
import { glyph, STATE_TOKEN } from "../theme.ts";
|
|
23
|
+
import { Line, type Part } from "../widgets/line.tsx";
|
|
24
|
+
import { List, type ListItem, useSelected } from "../widgets/list.tsx";
|
|
25
|
+
import { openActivate, openNewProfile, openRevert, openSave, showProfile } from "./profile-actions.ts";
|
|
26
|
+
|
|
27
|
+
/** What a row does, for the line under the tree when it has no issue. */
|
|
28
|
+
const ABOUT: Partial<Record<RowAction["type"], string>> = {
|
|
29
|
+
role: "space turns the role on or off; enter opens its access, default rung and models",
|
|
30
|
+
access:
|
|
31
|
+
"enter cycles read-only → workspace-write → full; enforced: the backend stops a write, advisory: it is only asked",
|
|
32
|
+
start: "enter picks where a lane starts without a kind and difficulty",
|
|
33
|
+
model: "enter opens its efforts; space never changes a rung",
|
|
34
|
+
rung: "space ticks this rung onto the role's ladder; routing orders the ladder by cost",
|
|
35
|
+
objective: "enter switches between cost and speed",
|
|
36
|
+
jev: "enter turns Jev on (auto: when a key exists) or off",
|
|
37
|
+
isolated: "space runs the backend with catherd's own config instead of yours",
|
|
38
|
+
number: "enter edits the value",
|
|
39
|
+
failover: "enter picks the stand-in on a usage limit; it runs on a fresh thread",
|
|
40
|
+
notify: "space turns this notification on or off",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The profiles poll failed after a good read (its error is newer than the value it keeps): one error line
|
|
45
|
+
* and, when the failure says, its fix; none once a read succeeds again.
|
|
46
|
+
*/
|
|
47
|
+
export function staleLines(data: Data, plain: boolean, o: { fix?: boolean } = {}): Part[] {
|
|
48
|
+
const p = data.profiles;
|
|
49
|
+
if (p.error === null || p.value === null) return [];
|
|
50
|
+
const said: Part[] = [
|
|
51
|
+
{
|
|
52
|
+
text: ` ${glyph("fail", plain)} could not read the profiles again: ${p.error} · showing the last good read`,
|
|
53
|
+
tone: "error",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
if (p.fix && o.fix !== false) said.push({ text: ` fix: ${p.fix}`, tone: "muted" });
|
|
57
|
+
return said;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Only ticking an unscored effort asks for a treat-like; unticking a rung always just unticks it. */
|
|
61
|
+
const needsTreatLike = (p: Profile, a: RowAction): boolean =>
|
|
62
|
+
a.type === "rung" && !a.scored && !p.roles[a.role].rungs.includes(a.rung);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Spec §9.1 tab 2: the profile's line, then one tree (roles → access + backend → model → efforts, then
|
|
66
|
+
* routing, harness, budget, failover, timeouts, notify), then a line about the selected row. Every edit
|
|
67
|
+
* is staged in the draft; nothing is written before ctrl+s.
|
|
68
|
+
*/
|
|
69
|
+
export function ProfilesView(props: { width: number; height: number }) {
|
|
70
|
+
const app = useApp();
|
|
71
|
+
const data = useData();
|
|
72
|
+
const ui = useUi();
|
|
73
|
+
const { selected, select: setSelected, current: selectedNow } = useSelected();
|
|
74
|
+
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(["role:worker"]));
|
|
75
|
+
const [filter, setFilter] = useState<string | null>(null);
|
|
76
|
+
const draft = currentDraft(app.state);
|
|
77
|
+
const here = data.profiles.value?.here ?? null;
|
|
78
|
+
// the profile the tab opens on; a read of it that fails is shown in the tab (below), and read again
|
|
79
|
+
// on r and after every good poll of the profiles
|
|
80
|
+
const [openFailed, setOpenFailed] = useState<{ name: string; error: string; fix: string | null } | null>(
|
|
81
|
+
null,
|
|
82
|
+
);
|
|
83
|
+
const [openTries, setOpenTries] = useState(0);
|
|
84
|
+
const polledAt = data.profiles.at;
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (app.state.profile || !here) return;
|
|
87
|
+
const e = showProfile(app, here, { quiet: true });
|
|
88
|
+
setOpenFailed(
|
|
89
|
+
e === null
|
|
90
|
+
? null
|
|
91
|
+
: {
|
|
92
|
+
name: here,
|
|
93
|
+
error: errorMessage(e),
|
|
94
|
+
fix: isCatherdError(e) && e.fix ? e.fix : null,
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
}, [app.state.profile, here, app, polledAt, openTries]);
|
|
98
|
+
const profile = useMemo(() => (draft ? resolveProfile(draft.doc, draft.name) : null), [draft]);
|
|
99
|
+
const billingKey = JSON.stringify(profile?.billing ?? {});
|
|
100
|
+
// read again after every save too, by the identity of the base it reads back (the same bytes when only
|
|
101
|
+
// treat-likes changed): a save writes the staged treat-likes to the catalog override and clears them
|
|
102
|
+
// from the draft, so the catalog read before it would lack them
|
|
103
|
+
const reads = useRef(0);
|
|
104
|
+
const savedKey = useMemo(() => ++reads.current, [draft?.base]);
|
|
105
|
+
const loaded = useLoad(
|
|
106
|
+
() => (profile ? app.effects.catalog(profile.billing) : null),
|
|
107
|
+
`${billingKey}|${savedKey}`,
|
|
108
|
+
);
|
|
109
|
+
const catalog = useMemo(
|
|
110
|
+
() => (loaded.value && draft ? withStaged(loaded.value.catalog, draft.treatLikes) : null),
|
|
111
|
+
[loaded.value, draft],
|
|
112
|
+
);
|
|
113
|
+
const validation = useMemo(
|
|
114
|
+
() => (profile && catalog ? app.effects.validate(profile, catalog) : { errors: [], warnings: [] }),
|
|
115
|
+
[profile, catalog, app.effects],
|
|
116
|
+
);
|
|
117
|
+
const rows: Row[] = useMemo(() => {
|
|
118
|
+
if (!profile || !catalog || !loaded.value || !draft) return [];
|
|
119
|
+
const all = buildRows({
|
|
120
|
+
profile,
|
|
121
|
+
models: loaded.value.models,
|
|
122
|
+
catalog,
|
|
123
|
+
staged: draft.treatLikes,
|
|
124
|
+
expanded,
|
|
125
|
+
harnesses: app.effects.harnesses,
|
|
126
|
+
enforcement: app.effects.enforcement,
|
|
127
|
+
validation,
|
|
128
|
+
expandAll: Boolean(filter),
|
|
129
|
+
});
|
|
130
|
+
return filter ? filterRows(all, filter) : all;
|
|
131
|
+
}, [profile, catalog, loaded.value, draft, expanded, filter, validation, app.effects]);
|
|
132
|
+
// a read that failed is shown with its fix and a retry, not as one still pending: the profiles (no
|
|
133
|
+
// draft can open without them; the 2 s poll also reads them again) or, once a profile shows, the catalog
|
|
134
|
+
const failure =
|
|
135
|
+
!draft && data.profiles.error !== null
|
|
136
|
+
? {
|
|
137
|
+
what: "the profiles",
|
|
138
|
+
error: data.profiles.error,
|
|
139
|
+
fix: data.profiles.fix,
|
|
140
|
+
retry: data.profiles.refresh,
|
|
141
|
+
polled: true,
|
|
142
|
+
}
|
|
143
|
+
: !draft && openFailed
|
|
144
|
+
? {
|
|
145
|
+
what: `the profile ${openFailed.name}`,
|
|
146
|
+
error: openFailed.error,
|
|
147
|
+
fix: openFailed.fix,
|
|
148
|
+
retry: () => setOpenTries((n) => n + 1),
|
|
149
|
+
polled: true,
|
|
150
|
+
}
|
|
151
|
+
: profile && loaded.error !== null && !loaded.value
|
|
152
|
+
? {
|
|
153
|
+
what: "the catalog",
|
|
154
|
+
error: loaded.error,
|
|
155
|
+
fix: loaded.fix,
|
|
156
|
+
retry: loaded.refresh,
|
|
157
|
+
polled: false,
|
|
158
|
+
}
|
|
159
|
+
: null;
|
|
160
|
+
// a later read that failed over a kept good one: said under the header until a read succeeds, like the
|
|
161
|
+
// Runs tab over its kept rows; the draft stays open and editable
|
|
162
|
+
const stale = draft ? staleLines(data, ui.plain) : [];
|
|
163
|
+
const row = rows.find((r) => r.key === selected) ?? null;
|
|
164
|
+
/** the row the cursor is on now, which a key earlier in the same tick may have moved */
|
|
165
|
+
const rowNow = () => rows.find((r) => r.key === selectedNow()) ?? null;
|
|
166
|
+
// typing a filter puts the cursor on the first row that matches it; an edit under a kept filter
|
|
167
|
+
// (new rows, same text) leaves the cursor where it is
|
|
168
|
+
const placedFor = useRef<string | null>(null);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!filter) {
|
|
171
|
+
placedFor.current = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (filter === placedFor.current) return;
|
|
175
|
+
const m = firstMatch(rows, filter);
|
|
176
|
+
if (!m) return;
|
|
177
|
+
placedFor.current = filter;
|
|
178
|
+
setSelected(m.key);
|
|
179
|
+
}, [filter, rows]);
|
|
180
|
+
/** the draft as it is now: two keys in one tick must not both edit the profile this render drew */
|
|
181
|
+
const current = () => {
|
|
182
|
+
const d = currentDraft(app.getState());
|
|
183
|
+
return d ? resolveProfile(d.doc, d.name) : null;
|
|
184
|
+
};
|
|
185
|
+
const edit = (patch: ReturnType<typeof patchFor>) => patch && app.dispatch({ type: "edit", patch });
|
|
186
|
+
const toggleOpen = (r: Row, open?: boolean) =>
|
|
187
|
+
setExpanded((e) => {
|
|
188
|
+
const next = new Set(e);
|
|
189
|
+
if (open ?? !next.has(r.key)) next.add(r.key);
|
|
190
|
+
else next.delete(r.key);
|
|
191
|
+
return next;
|
|
192
|
+
});
|
|
193
|
+
const pick = (r: Row) => {
|
|
194
|
+
if (!profile || !loaded.value || !catalog) return;
|
|
195
|
+
const a = r.action;
|
|
196
|
+
if (a.type === "start")
|
|
197
|
+
app.dispatch({
|
|
198
|
+
type: "open",
|
|
199
|
+
dialog: {
|
|
200
|
+
kind: "select",
|
|
201
|
+
purpose: { type: "start", role: a.role },
|
|
202
|
+
title: `Default rung for ${a.role}`,
|
|
203
|
+
options: startOptions(profile, a.role),
|
|
204
|
+
empty: "no rungs",
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
else if (a.type === "failover")
|
|
208
|
+
app.dispatch({
|
|
209
|
+
type: "open",
|
|
210
|
+
dialog: {
|
|
211
|
+
kind: "select",
|
|
212
|
+
purpose: { type: "failover", rung: a.rung },
|
|
213
|
+
title: `Stand-in for ${a.rung}`,
|
|
214
|
+
options: failoverOptions(profile, loaded.value.models, catalog, a.rung),
|
|
215
|
+
empty: "no scored rung on another quota",
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
else if (a.type === "rung" && !a.scored)
|
|
219
|
+
app.dispatch({
|
|
220
|
+
type: "open",
|
|
221
|
+
dialog: {
|
|
222
|
+
kind: "select",
|
|
223
|
+
purpose: { type: "treatLike", rung: a.rung, role: a.role },
|
|
224
|
+
title: `Treat ${a.rung} like…`,
|
|
225
|
+
options: treatLikeOptions(catalog),
|
|
226
|
+
empty: "no scored rung",
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
else if (a.type === "number") {
|
|
230
|
+
const v = numberValue(profile, a.path);
|
|
231
|
+
app.dispatch({
|
|
232
|
+
type: "open",
|
|
233
|
+
dialog: {
|
|
234
|
+
kind: "prompt",
|
|
235
|
+
purpose: { type: "number", path: a.path },
|
|
236
|
+
title: `Set ${a.path}`,
|
|
237
|
+
label: a.path.startsWith("budget.") ? "a number above 0; empty for no cap" : "minutes, above 0",
|
|
238
|
+
value: v === undefined ? "" : String(v),
|
|
239
|
+
error: null,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
const primary = (r: Row | null) => {
|
|
245
|
+
const p = current();
|
|
246
|
+
if (!r || !p) return;
|
|
247
|
+
const a = r.action;
|
|
248
|
+
if (a.type === "role" || a.type === "model") return toggleOpen(r);
|
|
249
|
+
if (a.type === "start" || a.type === "failover" || a.type === "number" || needsTreatLike(p, a))
|
|
250
|
+
return pick(r);
|
|
251
|
+
edit(patchFor(p, a));
|
|
252
|
+
};
|
|
253
|
+
const toggle = (r: Row | null) => {
|
|
254
|
+
const p = current();
|
|
255
|
+
if (!r || !p) return;
|
|
256
|
+
const a = r.action;
|
|
257
|
+
if (a.type === "model") return toggleOpen(r);
|
|
258
|
+
if (needsTreatLike(p, a)) return pick(r);
|
|
259
|
+
if (a.type === "role" || a.type === "rung" || a.type === "isolated" || a.type === "notify")
|
|
260
|
+
edit(patchFor(p, a));
|
|
261
|
+
};
|
|
262
|
+
useCommandLayer("tab.profiles", {
|
|
263
|
+
"profile.save": () => openSave(app),
|
|
264
|
+
"profile.activate": () => openActivate(app, data),
|
|
265
|
+
"profile.copy": () => draft && openNewProfile(app, draft.name),
|
|
266
|
+
"profile.revert": () => openRevert(app),
|
|
267
|
+
"catalog.refresh": () => {
|
|
268
|
+
// after a failed read, r reads it again (and only that)
|
|
269
|
+
if (failure) return failure.retry();
|
|
270
|
+
void app.effects.refreshCatalog().then(
|
|
271
|
+
() => {
|
|
272
|
+
loaded.refresh();
|
|
273
|
+
app.toast({ variant: "success", message: "Catalog refreshed" });
|
|
274
|
+
},
|
|
275
|
+
(e: unknown) => app.toast(errorToast(e)),
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
"edit.undo": () => app.dispatch({ type: "undo" }),
|
|
279
|
+
"edit.redo": () => app.dispatch({ type: "redo" }),
|
|
280
|
+
});
|
|
281
|
+
useCommandLayer("row.profiles", {
|
|
282
|
+
"tree.toggle": () => toggle(rowNow()),
|
|
283
|
+
"tree.open": () => primary(rowNow()),
|
|
284
|
+
"tree.expand": () => {
|
|
285
|
+
const row = rowNow();
|
|
286
|
+
if (row?.expandable) toggleOpen(row, true);
|
|
287
|
+
},
|
|
288
|
+
"tree.collapse": () => {
|
|
289
|
+
const row = rowNow();
|
|
290
|
+
if (!row) return;
|
|
291
|
+
if (row.expandable && row.expanded) return toggleOpen(row, false);
|
|
292
|
+
let p = rows.find((r) => r.key === row.parent);
|
|
293
|
+
while (p && !p.selectable) p = rows.find((r) => r.key === p?.parent);
|
|
294
|
+
if (p) setSelected(p.key);
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
useDialogHandler("start", (p, value) => {
|
|
298
|
+
app.dispatch({ type: "close" });
|
|
299
|
+
if (p.type === "start") edit({ roles: { [p.role]: { defaultRung: value || null } } });
|
|
300
|
+
});
|
|
301
|
+
useDialogHandler("failover", (p, value) => {
|
|
302
|
+
app.dispatch({ type: "close" });
|
|
303
|
+
if (p.type === "failover") edit({ failover: { [p.rung]: value || null } });
|
|
304
|
+
});
|
|
305
|
+
useDialogHandler("treatLike", (p, value) => {
|
|
306
|
+
app.dispatch({ type: "close" });
|
|
307
|
+
const prof = current();
|
|
308
|
+
if (p.type !== "treatLike" || !prof) return;
|
|
309
|
+
// one undo step: the treat-like and the tick it brings
|
|
310
|
+
const tick =
|
|
311
|
+
p.role && !prof.roles[p.role].rungs.includes(p.rung)
|
|
312
|
+
? patchFor(prof, { type: "rung", role: p.role, rung: p.rung, scored: true })
|
|
313
|
+
: null;
|
|
314
|
+
app.dispatch({ type: "treatLike", rung: p.rung, like: value, ...(tick ? { patch: tick } : {}) });
|
|
315
|
+
});
|
|
316
|
+
useDialogHandler("number", (p, value) => {
|
|
317
|
+
if (p.type !== "number") return;
|
|
318
|
+
const r = parseNumber(p.path, value);
|
|
319
|
+
if ("error" in r) return app.dispatch({ type: "invalid", error: r.error });
|
|
320
|
+
app.dispatch({ type: "close" });
|
|
321
|
+
edit(numberPatch(p.path, r.value));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const unsaved = draft ? dirtyCount(draft) : 0;
|
|
325
|
+
const top: Part[] = draft
|
|
326
|
+
? [
|
|
327
|
+
{ text: " PROFILE ", bold: true },
|
|
328
|
+
{ text: draft.name, bold: true },
|
|
329
|
+
{
|
|
330
|
+
text: draft.name === here ? ` (${hereWord(data.profiles.value ?? { repo: null })})` : "",
|
|
331
|
+
tone: "muted",
|
|
332
|
+
},
|
|
333
|
+
{ text: unsaved ? ` · ${unsaved} unsaved` : "", tone: "warning" },
|
|
334
|
+
{ text: ` ${glyph("dot", ui.plain)} ctrl+x l switch · ctrl+x n new`, tone: "muted" },
|
|
335
|
+
]
|
|
336
|
+
: failure
|
|
337
|
+
? [{ text: " PROFILE", bold: true }]
|
|
338
|
+
: [{ text: " reading the profile…", tone: "muted" }];
|
|
339
|
+
const about = row?.issue
|
|
340
|
+
? [
|
|
341
|
+
{
|
|
342
|
+
text: ` ${glyph(row.issue.level === "error" ? "fail" : "warn", ui.plain)} ${row.issue.message}`,
|
|
343
|
+
tone: STATE_TOKEN[row.issue.level === "error" ? "fail" : "warn"],
|
|
344
|
+
},
|
|
345
|
+
...(row.issue.fix ? [{ text: ` fix: ${row.issue.fix}`, tone: "muted" as const }] : []),
|
|
346
|
+
]
|
|
347
|
+
: [{ text: ` ${(row && ABOUT[row.action.type]) ?? ""}`, tone: "muted" as const }];
|
|
348
|
+
const aboutLines = about
|
|
349
|
+
.flatMap((p) => wrap(p.text, props.width).map((text) => ({ ...p, text })))
|
|
350
|
+
.slice(0, 2);
|
|
351
|
+
// the value column of a group of rows starts after its longest label, so long model names line up
|
|
352
|
+
const widest = new Map<string | null, number>();
|
|
353
|
+
for (const r of rows)
|
|
354
|
+
if (r.selectable) widest.set(r.parent, Math.max(widest.get(r.parent) ?? 0, Bun.stringWidth(r.label)));
|
|
355
|
+
const items: ListItem[] = rows.map((r) => ({
|
|
356
|
+
key: r.key,
|
|
357
|
+
selectable: r.selectable,
|
|
358
|
+
render: (sel, w) => {
|
|
359
|
+
const indent = " ".repeat(r.depth);
|
|
360
|
+
const marker = r.expandable ? `${glyph(r.expanded ? "open" : "shut", ui.plain)} ` : " ";
|
|
361
|
+
const check = r.check === null ? "" : `${glyph(r.check ? "on" : "off", ui.plain)} `;
|
|
362
|
+
const issue = r.issue ? ` ${glyph(r.issue.level === "error" ? "fail" : "warn", ui.plain)}` : "";
|
|
363
|
+
if (!r.selectable)
|
|
364
|
+
return (
|
|
365
|
+
<Line
|
|
366
|
+
width={w}
|
|
367
|
+
parts={[
|
|
368
|
+
{
|
|
369
|
+
text: ` ${indent}${r.label}`,
|
|
370
|
+
bold: r.depth === 0,
|
|
371
|
+
tone: r.depth === 0 ? undefined : "muted",
|
|
372
|
+
},
|
|
373
|
+
{ text: r.value ? ` ${r.value}` : "", tone: "muted" },
|
|
374
|
+
]}
|
|
375
|
+
/>
|
|
376
|
+
);
|
|
377
|
+
const labelWidth = Math.max(14, 30 - r.depth * 2, (widest.get(r.parent) ?? 0) + 2);
|
|
378
|
+
return (
|
|
379
|
+
<Line
|
|
380
|
+
width={w}
|
|
381
|
+
selected={sel}
|
|
382
|
+
dim={r.dim}
|
|
383
|
+
parts={[
|
|
384
|
+
{ text: ` ${indent}${marker}${check}` },
|
|
385
|
+
{
|
|
386
|
+
text: r.label.length + 2 > labelWidth ? `${r.label} ` : r.label.padEnd(labelWidth),
|
|
387
|
+
bold: r.depth === 1,
|
|
388
|
+
},
|
|
389
|
+
{ text: r.value, tone: "muted" },
|
|
390
|
+
{ text: issue, tone: r.issue?.level === "error" ? "error" : "warning" },
|
|
391
|
+
]}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
}));
|
|
396
|
+
return (
|
|
397
|
+
<box flexDirection="column" width={props.width} height={props.height}>
|
|
398
|
+
<Line width={props.width} parts={top} />
|
|
399
|
+
{stale.map((p, i) => (
|
|
400
|
+
<Line key={`stale${i}`} width={props.width} parts={[p]} />
|
|
401
|
+
))}
|
|
402
|
+
{failure ? (
|
|
403
|
+
<box flexDirection="column" width={props.width} height={props.height - 3}>
|
|
404
|
+
{[
|
|
405
|
+
...wrap(
|
|
406
|
+
` ${glyph("fail", ui.plain)} could not read ${failure.what}: ${failure.error}`,
|
|
407
|
+
props.width,
|
|
408
|
+
).map((text): Part => ({ text, tone: "error" })),
|
|
409
|
+
...(failure.fix ? wrap(` fix: ${failure.fix}`, props.width) : []).map((text): Part => ({
|
|
410
|
+
text,
|
|
411
|
+
tone: "muted",
|
|
412
|
+
})),
|
|
413
|
+
{
|
|
414
|
+
text: ` r retry${failure.polled ? " · also read again every 2 s" : ""}`,
|
|
415
|
+
tone: "muted",
|
|
416
|
+
} as Part,
|
|
417
|
+
]
|
|
418
|
+
.slice(0, Math.max(0, props.height - 3))
|
|
419
|
+
.map((p, i) => (
|
|
420
|
+
<Line key={i} width={props.width} parts={[p]} />
|
|
421
|
+
))}
|
|
422
|
+
</box>
|
|
423
|
+
) : (
|
|
424
|
+
<List
|
|
425
|
+
items={items}
|
|
426
|
+
selected={selected}
|
|
427
|
+
onSelect={setSelected}
|
|
428
|
+
width={props.width}
|
|
429
|
+
height={props.height - 3 - stale.length}
|
|
430
|
+
filter={filter}
|
|
431
|
+
onFilter={setFilter}
|
|
432
|
+
empty="reading the catalog…"
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
{[0, 1].map((i) => (
|
|
436
|
+
<Line key={i} width={props.width} parts={aboutLines[i] ? [aboutLines[i]] : []} />
|
|
437
|
+
))}
|
|
438
|
+
</box>
|
|
439
|
+
);
|
|
440
|
+
}
|