catherd-cli 0.2.1 → 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 -26
- 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 -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
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/ui/dialog.tsx (MIT, © 2025 opencode);
|
|
2
|
+
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { DIALOG_WIDTH, type DialogSize } from "../theme.ts";
|
|
7
|
+
import { Line } from "./line.tsx";
|
|
8
|
+
|
|
9
|
+
/** The panel's inner width: its size, clamped to the terminal less a margin, less the padding. */
|
|
10
|
+
const dialogInner = (size: DialogSize, termWidth: number): number =>
|
|
11
|
+
Math.max(10, Math.min(DIALOG_WIDTH[size], termWidth - 2) - 4);
|
|
12
|
+
|
|
13
|
+
/** The rows a quarter down the screen leaves for `rows`: the height less the top padding, the
|
|
14
|
+
* blank line, the title, the gap under it and the blank line at the bottom. */
|
|
15
|
+
export const dialogRows = (termHeight: number): number =>
|
|
16
|
+
Math.max(1, termHeight - Math.floor(termHeight / 4) - 4);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Spec §9.3: a raised panel over a dimmed backdrop, a quarter down the screen, 60, 88 or 116 columns
|
|
20
|
+
* wide, with no border; the title bold on the left and a muted `esc` on the right. `rows` returns one
|
|
21
|
+
* node per line, each `inner` columns wide (Line pads them); the panel pads every line with spaces on
|
|
22
|
+
* both sides, so it hides what is beneath even without colour.
|
|
23
|
+
*/
|
|
24
|
+
export function Dialog(props: { title: string; size?: DialogSize; rows: (inner: number) => ReactNode[] }) {
|
|
25
|
+
const dims = useTerminalDimensions();
|
|
26
|
+
const ui = useUi();
|
|
27
|
+
const tone = useTone();
|
|
28
|
+
const size = props.size ?? "medium";
|
|
29
|
+
const inner = dialogInner(size, dims.width);
|
|
30
|
+
const lines: ReactNode[] = [
|
|
31
|
+
<Line key="top" width={inner} parts={[]} />,
|
|
32
|
+
<Line
|
|
33
|
+
key="title"
|
|
34
|
+
width={inner}
|
|
35
|
+
parts={[
|
|
36
|
+
{ text: props.title, bold: true },
|
|
37
|
+
{ text: " ".repeat(Math.max(1, inner - Bun.stringWidth(props.title) - 3)) },
|
|
38
|
+
{ text: "esc", tone: "muted" },
|
|
39
|
+
]}
|
|
40
|
+
/>,
|
|
41
|
+
<Line key="gap" width={inner} parts={[]} />,
|
|
42
|
+
...props.rows(inner),
|
|
43
|
+
<Line key="bottom" width={inner} parts={[]} />,
|
|
44
|
+
];
|
|
45
|
+
return (
|
|
46
|
+
<box
|
|
47
|
+
position="absolute"
|
|
48
|
+
top={0}
|
|
49
|
+
left={0}
|
|
50
|
+
zIndex={3000}
|
|
51
|
+
width={dims.width}
|
|
52
|
+
height={dims.height}
|
|
53
|
+
alignItems="center"
|
|
54
|
+
paddingTop={Math.floor(dims.height / 4)}
|
|
55
|
+
backgroundColor={ui.color ? `${tone("backdrop")}99` : undefined}
|
|
56
|
+
>
|
|
57
|
+
<box width={inner + 4} flexDirection="column" backgroundColor={tone("surfaceRaised")}>
|
|
58
|
+
{lines.map((line, i) => (
|
|
59
|
+
<box key={i} flexDirection="row" height={1}>
|
|
60
|
+
<text>{" "}</text>
|
|
61
|
+
{line}
|
|
62
|
+
<text>{" "}</text>
|
|
63
|
+
</box>
|
|
64
|
+
))}
|
|
65
|
+
</box>
|
|
66
|
+
</box>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core";
|
|
2
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
3
|
+
import { ascii, truncateEnd, width as widthOf } from "../text.ts";
|
|
4
|
+
import type { Token } from "../theme.ts";
|
|
5
|
+
|
|
6
|
+
/** One run of text on a line, in a token's colour. */
|
|
7
|
+
export interface Part {
|
|
8
|
+
text: string;
|
|
9
|
+
tone?: Token;
|
|
10
|
+
bold?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The parts cut to `max` columns at the end (an ellipsis on the last one kept). */
|
|
14
|
+
export function clip(parts: Part[], max: number, plain: boolean): Part[] {
|
|
15
|
+
const out: Part[] = [];
|
|
16
|
+
let used = 0;
|
|
17
|
+
for (const p of parts) {
|
|
18
|
+
const w = widthOf(p.text);
|
|
19
|
+
if (used + w <= max) {
|
|
20
|
+
out.push(p);
|
|
21
|
+
used += w;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const room = max - used;
|
|
25
|
+
if (room > 0) out.push({ ...p, text: truncateEnd(p.text, room, plain) });
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one line primitive every row is drawn with: exactly `width` columns. A selected line is filled
|
|
33
|
+
* with the accent and bold (spec §9.3), or drawn in reverse video without colour; under --plain every
|
|
34
|
+
* character is ASCII.
|
|
35
|
+
*/
|
|
36
|
+
export function Line(props: {
|
|
37
|
+
parts: Part[];
|
|
38
|
+
width: number;
|
|
39
|
+
selected?: boolean;
|
|
40
|
+
dim?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Paints every cell, spaces included, so nothing beneath shows through (a toast over the tabs): the
|
|
43
|
+
* raised surface with colour, reverse video without, since a plain space paints nothing.
|
|
44
|
+
*/
|
|
45
|
+
opaque?: boolean;
|
|
46
|
+
}) {
|
|
47
|
+
const ui = useUi();
|
|
48
|
+
const tone = useTone();
|
|
49
|
+
const parts = clip(
|
|
50
|
+
ui.plain ? props.parts.map((p) => ({ ...p, text: ascii(p.text) })) : props.parts,
|
|
51
|
+
props.width,
|
|
52
|
+
ui.plain,
|
|
53
|
+
);
|
|
54
|
+
const used = parts.reduce((n, p) => n + widthOf(p.text), 0);
|
|
55
|
+
const pad = " ".repeat(Math.max(0, props.width - used));
|
|
56
|
+
const sel = props.selected === true;
|
|
57
|
+
const fg = (p: Part) =>
|
|
58
|
+
sel ? tone("selectionText") : props.dim ? tone("muted") : p.tone ? tone(p.tone) : undefined;
|
|
59
|
+
// without colour, the terminal's own default background (SGR 49, no colour) makes a space paint
|
|
60
|
+
const bg = sel
|
|
61
|
+
? tone("selection")
|
|
62
|
+
: props.opaque
|
|
63
|
+
? ui.color
|
|
64
|
+
? tone("surfaceRaised")
|
|
65
|
+
: RGBA.defaultBackground()
|
|
66
|
+
: undefined;
|
|
67
|
+
const inverse = (sel || props.opaque === true) && !ui.color ? TextAttributes.INVERSE : 0;
|
|
68
|
+
const attrs = (p: Part) => (sel || p.bold ? TextAttributes.BOLD : 0) | inverse;
|
|
69
|
+
return (
|
|
70
|
+
<text wrapMode="none">
|
|
71
|
+
{parts.map((p, i) => (
|
|
72
|
+
<span key={i} fg={fg(p)} bg={bg} attributes={attrs(p)}>
|
|
73
|
+
{p.text}
|
|
74
|
+
</span>
|
|
75
|
+
))}
|
|
76
|
+
<span bg={bg} attributes={inverse}>
|
|
77
|
+
{pad}
|
|
78
|
+
</span>
|
|
79
|
+
</text>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A full-width rule (`─`, or `-` in plain). */
|
|
84
|
+
export function Rule(props: { width: number }) {
|
|
85
|
+
const ui = useUi();
|
|
86
|
+
const tone = useTone();
|
|
87
|
+
return (
|
|
88
|
+
<text wrapMode="none" fg={tone("border")}>
|
|
89
|
+
{(ui.plain ? "-" : "─").repeat(Math.max(0, props.width))}
|
|
90
|
+
</text>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { InputRenderable } from "@opentui/core";
|
|
2
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useBack } from "../providers/app.tsx";
|
|
4
|
+
import { useCommandLayer } from "../providers/keymap.tsx";
|
|
5
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { glyph } from "../theme.ts";
|
|
7
|
+
import { Line } from "./line.tsx";
|
|
8
|
+
|
|
9
|
+
export interface ListItem {
|
|
10
|
+
key: string;
|
|
11
|
+
/** headings and continuation lines are skipped by the cursor */
|
|
12
|
+
selectable: boolean;
|
|
13
|
+
render(selected: boolean, width: number): ReactNode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Window {
|
|
17
|
+
top: number;
|
|
18
|
+
/** rows hidden above and below the window */
|
|
19
|
+
above: number;
|
|
20
|
+
below: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Which rows of `total` fit in `height` so that `index` shows, moving no more than needed from `top`.
|
|
27
|
+
* With 3 rows or more, a hint line (`↑ 3 more`) takes the first or the last row when rows are hidden there.
|
|
28
|
+
*/
|
|
29
|
+
export function windowOf(total: number, index: number, height: number, top: number): Window {
|
|
30
|
+
if (total <= height) return { top: 0, above: 0, below: 0 };
|
|
31
|
+
const i = clamp(index, 0, total - 1);
|
|
32
|
+
const maxTop = total - height;
|
|
33
|
+
let t = clamp(top, 0, maxTop);
|
|
34
|
+
if (height >= 3) {
|
|
35
|
+
const first = t + (t > 0 ? 1 : 0);
|
|
36
|
+
const last = t + height - 1 - (t < maxTop ? 1 : 0);
|
|
37
|
+
if (i < first) t = i === 0 ? 0 : i - 1;
|
|
38
|
+
else if (i > last) t = i === total - 1 ? maxTop : i - height + 2;
|
|
39
|
+
} else if (i < t) t = i;
|
|
40
|
+
else if (i > t + height - 1) t = i - height + 1;
|
|
41
|
+
t = clamp(t, 0, maxTop);
|
|
42
|
+
return height >= 3 ? { top: t, above: t, below: maxTop - t } : { top: t, above: 0, below: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The selectable row `by` selectable rows away from row `from` (clamped to the ends). From a row that
|
|
47
|
+
* cannot be selected, the next selectable row is one step down and the previous one a step up; `by` 0
|
|
48
|
+
* snaps to the next one (else the last).
|
|
49
|
+
*/
|
|
50
|
+
export function step(items: readonly { selectable: boolean }[], from: number, by: number): number {
|
|
51
|
+
const sel = items.flatMap((it, i) => (it.selectable ? [i] : []));
|
|
52
|
+
if (sel.length === 0) return -1;
|
|
53
|
+
const at = sel.indexOf(from);
|
|
54
|
+
if (at >= 0) return sel[clamp(at + by, 0, sel.length - 1)] as number;
|
|
55
|
+
const next = sel.findIndex((i) => i > from);
|
|
56
|
+
if (by === 0) return sel[next < 0 ? sel.length - 1 : next] as number;
|
|
57
|
+
const pos = by > 0 ? (next < 0 ? sel.length - 1 : next + by - 1) : (next < 0 ? sel.length : next) + by;
|
|
58
|
+
return sel[clamp(pos, 0, sel.length - 1)] as number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A view's selected row: `selected` is what this render draws, `current()` what a key handler must read
|
|
63
|
+
* (a key in the same tick may have moved it already).
|
|
64
|
+
*/
|
|
65
|
+
export function useSelected(): {
|
|
66
|
+
selected: string | null;
|
|
67
|
+
select(key: string): void;
|
|
68
|
+
current(): string | null;
|
|
69
|
+
} {
|
|
70
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
71
|
+
const ref = useRef<string | null>(null);
|
|
72
|
+
ref.current = selected;
|
|
73
|
+
return {
|
|
74
|
+
selected,
|
|
75
|
+
select(key) {
|
|
76
|
+
ref.current = key;
|
|
77
|
+
setSelected(key);
|
|
78
|
+
},
|
|
79
|
+
current: () => ref.current,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A list or tree pane (spec §9.2 lists: arrows plus j/k, paging, Home/End, `/` filter). Controlled: the
|
|
85
|
+
* view owns the selected key and the filter text; the list moves them and draws the visible window with
|
|
86
|
+
* `↑ n more` / `↓ n more` hints instead of a scrollbar.
|
|
87
|
+
*/
|
|
88
|
+
export function List(props: {
|
|
89
|
+
items: ListItem[];
|
|
90
|
+
selected: string | null;
|
|
91
|
+
onSelect(key: string): void;
|
|
92
|
+
width: number;
|
|
93
|
+
height: number;
|
|
94
|
+
/** the filter text, null when no filter is open */
|
|
95
|
+
filter?: string | null;
|
|
96
|
+
onFilter?(text: string | null): void;
|
|
97
|
+
/** the muted line shown when there are no rows */
|
|
98
|
+
empty: string;
|
|
99
|
+
}) {
|
|
100
|
+
const ui = useUi();
|
|
101
|
+
const tone = useTone();
|
|
102
|
+
const input = useRef<InputRenderable>(null);
|
|
103
|
+
const [top, setTop] = useState(0);
|
|
104
|
+
// the command that opens the filter is drawn at once (useCommandLayer), so this runs before the next key
|
|
105
|
+
const [focusTick, setFocusTick] = useState(0);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (focusTick > 0) input.current?.focus();
|
|
108
|
+
}, [focusTick]);
|
|
109
|
+
const filtering = props.filter !== undefined && props.filter !== null;
|
|
110
|
+
const bodyHeight = Math.max(1, props.height - (filtering ? 1 : 0));
|
|
111
|
+
const indexOf = (key: string | null) => {
|
|
112
|
+
const i = props.items.findIndex((it) => it.key === key);
|
|
113
|
+
return i < 0 || !props.items[i]?.selectable ? step(props.items, Math.max(0, i), 0) : i;
|
|
114
|
+
};
|
|
115
|
+
const index = indexOf(props.selected);
|
|
116
|
+
// the row a key handler moves from: the one the last key chose, which may not be drawn yet
|
|
117
|
+
const selectedRef = useRef(props.selected);
|
|
118
|
+
selectedRef.current = props.selected;
|
|
119
|
+
const choose = (i: number) => {
|
|
120
|
+
const it = props.items[i];
|
|
121
|
+
if (!it) return;
|
|
122
|
+
selectedRef.current = it.key;
|
|
123
|
+
props.onSelect(it.key);
|
|
124
|
+
};
|
|
125
|
+
const w = windowOf(props.items.length, Math.max(0, index), bodyHeight, top);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (w.top !== top) setTop(w.top);
|
|
128
|
+
}, [w.top, top]);
|
|
129
|
+
// the cursor snaps to a selectable row, judged on the selection now: this effect can run after a key
|
|
130
|
+
// already moved it (a key that lands between a draw and its effects), and must not take that back
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const it = props.items[indexOf(selectedRef.current)];
|
|
133
|
+
if (it && it.key !== selectedRef.current) choose(indexOf(selectedRef.current));
|
|
134
|
+
});
|
|
135
|
+
const move = (by: number) => choose(step(props.items, Math.max(0, indexOf(selectedRef.current)), by));
|
|
136
|
+
const page = Math.max(1, bodyHeight - 2);
|
|
137
|
+
useCommandLayer("list", {
|
|
138
|
+
"list.up": () => move(-1),
|
|
139
|
+
"list.down": () => move(1),
|
|
140
|
+
"list.pageUp": () => move(-page),
|
|
141
|
+
"list.pageDown": () => move(page),
|
|
142
|
+
"list.first": () => choose(step(props.items, 0, 0)),
|
|
143
|
+
"list.last": () => choose(step(props.items, props.items.length - 1, 0)),
|
|
144
|
+
...(props.onFilter
|
|
145
|
+
? {
|
|
146
|
+
"list.filter": () => {
|
|
147
|
+
props.onFilter?.(props.filter ?? "");
|
|
148
|
+
setFocusTick((n) => n + 1);
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
: {}),
|
|
152
|
+
});
|
|
153
|
+
useCommandLayer("filter", { "filter.accept": () => input.current?.blur() });
|
|
154
|
+
useBack(filtering, "input", () => {
|
|
155
|
+
input.current?.blur();
|
|
156
|
+
props.onFilter?.(null);
|
|
157
|
+
});
|
|
158
|
+
const rows: ReactNode[] = [];
|
|
159
|
+
const visible = props.items.slice(w.top, w.top + bodyHeight);
|
|
160
|
+
visible.forEach((it, i) => {
|
|
161
|
+
const hintAbove = i === 0 && w.above > 0;
|
|
162
|
+
const hintBelow = i === visible.length - 1 && w.below > 0;
|
|
163
|
+
if (hintAbove || hintBelow) {
|
|
164
|
+
const n = hintAbove ? w.above + 1 : w.below + 1;
|
|
165
|
+
rows.push(
|
|
166
|
+
<Line
|
|
167
|
+
key={`hint-${hintAbove ? "up" : "down"}`}
|
|
168
|
+
width={props.width}
|
|
169
|
+
parts={[{ text: ` ${glyph(hintAbove ? "up" : "down", ui.plain)} ${n} more`, tone: "muted" }]}
|
|
170
|
+
/>,
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
rows.push(<box key={it.key}>{it.render(w.top + i === index, props.width)}</box>);
|
|
175
|
+
});
|
|
176
|
+
if (props.items.length === 0)
|
|
177
|
+
rows.push(<Line key="empty" width={props.width} parts={[{ text: ` ${props.empty}`, tone: "muted" }]} />);
|
|
178
|
+
const matches = props.items.filter((it) => it.selectable).length;
|
|
179
|
+
return (
|
|
180
|
+
<box flexDirection="column" width={props.width} height={props.height}>
|
|
181
|
+
{filtering ? (
|
|
182
|
+
<box flexDirection="row" width={props.width} height={1}>
|
|
183
|
+
<text fg={tone("accent")}>{" / "}</text>
|
|
184
|
+
<input
|
|
185
|
+
ref={input}
|
|
186
|
+
value={props.filter ?? ""}
|
|
187
|
+
onInput={(v: string) => props.onFilter?.(v)}
|
|
188
|
+
width={Math.max(1, props.width - 18)}
|
|
189
|
+
/>
|
|
190
|
+
<text fg={tone("muted")}>{` ${matches} ${matches === 1 ? "match" : "matches"}`.padEnd(15)}</text>
|
|
191
|
+
</box>
|
|
192
|
+
) : null}
|
|
193
|
+
{rows}
|
|
194
|
+
</box>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/component/spinner.tsx and startup-loading.tsx
|
|
2
|
+
// (MIT, © 2025 opencode); see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useApp } from "../providers/app.tsx";
|
|
5
|
+
import { useTone, useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { ascii } from "../text.ts";
|
|
7
|
+
import { SPINNER } from "../theme.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spec §9.3 motion: true once `active` has lasted SPINNER.showAfterMs, and then for at least
|
|
11
|
+
* SPINNER.minShowMs, so a quick check never flickers.
|
|
12
|
+
*/
|
|
13
|
+
export function useDelayedPresence(active: boolean): boolean {
|
|
14
|
+
const { clock } = useApp();
|
|
15
|
+
const [shownAt, setShownAt] = useState<number | null>(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (active && shownAt === null) {
|
|
18
|
+
const h = clock.setTimeout(() => setShownAt(clock.now()), SPINNER.showAfterMs);
|
|
19
|
+
return () => clock.clearTimeout(h);
|
|
20
|
+
}
|
|
21
|
+
if (!active && shownAt !== null) {
|
|
22
|
+
const left = SPINNER.minShowMs - (clock.now() - shownAt);
|
|
23
|
+
if (left <= 0) {
|
|
24
|
+
setShownAt(null);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const h = clock.setTimeout(() => setShownAt(null), left);
|
|
28
|
+
return () => clock.clearTimeout(h);
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}, [active, shownAt, clock]);
|
|
32
|
+
return shownAt !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A braille spinner and what it waits on; a still `⋯` under reduced motion (spec §9.3). */
|
|
36
|
+
export function Spinner(props: { label: string }) {
|
|
37
|
+
const { clock } = useApp();
|
|
38
|
+
const ui = useUi();
|
|
39
|
+
const tone = useTone();
|
|
40
|
+
const [frame, setFrame] = useState(0);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (ui.reducedMotion) return;
|
|
43
|
+
const h = clock.setInterval(() => setFrame((f) => f + 1), SPINNER.ms);
|
|
44
|
+
return () => clock.clearInterval(h);
|
|
45
|
+
}, [clock, ui.reducedMotion]);
|
|
46
|
+
const frames = ui.plain ? SPINNER.plainFrames : SPINNER.frames;
|
|
47
|
+
const g = ui.reducedMotion ? SPINNER.still[ui.plain ? 1 : 0] : frames[frame % frames.length];
|
|
48
|
+
return <text fg={tone("muted")}>{`${g} ${ui.plain ? ascii(props.label) : props.label}`}</text>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Adapted from anomalyco/opencode packages/tui/src/ui/toast.tsx (MIT, © 2025 opencode);
|
|
2
|
+
// see THIRD_PARTY_NOTICES.md.
|
|
3
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
4
|
+
import { useToasts } from "../providers/toast.tsx";
|
|
5
|
+
import { useUi } from "../providers/theme.tsx";
|
|
6
|
+
import { truncateEnd, wrap } from "../text.ts";
|
|
7
|
+
import { glyph, type Token } from "../theme.ts";
|
|
8
|
+
import { Line } from "./line.tsx";
|
|
9
|
+
|
|
10
|
+
const TONE: Record<string, Token> = { info: "info", success: "success", warning: "warning", error: "error" };
|
|
11
|
+
|
|
12
|
+
/** Lines of a toast's message kept before the rest is cut; a fix command always shows in full. */
|
|
13
|
+
const MESSAGE_LINES = 3;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Spec §9.3: the current toast at the top right, with a `┃` bar in its colour and the queue's length.
|
|
17
|
+
* The message wraps to three lines; a fix command wraps in full on muted lines under it.
|
|
18
|
+
*/
|
|
19
|
+
export function ToastHost() {
|
|
20
|
+
const { current, queued } = useToasts();
|
|
21
|
+
const ui = useUi();
|
|
22
|
+
const dims = useTerminalDimensions();
|
|
23
|
+
if (!current) return null;
|
|
24
|
+
const bar = glyph("bar", ui.plain);
|
|
25
|
+
const max = Math.max(1, Math.min(60, dims.width - 6) - 4);
|
|
26
|
+
const said = wrap(`${current.message}${queued ? ` +${queued} more` : ""}`, max);
|
|
27
|
+
const message = said.slice(0, MESSAGE_LINES);
|
|
28
|
+
if (said.length > MESSAGE_LINES)
|
|
29
|
+
message[MESSAGE_LINES - 1] = truncateEnd(
|
|
30
|
+
`${message[MESSAGE_LINES - 1]} ${said[MESSAGE_LINES]}`,
|
|
31
|
+
max,
|
|
32
|
+
ui.plain,
|
|
33
|
+
);
|
|
34
|
+
const fix = current.fix ? wrap(current.fix, max) : [];
|
|
35
|
+
const text = Math.max(...[...message, ...fix].map((l) => Bun.stringWidth(l)));
|
|
36
|
+
const w = text + 4;
|
|
37
|
+
const line = (t: string, muted: boolean, i: number) => (
|
|
38
|
+
<Line
|
|
39
|
+
key={i}
|
|
40
|
+
width={w}
|
|
41
|
+
opaque
|
|
42
|
+
parts={[
|
|
43
|
+
{ text: `${bar} `, tone: TONE[current.variant] },
|
|
44
|
+
{ text: t.padEnd(text + t.length - Bun.stringWidth(t)), tone: muted ? "muted" : undefined },
|
|
45
|
+
{ text: ` ${bar}`, tone: TONE[current.variant] },
|
|
46
|
+
]}
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
return (
|
|
50
|
+
<box
|
|
51
|
+
position="absolute"
|
|
52
|
+
top={1}
|
|
53
|
+
left={Math.max(0, dims.width - w - 2)}
|
|
54
|
+
zIndex={4000}
|
|
55
|
+
width={w}
|
|
56
|
+
height={message.length + fix.length}
|
|
57
|
+
flexDirection="column"
|
|
58
|
+
>
|
|
59
|
+
{[...message.map((t, i) => line(t, false, i)), ...fix.map((t, i) => line(t, true, message.length + i))]}
|
|
60
|
+
</box>
|
|
61
|
+
);
|
|
62
|
+
}
|