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,139 @@
|
|
|
1
|
+
import type { State } from "../glyphs.ts";
|
|
2
|
+
|
|
3
|
+
/** Spec §9.3: the 16 semantic tokens every widget paints with. */
|
|
4
|
+
export const TOKENS = [
|
|
5
|
+
"text",
|
|
6
|
+
"muted",
|
|
7
|
+
"accent",
|
|
8
|
+
"accentText",
|
|
9
|
+
"surface",
|
|
10
|
+
"surfaceRaised",
|
|
11
|
+
"backdrop",
|
|
12
|
+
"border",
|
|
13
|
+
"success",
|
|
14
|
+
"warning",
|
|
15
|
+
"error",
|
|
16
|
+
"info",
|
|
17
|
+
"selection",
|
|
18
|
+
"selectionText",
|
|
19
|
+
"diffAdd",
|
|
20
|
+
"diffDel",
|
|
21
|
+
] as const;
|
|
22
|
+
export type Token = (typeof TOKENS)[number];
|
|
23
|
+
export type Palette = Record<Token, string>;
|
|
24
|
+
export type ThemeMode = "dark" | "light";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Accent ginger `#E8833A` on dark and `#B45309` on light (spec §9.3); every colour that carries text
|
|
28
|
+
* keeps 4.5:1 against both surfaces (test/entry/tui/theme.test.ts). The screen itself keeps the
|
|
29
|
+
* terminal's own background; `surface` is only what text on the accent fill contrasts with.
|
|
30
|
+
*/
|
|
31
|
+
export const PALETTES: Record<ThemeMode, Palette> = {
|
|
32
|
+
dark: {
|
|
33
|
+
text: "#EEEEEE",
|
|
34
|
+
muted: "#8A8A8A",
|
|
35
|
+
accent: "#E8833A",
|
|
36
|
+
accentText: "#0A0A0A",
|
|
37
|
+
surface: "#0A0A0A",
|
|
38
|
+
surfaceRaised: "#1E1E1E",
|
|
39
|
+
backdrop: "#000000",
|
|
40
|
+
border: "#484848",
|
|
41
|
+
success: "#7FD88F",
|
|
42
|
+
warning: "#F5A742",
|
|
43
|
+
error: "#E06C75",
|
|
44
|
+
info: "#56B6C2",
|
|
45
|
+
selection: "#E8833A",
|
|
46
|
+
selectionText: "#0A0A0A",
|
|
47
|
+
diffAdd: "#4FD6BE",
|
|
48
|
+
diffDel: "#E06C75",
|
|
49
|
+
},
|
|
50
|
+
light: {
|
|
51
|
+
text: "#1A1A1A",
|
|
52
|
+
muted: "#6B6B6B",
|
|
53
|
+
accent: "#B45309",
|
|
54
|
+
accentText: "#FFFFFF",
|
|
55
|
+
surface: "#FFFFFF",
|
|
56
|
+
surfaceRaised: "#F7F7F7",
|
|
57
|
+
backdrop: "#000000",
|
|
58
|
+
border: "#B8B8B8",
|
|
59
|
+
success: "#2F7D46",
|
|
60
|
+
warning: "#8A6116",
|
|
61
|
+
error: "#C22D33",
|
|
62
|
+
info: "#256F7B",
|
|
63
|
+
selection: "#B45309",
|
|
64
|
+
selectionText: "#FFFFFF",
|
|
65
|
+
diffAdd: "#1E725C",
|
|
66
|
+
diffDel: "#B42A3E",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** How this terminal is drawn: `plain` is ASCII only, `color` false paints nothing (NO_COLOR). */
|
|
71
|
+
export interface Ui {
|
|
72
|
+
plain: boolean;
|
|
73
|
+
color: boolean;
|
|
74
|
+
reducedMotion: boolean;
|
|
75
|
+
mode: ThemeMode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Env = Record<string, string | undefined>;
|
|
79
|
+
|
|
80
|
+
/** The first locale variable that is set decides; with none set the terminal is taken to speak UTF-8. */
|
|
81
|
+
function utf8(env: Env): boolean {
|
|
82
|
+
const locale = env.LC_ALL || env.LC_CTYPE || env.LANG;
|
|
83
|
+
return !locale || /utf-?8/i.test(locale);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Spec §9.3: `NO_COLOR` and `--plain` (ASCII glyphs, no colour). Reads only `rawArgs` and `env`, never
|
|
88
|
+
* the terminal, so it answers the same on every machine. OpenTUI downsamples true colour itself.
|
|
89
|
+
*/
|
|
90
|
+
export function detectUi(rawArgs: readonly string[], env: Env): Omit<Ui, "mode"> {
|
|
91
|
+
const dumb = env.TERM === "dumb";
|
|
92
|
+
const plain = rawArgs.includes("--plain") || env.TERM === "linux" || dumb || !utf8(env);
|
|
93
|
+
return {
|
|
94
|
+
plain,
|
|
95
|
+
color: !rawArgs.includes("--plain") && !dumb && !env.NO_COLOR,
|
|
96
|
+
reducedMotion: rawArgs.includes("--reduced-motion") || Boolean(env.CATHERD_REDUCED_MOTION),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A token's colour, or undefined when the terminal gets no colour. */
|
|
101
|
+
export const paint = (ui: Ui, token: Token): string | undefined =>
|
|
102
|
+
ui.color ? PALETTES[ui.mode][token] : undefined;
|
|
103
|
+
|
|
104
|
+
export { glyph, GLYPH_NAMES, type Glyph, type State } from "../glyphs.ts";
|
|
105
|
+
|
|
106
|
+
/** Each state's colour token; the word travels with it. */
|
|
107
|
+
export const STATE_TOKEN: Record<State, Token> = {
|
|
108
|
+
ok: "success",
|
|
109
|
+
warn: "warning",
|
|
110
|
+
fail: "error",
|
|
111
|
+
skip: "muted",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type Mood = "good" | "working" | "waiting" | "failed";
|
|
115
|
+
export const FACES: Record<Mood, string> = {
|
|
116
|
+
good: "=^.^=",
|
|
117
|
+
working: "=o.o=",
|
|
118
|
+
waiting: "=-.-=",
|
|
119
|
+
failed: "=x.x=",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** The conductor, for the init welcome and empty states only (spec §9.3). ASCII already. */
|
|
123
|
+
export function mascot(mood: Mood): [string, string, string] {
|
|
124
|
+
return [" /\\_/\\ .", `(${FACES[mood]})/`, ' (")(")'].map((l) => l.padEnd(9)) as [string, string, string];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Spec §9.3 motion: a braille spinner at 80 ms, shown after 500 ms and kept at least 3 s. */
|
|
128
|
+
export const SPINNER = {
|
|
129
|
+
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
130
|
+
plainFrames: ["|", "/", "-", "\\"],
|
|
131
|
+
still: ["⋯", "..."],
|
|
132
|
+
ms: 80,
|
|
133
|
+
showAfterMs: 500,
|
|
134
|
+
minShowMs: 3_000,
|
|
135
|
+
} as const;
|
|
136
|
+
|
|
137
|
+
/** Spec §9.3: dialogs are 60, 88 or 116 columns, clamped to the terminal. */
|
|
138
|
+
export const DIALOG_WIDTH = { medium: 60, large: 88, xlarge: 116 } as const;
|
|
139
|
+
export type DialogSize = keyof typeof DIALOG_WIDTH;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
2
|
+
import { type CommandId, formatKeys } from "../commands.ts";
|
|
3
|
+
import { hereWord } from "../effects.ts";
|
|
4
|
+
import { useApp, useDialogHandler } from "../providers/app.tsx";
|
|
5
|
+
import { DataProvider, useData } from "../providers/data.tsx";
|
|
6
|
+
import { reachableCommands, useCommandLayer, useKeybinds, useKeymap } from "../providers/keymap.tsx";
|
|
7
|
+
import { useUi } from "../providers/theme.tsx";
|
|
8
|
+
import { currentDraft, isArmed, isSaving, TABS, totalDirty } from "../state.ts";
|
|
9
|
+
import type { Mood } from "../theme.ts";
|
|
10
|
+
import { Footer, Header, Tabs } from "../widgets/chrome.tsx";
|
|
11
|
+
import { plural } from "../text.ts";
|
|
12
|
+
import { Rule } from "../widgets/line.tsx";
|
|
13
|
+
import { Spinner, useDelayedPresence } from "../widgets/spinner.tsx";
|
|
14
|
+
import { ToastHost } from "../widgets/toast.tsx";
|
|
15
|
+
import { Behind, DialogHost } from "./dialogs.tsx";
|
|
16
|
+
import { playStory, STORIES } from "../stories.ts";
|
|
17
|
+
import { openNewProfile, openProfileList, useProfileDialogs } from "./profile-actions.ts";
|
|
18
|
+
import { ProfilesView } from "./profiles.tsx";
|
|
19
|
+
import { RunsView } from "./runs.tsx";
|
|
20
|
+
import { StatusView } from "./status.tsx";
|
|
21
|
+
|
|
22
|
+
/** Rows the chrome takes at any size: header, tabs, rule, rule, footer (spec §9.3: 19 of 24 rows left). */
|
|
23
|
+
const CHROME_ROWS = 5;
|
|
24
|
+
|
|
25
|
+
/** The palette's and help's rows, read from the commands a key could reach before the dialog opens. */
|
|
26
|
+
function useCommandDialogs() {
|
|
27
|
+
const app = useApp();
|
|
28
|
+
const keymap = useKeymap();
|
|
29
|
+
const keys = useKeybinds();
|
|
30
|
+
const ui = useUi();
|
|
31
|
+
const open = (kind: "palette" | "help") => {
|
|
32
|
+
const entries = reachableCommands(keymap).filter((c) =>
|
|
33
|
+
kind === "palette" ? c.palette : keys[c.id].length > 0 && !c.id.startsWith("dialog."),
|
|
34
|
+
);
|
|
35
|
+
app.dispatch({
|
|
36
|
+
type: "open",
|
|
37
|
+
dialog: {
|
|
38
|
+
kind: "select",
|
|
39
|
+
purpose: { type: kind },
|
|
40
|
+
title: kind === "palette" ? "Commands" : "Keyboard shortcuts",
|
|
41
|
+
empty: "no commands",
|
|
42
|
+
suggested: kind === "palette" ? entries.filter((c) => c.suggested).map((c) => c.id) : [],
|
|
43
|
+
options: entries.map((c) => ({
|
|
44
|
+
value: c.id,
|
|
45
|
+
title: c.title,
|
|
46
|
+
group: c.group,
|
|
47
|
+
detail: formatKeys(keys[c.id as CommandId], ui.plain),
|
|
48
|
+
...(kind === "palette" && c.cli ? { cli: c.cli } : {}),
|
|
49
|
+
})),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
const run = (_p: unknown, id: string) => {
|
|
54
|
+
app.dispatch({ type: "close" });
|
|
55
|
+
keymap.dispatchCommand(id);
|
|
56
|
+
};
|
|
57
|
+
useDialogHandler("palette", run);
|
|
58
|
+
useDialogHandler("help", run);
|
|
59
|
+
return open;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The storybook's picker (CATHERD_STORY=1): a story plays from a clean state. */
|
|
63
|
+
function useStories(on: boolean) {
|
|
64
|
+
const app = useApp();
|
|
65
|
+
const keymap = useKeymap();
|
|
66
|
+
useDialogHandler("stories", (_p, name) => {
|
|
67
|
+
app.dispatch({ type: "close" });
|
|
68
|
+
const story = STORIES.find((s) => s.name === name);
|
|
69
|
+
if (story) void playStory(story, app, keymap, () => new Promise((r) => app.clock.setTimeout(r, 50)));
|
|
70
|
+
});
|
|
71
|
+
return on
|
|
72
|
+
? () =>
|
|
73
|
+
app.dispatch({
|
|
74
|
+
type: "open",
|
|
75
|
+
dialog: {
|
|
76
|
+
kind: "select",
|
|
77
|
+
purpose: { type: "stories" },
|
|
78
|
+
title: "Stories",
|
|
79
|
+
empty: "no stories",
|
|
80
|
+
options: STORIES.map((s) => ({ value: s.name, title: s.title, detail: s.name })),
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
: null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Screen(props: { story: boolean }) {
|
|
87
|
+
const app = useApp();
|
|
88
|
+
const data = useData();
|
|
89
|
+
const dims = useTerminalDimensions();
|
|
90
|
+
const openCommands = useCommandDialogs();
|
|
91
|
+
const openStories = useStories(props.story);
|
|
92
|
+
useProfileDialogs();
|
|
93
|
+
const { state } = app;
|
|
94
|
+
const dirty = totalDirty(state);
|
|
95
|
+
const quit = () => {
|
|
96
|
+
const dirty = totalDirty(app.getState());
|
|
97
|
+
if (dirty === 0) return app.exit(0);
|
|
98
|
+
app.dispatch({
|
|
99
|
+
type: "open",
|
|
100
|
+
dialog: {
|
|
101
|
+
kind: "confirm",
|
|
102
|
+
purpose: { type: "quit" },
|
|
103
|
+
title: "Quit with unsaved changes?",
|
|
104
|
+
message: [`${plural(dirty, "unsaved change")} will be lost.`],
|
|
105
|
+
yes: "Discard and quit",
|
|
106
|
+
no: "Keep editing",
|
|
107
|
+
destructive: true,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
useDialogHandler("quit", () => app.exit(0));
|
|
112
|
+
const go = (by: number) => {
|
|
113
|
+
const tab = app.getState().tab;
|
|
114
|
+
app.dispatch({
|
|
115
|
+
type: "tab",
|
|
116
|
+
tab: TABS[(TABS.indexOf(tab) + by + TABS.length) % TABS.length] ?? "status",
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
useCommandLayer("global", {
|
|
120
|
+
// spec §9.2 ctrl+c: close a dialog, else clear a text input, else quit when clean; when dirty a second
|
|
121
|
+
// press within 1.5 s discards and exits 130
|
|
122
|
+
"app.interrupt": () => {
|
|
123
|
+
const state = app.getState();
|
|
124
|
+
const dirty = totalDirty(state);
|
|
125
|
+
// a save that is writing is never cut off: nothing closes or quits until it settles
|
|
126
|
+
if (isSaving(state)) return app.toast({ variant: "info", message: "saving… wait for it to finish" });
|
|
127
|
+
if (state.dialogs.length) return app.dispatch({ type: "close" });
|
|
128
|
+
if (app.back("input")) return;
|
|
129
|
+
if (dirty === 0) return app.exit(0);
|
|
130
|
+
const now = app.clock.now();
|
|
131
|
+
if (isArmed(state, "interrupt", "app", now)) return app.exit(130);
|
|
132
|
+
app.dispatch({ type: "arm", what: "interrupt", target: "app", at: now });
|
|
133
|
+
app.toast({
|
|
134
|
+
variant: "warning",
|
|
135
|
+
message: `${plural(dirty, "unsaved change")}: ctrl+c again to discard and quit`,
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
// esc backs out one level and never quits; an armed double press is a level of its own
|
|
139
|
+
"app.back": () => {
|
|
140
|
+
const state = app.getState();
|
|
141
|
+
const a = state.armed;
|
|
142
|
+
if (a) app.dispatch({ type: "disarm" });
|
|
143
|
+
if (a && isArmed(state, a.what, a.target, app.clock.now())) return;
|
|
144
|
+
if (state.dialogs.length) return app.dispatch({ type: "close" });
|
|
145
|
+
app.back();
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
useCommandLayer("app", {
|
|
149
|
+
"app.palette": () => openCommands("palette"),
|
|
150
|
+
"app.help": () => openCommands("help"),
|
|
151
|
+
"app.quit": quit,
|
|
152
|
+
"tab.status": () => app.dispatch({ type: "tab", tab: "status" }),
|
|
153
|
+
"tab.profiles": () => app.dispatch({ type: "tab", tab: "profiles" }),
|
|
154
|
+
"tab.runs": () => app.dispatch({ type: "tab", tab: "runs" }),
|
|
155
|
+
"tab.next": () => go(1),
|
|
156
|
+
"tab.prev": () => go(-1),
|
|
157
|
+
"profile.new": () => openNewProfile(app),
|
|
158
|
+
"profile.list": () => openProfileList(app, data),
|
|
159
|
+
...(openStories ? { "app.stories": openStories } : {}),
|
|
160
|
+
});
|
|
161
|
+
const checking = useDelayedPresence(data.checking);
|
|
162
|
+
const live = (data.runs.value?.rows ?? []).some((r) => r.live > 0);
|
|
163
|
+
const mood: Mood = data.report && !data.report.ready ? "failed" : live ? "working" : "good";
|
|
164
|
+
const draft = currentDraft(state);
|
|
165
|
+
const shown = draft?.name ?? data.profiles.value?.here ?? null;
|
|
166
|
+
const height = Math.max(1, dims.height - CHROME_ROWS);
|
|
167
|
+
const w = dims.width;
|
|
168
|
+
return (
|
|
169
|
+
<box flexDirection="column" width={w} height={dims.height}>
|
|
170
|
+
<box flexDirection="row" width={w} height={1}>
|
|
171
|
+
<Header
|
|
172
|
+
version={app.effects.version}
|
|
173
|
+
profile={shown}
|
|
174
|
+
active={shown !== null && shown === data.profiles.value?.here}
|
|
175
|
+
label={hereWord(data.profiles.value ?? { repo: null })}
|
|
176
|
+
dirty={dirty}
|
|
177
|
+
mood={mood}
|
|
178
|
+
width={checking ? w - 18 : w}
|
|
179
|
+
/>
|
|
180
|
+
{checking ? (
|
|
181
|
+
<box width={18}>
|
|
182
|
+
<Spinner label="checking setup" />
|
|
183
|
+
</box>
|
|
184
|
+
) : null}
|
|
185
|
+
</box>
|
|
186
|
+
<Tabs tab={state.tab} width={w} />
|
|
187
|
+
<Rule width={w} />
|
|
188
|
+
<Behind width={w} height={height}>
|
|
189
|
+
{state.tab === "status" ? <StatusView width={w} height={height} /> : null}
|
|
190
|
+
{state.tab === "profiles" ? <ProfilesView width={w} height={height} /> : null}
|
|
191
|
+
{state.tab === "runs" ? <RunsView width={w} height={height} /> : null}
|
|
192
|
+
</Behind>
|
|
193
|
+
<Rule width={w} />
|
|
194
|
+
<Footer width={w} />
|
|
195
|
+
<DialogHost />
|
|
196
|
+
<ToastHost />
|
|
197
|
+
</box>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Spec §9: one app, three tabs, dialogs and toasts over them. Must sit inside `Providers`. */
|
|
202
|
+
export function App(props: { story?: boolean }) {
|
|
203
|
+
return (
|
|
204
|
+
<DataProvider>
|
|
205
|
+
<Screen story={props.story === true} />
|
|
206
|
+
</DataProvider>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { useApp } from "../providers/app.tsx";
|
|
3
|
+
import { useUi } from "../providers/theme.tsx";
|
|
4
|
+
import { DialogConfirm } from "../widgets/dialog-confirm.tsx";
|
|
5
|
+
import { DialogPrompt } from "../widgets/dialog-prompt.tsx";
|
|
6
|
+
import { DialogSelect } from "../widgets/dialog-select.tsx";
|
|
7
|
+
import { SaveDialog } from "./save-dialog.tsx";
|
|
8
|
+
|
|
9
|
+
/** Draws the top dialog of the stack; a new dialog remounts, so its cursor and filter start fresh. */
|
|
10
|
+
export function DialogHost() {
|
|
11
|
+
const { state } = useApp();
|
|
12
|
+
const top = state.dialogs.at(-1);
|
|
13
|
+
if (!top) return null;
|
|
14
|
+
const key = `${state.dialogs.length}:${top.purpose.type}`;
|
|
15
|
+
switch (top.kind) {
|
|
16
|
+
case "select":
|
|
17
|
+
return <DialogSelect key={key} dialog={top} />;
|
|
18
|
+
case "confirm":
|
|
19
|
+
return <DialogConfirm key={key} dialog={top} />;
|
|
20
|
+
case "prompt":
|
|
21
|
+
return <DialogPrompt key={key} dialog={top} />;
|
|
22
|
+
case "save":
|
|
23
|
+
return <SaveDialog key={key} dialog={top} />;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What an open dialog is drawn over. Without colour nothing can paint the panel opaque (a space
|
|
29
|
+
* with no background shows what is beneath), so there the dialog replaces these lines instead.
|
|
30
|
+
*/
|
|
31
|
+
export function Behind(props: { width: number; height: number; children: ReactNode }) {
|
|
32
|
+
const { state } = useApp();
|
|
33
|
+
const ui = useUi();
|
|
34
|
+
return (
|
|
35
|
+
<box width={props.width} height={props.height}>
|
|
36
|
+
<box width={props.width} height={props.height} visible={!(state.dialogs.length > 0 && !ui.color)}>
|
|
37
|
+
{props.children}
|
|
38
|
+
</box>
|
|
39
|
+
</box>
|
|
40
|
+
);
|
|
41
|
+
}
|