catherd-cli 0.2.0 → 1.0.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 +114 -30
- 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 +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -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 +227 -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 +316 -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 +58 -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 +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -25
- 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 +33 -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 +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -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 +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -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 +133 -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 +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -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 +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -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 +196 -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/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -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/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -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 +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -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 -69
- 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
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { Check } from "../../../services/doctor-checks.ts";
|
|
2
|
+
import { useApp, useNow } from "../providers/app.tsx";
|
|
3
|
+
import { useData } from "../providers/data.tsx";
|
|
4
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
5
|
+
import { useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { ago, plural, wrap } from "../text.ts";
|
|
7
|
+
import { glyph, STATE_TOKEN } from "../theme.ts";
|
|
8
|
+
import { Line, type Part } from "../widgets/line.tsx";
|
|
9
|
+
import { List, type ListItem, useSelected } from "../widgets/list.tsx";
|
|
10
|
+
import { hereWord, type RunRow } from "../effects.ts";
|
|
11
|
+
import { showProfile } from "./profile-actions.ts";
|
|
12
|
+
import { staleLines } from "./profiles.tsx";
|
|
13
|
+
|
|
14
|
+
/** `✓ ready` / `! not logged in` / `✗ missing`: state is always glyph and word (spec §9.3). */
|
|
15
|
+
function stateParts(state: Check["state"], word: string, plain: boolean): Part[] {
|
|
16
|
+
return [{ text: `${glyph(state, plain)} ${word}`.padEnd(16), tone: STATE_TOKEN[state] }];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A run in one line: its state word first, then title, repo and progress (research C4). */
|
|
20
|
+
export function runParts(r: RunRow, now: number, plain: boolean): Part[] {
|
|
21
|
+
const bits = [plural(r.roleRuns, "role run"), `${r.landed} landed`];
|
|
22
|
+
if (r.budget !== null) bits.push(`${Math.round(r.budget * 100)}% budget`);
|
|
23
|
+
return [
|
|
24
|
+
r.live
|
|
25
|
+
? { text: `${glyph("live", plain)} live `, tone: "info" }
|
|
26
|
+
: { text: `${glyph("dot", plain)} idle `, tone: "muted" },
|
|
27
|
+
{ text: `${r.title} `, bold: true },
|
|
28
|
+
{ text: `${r.repo} `, tone: "muted" },
|
|
29
|
+
{ text: `${r.live ? `${r.live} live · ` : ""}${bits.join(" · ")} `, tone: "muted" },
|
|
30
|
+
{ text: ago(now - Date.parse(r.createdAt)), tone: "muted" },
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spec §9.1 tab 1: each check as `glyph word — detail` with its full fix command (y copies it), the
|
|
36
|
+
* active profile, and the last five runs. enter opens the profile or a run.
|
|
37
|
+
*/
|
|
38
|
+
export function StatusView(props: { width: number; height: number }) {
|
|
39
|
+
const app = useApp();
|
|
40
|
+
const data = useData();
|
|
41
|
+
const ui = useUi();
|
|
42
|
+
const now = useNow(1_000);
|
|
43
|
+
const { selected, select: setSelected, current: selectedNow } = useSelected();
|
|
44
|
+
const checks = data.report?.checks ?? [];
|
|
45
|
+
const profiles = data.profiles.value ?? { names: [], active: "…", here: "…", repo: null };
|
|
46
|
+
const runs = (data.runs.value?.rows ?? []).slice(0, 5);
|
|
47
|
+
const items: ListItem[] = [];
|
|
48
|
+
const heading = (key: string, text: string, right = "") =>
|
|
49
|
+
items.push({
|
|
50
|
+
key,
|
|
51
|
+
selectable: false,
|
|
52
|
+
render: (_s, w) => (
|
|
53
|
+
<Line
|
|
54
|
+
width={w}
|
|
55
|
+
parts={[
|
|
56
|
+
{ text, bold: true },
|
|
57
|
+
{ text: " ".repeat(Math.max(1, w - text.length - right.length - 1)) },
|
|
58
|
+
{ text: right, tone: "muted" },
|
|
59
|
+
]}
|
|
60
|
+
/>
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
const checked = data.checking
|
|
64
|
+
? "checking…"
|
|
65
|
+
: data.checkedAt !== null
|
|
66
|
+
? `checked ${ago(now - data.checkedAt)}`
|
|
67
|
+
: "";
|
|
68
|
+
heading("h:setup", " SETUP", checked);
|
|
69
|
+
if (data.checkError)
|
|
70
|
+
items.push({
|
|
71
|
+
key: "error",
|
|
72
|
+
selectable: false,
|
|
73
|
+
render: (_s, w) => (
|
|
74
|
+
<Line
|
|
75
|
+
width={w}
|
|
76
|
+
parts={[{ text: ` ${glyph("fail", ui.plain)} ${data.checkError}`, tone: "error" }]}
|
|
77
|
+
/>
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
for (const c of checks) {
|
|
81
|
+
// the detail wraps under the label instead of being cut: it is often a path (spec §9.3)
|
|
82
|
+
const said = wrap(`${c.label}${c.detail ? ` — ${c.detail}` : ""}`, Math.max(20, props.width - 19));
|
|
83
|
+
const first = said[0] ?? "";
|
|
84
|
+
const label = first.startsWith(c.label) ? c.label : "";
|
|
85
|
+
items.push({
|
|
86
|
+
key: `check:${c.id}`,
|
|
87
|
+
selectable: true,
|
|
88
|
+
render: (sel, w) => (
|
|
89
|
+
<Line
|
|
90
|
+
width={w}
|
|
91
|
+
selected={sel}
|
|
92
|
+
parts={[
|
|
93
|
+
{ text: " " },
|
|
94
|
+
...stateParts(c.state, c.word, ui.plain),
|
|
95
|
+
{ text: label, bold: true },
|
|
96
|
+
{ text: first.slice(label.length), tone: "muted" },
|
|
97
|
+
]}
|
|
98
|
+
/>
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
said.slice(1).forEach((line, i) =>
|
|
102
|
+
items.push({
|
|
103
|
+
key: `detail:${c.id}:${i}`,
|
|
104
|
+
selectable: false,
|
|
105
|
+
render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(19)}${line}`, tone: "muted" }]} />,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
if (c.fix)
|
|
109
|
+
wrap(`fix: ${c.fix}`, Math.max(20, props.width - 22)).forEach((line, i) =>
|
|
110
|
+
items.push({
|
|
111
|
+
key: `fix:${c.id}:${i}`,
|
|
112
|
+
selectable: false,
|
|
113
|
+
render: (_s, w) => <Line width={w} parts={[{ text: `${" ".repeat(20)}${line}`, tone: "muted" }]} />,
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
heading("h:profile", " PROFILE");
|
|
118
|
+
// until the first check is in, nothing is selectable, so the cursor starts on the first check
|
|
119
|
+
const ready = data.report !== null || data.checkError !== null;
|
|
120
|
+
items.push({
|
|
121
|
+
key: "profile",
|
|
122
|
+
selectable: ready,
|
|
123
|
+
render: (sel, w) => (
|
|
124
|
+
<Line
|
|
125
|
+
width={w}
|
|
126
|
+
selected={sel}
|
|
127
|
+
parts={[
|
|
128
|
+
{ text: ` ${profiles.here}`, bold: true },
|
|
129
|
+
{
|
|
130
|
+
text: ` ${hereWord(profiles)} · ${plural(profiles.names.length, "profile")}`,
|
|
131
|
+
tone: "muted",
|
|
132
|
+
},
|
|
133
|
+
]}
|
|
134
|
+
/>
|
|
135
|
+
),
|
|
136
|
+
});
|
|
137
|
+
// a later read that failed: the row above is the last good read, not the profile now
|
|
138
|
+
for (const [i, p] of staleLines(data, ui.plain, { fix: false }).entries())
|
|
139
|
+
items.push({
|
|
140
|
+
key: `profile:stale:${i}`,
|
|
141
|
+
selectable: false,
|
|
142
|
+
render: (_s, w) => <Line width={w} parts={[{ ...p, text: ` ${p.text}` }]} />,
|
|
143
|
+
});
|
|
144
|
+
heading("h:runs", " RECENT RUNS");
|
|
145
|
+
if (runs.length === 0)
|
|
146
|
+
items.push({
|
|
147
|
+
key: "runs:none",
|
|
148
|
+
selectable: false,
|
|
149
|
+
render: (_s, w) => <Line width={w} parts={[{ text: " no runs yet", tone: "muted" }]} />,
|
|
150
|
+
});
|
|
151
|
+
for (const r of runs)
|
|
152
|
+
items.push({
|
|
153
|
+
key: `run:${r.id}`,
|
|
154
|
+
selectable: ready,
|
|
155
|
+
render: (sel, w) => (
|
|
156
|
+
<Line width={w} selected={sel} parts={[{ text: " " }, ...runParts(r, now, ui.plain)]} />
|
|
157
|
+
),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const checkAt = (key: string | null) => checks.find((c) => key === `check:${c.id}`);
|
|
161
|
+
useCommandLayer("tab.status", {
|
|
162
|
+
"status.recheck": () => data.recheck(),
|
|
163
|
+
"status.copy": () => {
|
|
164
|
+
const selectedCheck = checkAt(selectedNow());
|
|
165
|
+
if (!selectedCheck?.fix) return app.toast({ variant: "info", message: "This row has no fix command" });
|
|
166
|
+
const ok = app.copy(selectedCheck.fix);
|
|
167
|
+
app.toast(
|
|
168
|
+
ok
|
|
169
|
+
? { variant: "success", message: "Copied the fix command" }
|
|
170
|
+
: { variant: "warning", message: "This terminal cannot copy; the command is shown in full" },
|
|
171
|
+
);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
useCommandLayer("row.status", {
|
|
175
|
+
"status.open": () => {
|
|
176
|
+
const selected = selectedNow();
|
|
177
|
+
const selectedCheck = checkAt(selected);
|
|
178
|
+
// the PROFILE row is what this directory runs on; doctor's `profile` row is the global active one,
|
|
179
|
+
// and its `profile:<name>` rows are the repo-bound ones
|
|
180
|
+
const name =
|
|
181
|
+
selected === "profile"
|
|
182
|
+
? profiles.here
|
|
183
|
+
: selectedCheck?.id === "profile"
|
|
184
|
+
? profiles.active
|
|
185
|
+
: selectedCheck?.id.startsWith("profile:")
|
|
186
|
+
? selectedCheck.id.slice("profile:".length)
|
|
187
|
+
: null;
|
|
188
|
+
if (name !== null) {
|
|
189
|
+
showProfile(app, name);
|
|
190
|
+
} else if (selected?.startsWith("run:")) {
|
|
191
|
+
app.dispatch({ type: "tab", tab: "runs" });
|
|
192
|
+
app.dispatch({ type: "run", id: selected.slice("run:".length) });
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
return (
|
|
197
|
+
<List
|
|
198
|
+
items={items}
|
|
199
|
+
selected={selected}
|
|
200
|
+
onSelect={setSelected}
|
|
201
|
+
width={props.width}
|
|
202
|
+
height={props.height}
|
|
203
|
+
empty="checking…"
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core";
|
|
2
|
+
import { usePendingSequence } from "@opentui/keymap/react";
|
|
3
|
+
import { type CommandId, formatKeys, LEADER } from "../commands.ts";
|
|
4
|
+
import { MODE, reachableCommands, useKeybinds, useKeymap, useKeymapVersion } from "../providers/keymap.tsx";
|
|
5
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { FACES, type Mood } from "../theme.ts";
|
|
7
|
+
import { TABS, type Tab } from "../state.ts";
|
|
8
|
+
import { width as widthOf } from "../text.ts";
|
|
9
|
+
import { Line, type Part } from "./line.tsx";
|
|
10
|
+
|
|
11
|
+
const TAB_TITLE: Record<Tab, string> = { status: "Status", profiles: "Profiles", runs: "Runs" };
|
|
12
|
+
|
|
13
|
+
/** Spec §9.3 header: one line with the version, the profile, the unsaved count and the mood face. */
|
|
14
|
+
export function Header(props: {
|
|
15
|
+
version: string;
|
|
16
|
+
profile: string | null;
|
|
17
|
+
active: boolean;
|
|
18
|
+
/** the word after the profile: "active", or "this repo" in a bound repo */
|
|
19
|
+
label?: string;
|
|
20
|
+
dirty: number;
|
|
21
|
+
mood: Mood;
|
|
22
|
+
width: number;
|
|
23
|
+
}) {
|
|
24
|
+
const pending = usePendingSequence();
|
|
25
|
+
const left: Part[] = [{ text: ` catherd ${props.version}`, bold: true }];
|
|
26
|
+
if (props.profile) {
|
|
27
|
+
left.push({ text: ` profile ${props.profile}` });
|
|
28
|
+
if (props.active) left.push({ text: ` (${props.label ?? "active"})`, tone: "muted" });
|
|
29
|
+
}
|
|
30
|
+
if (props.dirty > 0) left.push({ text: ` · ${props.dirty} unsaved`, tone: "warning" });
|
|
31
|
+
const right: Part[] = [];
|
|
32
|
+
if (pending.length)
|
|
33
|
+
right.push({
|
|
34
|
+
text: `${pending.map((p) => (p.tokenName === "leader" ? LEADER : p.display)).join(" ")} … `,
|
|
35
|
+
tone: "accent",
|
|
36
|
+
});
|
|
37
|
+
right.push({ text: `${FACES[props.mood]} `, tone: props.mood === "failed" ? "error" : "muted" });
|
|
38
|
+
const used = [...left, ...right].reduce((n, p) => n + widthOf(p.text), 0);
|
|
39
|
+
const gap = Math.max(1, props.width - used);
|
|
40
|
+
return <Line width={props.width} parts={[...left, { text: " ".repeat(gap) }, ...right]} />;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The tab strip: ` 1 Status 2 Profiles 3 Runs`, the active one bold in the accent. */
|
|
44
|
+
export function Tabs(props: { tab: Tab; width: number }) {
|
|
45
|
+
const ui = useUi();
|
|
46
|
+
const tone = useTone();
|
|
47
|
+
return (
|
|
48
|
+
<box flexDirection="row" width={props.width} height={1}>
|
|
49
|
+
{TABS.map((t, i) => {
|
|
50
|
+
const on = t === props.tab;
|
|
51
|
+
const text = ` ${i + 1} ${TAB_TITLE[t]} `;
|
|
52
|
+
return (
|
|
53
|
+
<text
|
|
54
|
+
key={t}
|
|
55
|
+
wrapMode="none"
|
|
56
|
+
fg={on ? tone(ui.color ? "accent" : "text") : tone("muted")}
|
|
57
|
+
attributes={on ? TextAttributes.BOLD | (ui.color ? 0 : TextAttributes.INVERSE) : 0}
|
|
58
|
+
>
|
|
59
|
+
{i === 0 ? text : ` ${text}`}
|
|
60
|
+
</text>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</box>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Spec §9.2: footer hints are generated from the commands a key would reach now, in their `hint` order,
|
|
69
|
+
* as `key label` (the key as the user bound it). Hints drop from the end to fit, but the palette and
|
|
70
|
+
* help hints stay.
|
|
71
|
+
*/
|
|
72
|
+
export function Footer(props: { width: number }) {
|
|
73
|
+
useKeymapVersion();
|
|
74
|
+
const keymap = useKeymap();
|
|
75
|
+
const keys = useKeybinds();
|
|
76
|
+
const ui = useUi();
|
|
77
|
+
const modal = keymap.getData(MODE) === "modal";
|
|
78
|
+
const hints: { id: string; text: string }[] = [];
|
|
79
|
+
let last: { order: number; label: string; keys: string[] } | null = null;
|
|
80
|
+
for (const c of reachableCommands(keymap)
|
|
81
|
+
.filter((c) => c.hint !== null && keys[c.id].length > 0 && (c.id !== "app.back" || modal))
|
|
82
|
+
.sort((a, b) => (a.hint as number) - (b.hint as number))) {
|
|
83
|
+
const key = formatKeys(keys[c.id as CommandId].slice(0, 1), ui.plain);
|
|
84
|
+
const label = c.short ?? c.title;
|
|
85
|
+
// commands with the same order and label read as one hint: `↑↓ move`
|
|
86
|
+
if (last && last.order === c.hint && last.label === label) {
|
|
87
|
+
last.keys.push(key);
|
|
88
|
+
const joined = last.keys.join(ui.plain ? "/" : "");
|
|
89
|
+
(hints.at(-1) as { text: string }).text = `${joined} ${label}`;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
last = { order: c.hint as number, label, keys: [key] };
|
|
93
|
+
hints.push({ id: c.id, text: `${key} ${label}` });
|
|
94
|
+
}
|
|
95
|
+
const fixed = hints.filter((h) => h.id === "app.palette" || h.id === "app.help");
|
|
96
|
+
const rest = hints.filter((h) => !fixed.includes(h));
|
|
97
|
+
const sep = " ";
|
|
98
|
+
const room = props.width - 1 - fixed.reduce((n, h) => n + widthOf(h.text) + sep.length, 0);
|
|
99
|
+
const kept: string[] = [];
|
|
100
|
+
let used = 0;
|
|
101
|
+
for (const h of rest) {
|
|
102
|
+
if (used + widthOf(h.text) + sep.length > room) break;
|
|
103
|
+
kept.push(h.text);
|
|
104
|
+
used += widthOf(h.text) + sep.length;
|
|
105
|
+
}
|
|
106
|
+
const all = [...kept, ...fixed.map((h) => h.text)];
|
|
107
|
+
const parts: Part[] = [{ text: " " }];
|
|
108
|
+
all.forEach((t, i) => {
|
|
109
|
+
const at = t.indexOf(" ");
|
|
110
|
+
parts.push({ text: t.slice(0, at), bold: true }, { text: t.slice(at), tone: "muted" });
|
|
111
|
+
if (i < all.length - 1) parts.push({ text: sep });
|
|
112
|
+
});
|
|
113
|
+
return <Line width={props.width} parts={parts} />;
|
|
114
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog-confirm.tsx (MIT, © 2025 opencode);
|
|
2
|
+
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import { useApp } from "../providers/app.tsx";
|
|
5
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
6
|
+
import type { Dialog as DialogState } from "../state.ts";
|
|
7
|
+
import { wrap } from "../text.ts";
|
|
8
|
+
import { Dialog } from "./dialog.tsx";
|
|
9
|
+
import { Line } from "./line.tsx";
|
|
10
|
+
|
|
11
|
+
type Confirm = Extract<DialogState, { kind: "confirm" }>;
|
|
12
|
+
|
|
13
|
+
/** A row of buttons: ←/→ or tab move, enter chooses (spec §9.2 confirmations; never "any other key"). */
|
|
14
|
+
export function Buttons(props: { labels: string[]; focused: number; width: number }) {
|
|
15
|
+
const cells = props.labels.map((l) => `[ ${l} ]`);
|
|
16
|
+
const used = cells.reduce((n, c) => n + Bun.stringWidth(c) + 2, 0);
|
|
17
|
+
return (
|
|
18
|
+
<box flexDirection="row" width={props.width} height={1}>
|
|
19
|
+
{cells.map((c, i) => (
|
|
20
|
+
<box key={c} flexDirection="row">
|
|
21
|
+
<Line width={Bun.stringWidth(c)} parts={[{ text: c }]} selected={i === props.focused} />
|
|
22
|
+
<text>{" "}</text>
|
|
23
|
+
</box>
|
|
24
|
+
))}
|
|
25
|
+
<text>{" ".repeat(Math.max(0, props.width - used))}</text>
|
|
26
|
+
</box>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A real confirmation: the message, then [no] [yes]; a destructive one starts on "no". */
|
|
31
|
+
export function DialogConfirm(props: { dialog: Confirm }) {
|
|
32
|
+
const app = useApp();
|
|
33
|
+
const d = props.dialog;
|
|
34
|
+
const [focused, setFocusedState] = useState(d.destructive ? 0 : 1);
|
|
35
|
+
// keys in one burst read the focus as it is now, not as it was drawn
|
|
36
|
+
const focusedRef = useRef(focused);
|
|
37
|
+
const setFocused = (f: number) => {
|
|
38
|
+
focusedRef.current = f;
|
|
39
|
+
setFocusedState(f);
|
|
40
|
+
};
|
|
41
|
+
useCommandLayer("dialog", {
|
|
42
|
+
"dialog.left": () => setFocused(0),
|
|
43
|
+
"dialog.right": () => setFocused(1),
|
|
44
|
+
"dialog.submit": () => (focusedRef.current === 1 ? app.answer("yes") : app.dispatch({ type: "close" })),
|
|
45
|
+
});
|
|
46
|
+
return (
|
|
47
|
+
<Dialog
|
|
48
|
+
title={d.title}
|
|
49
|
+
rows={(inner) => [
|
|
50
|
+
...d.message.flatMap((m, i) =>
|
|
51
|
+
wrap(m, inner).map((l, j) => <Line key={`m${i}-${j}`} width={inner} parts={[{ text: l }]} />),
|
|
52
|
+
),
|
|
53
|
+
<Line key="gap" width={inner} parts={[]} />,
|
|
54
|
+
<Buttons key="buttons" labels={[d.no, d.yes]} focused={focused} width={inner} />,
|
|
55
|
+
]}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog-prompt.tsx (MIT, © 2025 opencode);
|
|
2
|
+
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import type { InputRenderable } from "@opentui/core";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { useApp } from "../providers/app.tsx";
|
|
6
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
7
|
+
import { useTone } from "../providers/theme.tsx";
|
|
8
|
+
import type { Dialog as DialogState } from "../state.ts";
|
|
9
|
+
import { Dialog } from "./dialog.tsx";
|
|
10
|
+
import { Line } from "./line.tsx";
|
|
11
|
+
|
|
12
|
+
type Prompt = Extract<DialogState, { kind: "prompt" }>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A value editor: one input that owns every printable key, enter to submit, esc to cancel. A refused
|
|
16
|
+
* value keeps what was typed and shows why under it (research K13).
|
|
17
|
+
*/
|
|
18
|
+
export function DialogPrompt(props: { dialog: Prompt }) {
|
|
19
|
+
const app = useApp();
|
|
20
|
+
const tone = useTone();
|
|
21
|
+
const input = useRef<InputRenderable>(null);
|
|
22
|
+
const d = props.dialog;
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
input.current?.focus();
|
|
25
|
+
}, []);
|
|
26
|
+
// the value typed so far, not the one drawn: text typed in the same tick as enter has not drawn yet
|
|
27
|
+
useCommandLayer("dialog", {
|
|
28
|
+
"dialog.submit": () => {
|
|
29
|
+
const top = app.getState().dialogs.at(-1);
|
|
30
|
+
app.answer(top?.kind === "prompt" ? top.value : d.value);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
return (
|
|
34
|
+
<Dialog
|
|
35
|
+
title={d.title}
|
|
36
|
+
rows={(inner) => [
|
|
37
|
+
<Line key="label" width={inner} parts={[{ text: d.label, tone: "muted" }]} />,
|
|
38
|
+
<box key="input" flexDirection="row" width={inner} height={1}>
|
|
39
|
+
<text fg={tone("accent")}>{"> "}</text>
|
|
40
|
+
<input
|
|
41
|
+
ref={input}
|
|
42
|
+
value={d.value}
|
|
43
|
+
onInput={(v: string) => app.dispatch({ type: "input", value: v })}
|
|
44
|
+
width={inner - 2}
|
|
45
|
+
/>
|
|
46
|
+
</box>,
|
|
47
|
+
<Line key="error" width={inner} parts={d.error ? [{ text: d.error, tone: "error" }] : []} />,
|
|
48
|
+
<Line key="hint" width={inner} parts={[{ text: "enter to save, esc to cancel", tone: "muted" }]} />,
|
|
49
|
+
]}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog-select.tsx (MIT, © 2025 opencode);
|
|
2
|
+
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import type { InputRenderable } from "@opentui/core";
|
|
4
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
5
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { useApp } from "../providers/app.tsx";
|
|
7
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
8
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
9
|
+
import { type Dialog as DialogState, isArmed, type SelectOption } from "../state.ts";
|
|
10
|
+
import { glyph } from "../theme.ts";
|
|
11
|
+
import { Dialog, dialogRows } from "./dialog.tsx";
|
|
12
|
+
import { Line, type Part } from "./line.tsx";
|
|
13
|
+
import { windowOf } from "./list.tsx";
|
|
14
|
+
|
|
15
|
+
type Select = Extract<DialogState, { kind: "select" }>;
|
|
16
|
+
|
|
17
|
+
interface Entry {
|
|
18
|
+
key: string;
|
|
19
|
+
heading: string | null;
|
|
20
|
+
option: SelectOption | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Options matching every word of `text` in their title, group or detail. */
|
|
24
|
+
export function matchOptions(options: SelectOption[], text: string): SelectOption[] {
|
|
25
|
+
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
|
26
|
+
return options.filter((o) => {
|
|
27
|
+
const hay = `${o.title} ${o.group ?? ""} ${o.detail ?? ""} ${o.cli ?? ""}`.toLowerCase();
|
|
28
|
+
return words.every((w) => hay.includes(w));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Grouped lines for the options: a heading before each group, Suggested first while not filtering. */
|
|
33
|
+
export function entriesOf(d: Select, text: string): Entry[] {
|
|
34
|
+
const shown = matchOptions(d.options, text);
|
|
35
|
+
const out: Entry[] = [];
|
|
36
|
+
if (!text.trim() && d.suggested?.length) {
|
|
37
|
+
out.push({ key: "h:Suggested", heading: "Suggested", option: null });
|
|
38
|
+
for (const v of d.suggested) {
|
|
39
|
+
const o = d.options.find((x) => x.value === v);
|
|
40
|
+
if (o) out.push({ key: `s:${v}`, heading: null, option: o });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let group: string | undefined;
|
|
44
|
+
for (const o of shown) {
|
|
45
|
+
if (o.group !== undefined && o.group !== group)
|
|
46
|
+
out.push({ key: `h:${o.group}`, heading: o.group, option: null });
|
|
47
|
+
group = o.group;
|
|
48
|
+
out.push({ key: `o:${o.value}`, heading: null, option: o });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The picker every list dialog uses (palette, help, profile list, treat-like, failover, default rung):
|
|
55
|
+
* a filter that owns the letters, ↑/↓ or ctrl+p/ctrl+n to move, enter to choose, esc to cancel; ctrl+d
|
|
56
|
+
* twice deletes where the dialog offers it (spec §9.2).
|
|
57
|
+
*/
|
|
58
|
+
export function DialogSelect(props: { dialog: Select }) {
|
|
59
|
+
const app = useApp();
|
|
60
|
+
const ui = useUi();
|
|
61
|
+
const tone = useTone();
|
|
62
|
+
const dims = useTerminalDimensions();
|
|
63
|
+
const input = useRef<InputRenderable>(null);
|
|
64
|
+
const [text, setText] = useState("");
|
|
65
|
+
const [index, setIndex] = useState(0);
|
|
66
|
+
// what a key handler reads: the filter and cursor as they are now, not as drawn (keys in one tick)
|
|
67
|
+
const textRef = useRef("");
|
|
68
|
+
const indexRef = useRef(0);
|
|
69
|
+
const [top, setTop] = useState(0);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
input.current?.focus();
|
|
72
|
+
}, []);
|
|
73
|
+
const pick = (t: string, i: number) => {
|
|
74
|
+
const entries = entriesOf(props.dialog, t);
|
|
75
|
+
const selectable = entries.flatMap((e, n) => (e.option ? [n] : []));
|
|
76
|
+
const cur = selectable[Math.max(0, Math.min(i, selectable.length - 1))];
|
|
77
|
+
return { entries, selectable, cur, chosen: cur === undefined ? null : (entries[cur]?.option ?? null) };
|
|
78
|
+
};
|
|
79
|
+
const { entries, cur, chosen } = pick(text, index);
|
|
80
|
+
const chosenNow = () => pick(textRef.current, indexRef.current).chosen;
|
|
81
|
+
// every row the panel has above the rule and footer, less the filter and the gap under it
|
|
82
|
+
const height = Math.max(3, Math.min(entries.length, dialogRows(dims.height) - 4));
|
|
83
|
+
const w = windowOf(entries.length, cur ?? 0, height, top);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (w.top !== top) setTop(w.top);
|
|
86
|
+
}, [w.top, top]);
|
|
87
|
+
// moving the cursor takes back a first ctrl+d (opencode's session list does the same)
|
|
88
|
+
const move = (by: number) => {
|
|
89
|
+
if (app.getState().armed) app.dispatch({ type: "disarm" });
|
|
90
|
+
const n = pick(textRef.current, indexRef.current).selectable.length;
|
|
91
|
+
indexRef.current = Math.max(0, Math.min(n - 1, indexRef.current + by));
|
|
92
|
+
setIndex(indexRef.current);
|
|
93
|
+
};
|
|
94
|
+
const armed = chosen ? isArmed(app.state, "delete", chosen.value, app.clock.now()) : false;
|
|
95
|
+
useCommandLayer("dialog", {
|
|
96
|
+
"dialog.up": () => move(-1),
|
|
97
|
+
"dialog.down": () => move(1),
|
|
98
|
+
"dialog.pageUp": () => move(-height),
|
|
99
|
+
"dialog.pageDown": () => move(height),
|
|
100
|
+
"dialog.submit": () => {
|
|
101
|
+
const c = chosenNow();
|
|
102
|
+
if (c) app.answer(c.value);
|
|
103
|
+
},
|
|
104
|
+
...(props.dialog.deletable
|
|
105
|
+
? {
|
|
106
|
+
"dialog.delete": () => {
|
|
107
|
+
const c = chosenNow();
|
|
108
|
+
if (!c) return;
|
|
109
|
+
if (isArmed(app.getState(), "delete", c.value, app.clock.now())) {
|
|
110
|
+
app.dispatch({ type: "disarm" });
|
|
111
|
+
app.answer(`delete:${c.value}`);
|
|
112
|
+
} else app.dispatch({ type: "arm", what: "delete", target: c.value, at: app.clock.now() });
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
: {}),
|
|
116
|
+
});
|
|
117
|
+
const rows = (inner: number): ReactNode[] => {
|
|
118
|
+
// columns: the title, the detail and the CLI twin, each padded to the widest in the whole list, so
|
|
119
|
+
// they line up as the list scrolls; the CLI column goes when it does not fit, the detail column
|
|
120
|
+
// falls back to the right edge when even it does not
|
|
121
|
+
const wide = (f: (o: SelectOption) => string | undefined) =>
|
|
122
|
+
Math.max(0, ...props.dialog.options.map((o) => Bun.stringWidth(f(o) ?? "")));
|
|
123
|
+
const tw = wide((o) => o.title);
|
|
124
|
+
const dw = wide((o) => o.detail);
|
|
125
|
+
const cw = wide((o) => o.cli);
|
|
126
|
+
const columns = 2 + tw + 2 + dw <= inner;
|
|
127
|
+
const withCli = columns && cw > 0 && 2 + tw + 2 + dw + 2 + cw <= inner;
|
|
128
|
+
const out: ReactNode[] = [
|
|
129
|
+
<box key="filter" flexDirection="row" width={inner} height={1}>
|
|
130
|
+
<text fg={tone("accent")}>{"> "}</text>
|
|
131
|
+
<input
|
|
132
|
+
ref={input}
|
|
133
|
+
value={text}
|
|
134
|
+
placeholder="type to filter"
|
|
135
|
+
onInput={(v: string) => {
|
|
136
|
+
textRef.current = v;
|
|
137
|
+
indexRef.current = 0;
|
|
138
|
+
setText(v);
|
|
139
|
+
setIndex(0);
|
|
140
|
+
if (app.getState().armed) app.dispatch({ type: "disarm" });
|
|
141
|
+
}}
|
|
142
|
+
width={inner - 2}
|
|
143
|
+
/>
|
|
144
|
+
</box>,
|
|
145
|
+
<Line key="gap" width={inner} parts={[]} />,
|
|
146
|
+
];
|
|
147
|
+
if (entries.length === 0) {
|
|
148
|
+
out.push(
|
|
149
|
+
<Line
|
|
150
|
+
key="none"
|
|
151
|
+
width={inner}
|
|
152
|
+
parts={[{ text: props.dialog.options.length ? "No match" : props.dialog.empty, tone: "muted" }]}
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
entries.slice(w.top, w.top + height).forEach((e, i) => {
|
|
158
|
+
const at = w.top + i;
|
|
159
|
+
if (i === 0 && w.above > 0)
|
|
160
|
+
return out.push(
|
|
161
|
+
<Line
|
|
162
|
+
key="up"
|
|
163
|
+
width={inner}
|
|
164
|
+
parts={[{ text: `${glyph("up", ui.plain)} ${w.above + 1} more`, tone: "muted" }]}
|
|
165
|
+
/>,
|
|
166
|
+
);
|
|
167
|
+
if (i === height - 1 && w.below > 0)
|
|
168
|
+
return out.push(
|
|
169
|
+
<Line
|
|
170
|
+
key="down"
|
|
171
|
+
width={inner}
|
|
172
|
+
parts={[{ text: `${glyph("down", ui.plain)} ${w.below + 1} more`, tone: "muted" }]}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
if (!e.option)
|
|
176
|
+
return out.push(
|
|
177
|
+
<Line key={e.key} width={inner} parts={[{ text: e.heading ?? "", tone: "accent", bold: true }]} />,
|
|
178
|
+
);
|
|
179
|
+
const o = e.option;
|
|
180
|
+
const selected = at === cur;
|
|
181
|
+
const label = selected && armed ? "press ctrl+d again to delete" : o.title;
|
|
182
|
+
const detail = o.detail ?? "";
|
|
183
|
+
const left: Part[] = [
|
|
184
|
+
{ text: `${o.current ? glyph("current", ui.plain) : " "} ` },
|
|
185
|
+
{ text: label, tone: selected && armed ? "warning" : undefined },
|
|
186
|
+
];
|
|
187
|
+
const pad = (t: string, n: number) => " ".repeat(Math.max(0, n - Bun.stringWidth(t)));
|
|
188
|
+
const right: Part[] = columns
|
|
189
|
+
? [
|
|
190
|
+
{ text: `${pad(label, tw)} ` },
|
|
191
|
+
{ text: withCli ? `${detail}${pad(detail, dw)} ` : detail, tone: "muted" },
|
|
192
|
+
{ text: withCli ? (o.cli ?? "") : "", tone: "muted" },
|
|
193
|
+
]
|
|
194
|
+
: [
|
|
195
|
+
{
|
|
196
|
+
text: " ".repeat(Math.max(1, inner - Bun.stringWidth(` ${label}`) - Bun.stringWidth(detail))),
|
|
197
|
+
},
|
|
198
|
+
{ text: detail, tone: "muted" },
|
|
199
|
+
];
|
|
200
|
+
out.push(<Line key={e.key} width={inner} selected={selected} parts={[...left, ...right]} />);
|
|
201
|
+
});
|
|
202
|
+
return out;
|
|
203
|
+
};
|
|
204
|
+
return (
|
|
205
|
+
<Dialog
|
|
206
|
+
title={props.dialog.title}
|
|
207
|
+
// the palette's CLI column needs the wide panel
|
|
208
|
+
size={props.dialog.options.length > 40 || props.dialog.options.some((o) => o.cli) ? "large" : "medium"}
|
|
209
|
+
rows={rows}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
}
|