gflows 1.0.0 → 1.0.2
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/CHANGELOG.md +15 -1
- package/package.json +1 -1
- package/src/commands/doctor.ts +45 -21
- package/src/commands/help.ts +11 -5
- package/src/commands/mcp.ts +2 -1
- package/src/commands/schema.ts +2 -1
- package/src/commands/status-lines.ts +92 -0
- package/src/tui/HubHome.tsx +59 -11
- package/src/tui/HubShell.tsx +94 -29
- package/src/tui/flows.tsx +67 -2
- package/src/tui/hub.ts +34 -9
- package/src/tui/panels.tsx +111 -0
- package/src/tui/slash.ts +64 -0
- package/src/tui/views.tsx +182 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,14 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.0.2] - 2026-07-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Hub read-only screens stay in Ink: `/doctor`, `/help`, `/status`, `/config`, `/version` (enter/esc return)
|
|
15
|
+
|
|
16
|
+
## [1.0.1] - 2026-07-25
|
|
17
|
+
|
|
10
18
|
### Added
|
|
11
19
|
|
|
12
20
|
- Fullscreen **Ink** TUI hub (`gflows` on TTY): bordered frame, tips / what’s next, actions, `❯` slash prompt, status bar
|
|
13
21
|
- Slash commands: `/init` `/start` `/sync` `/pr` `/finish` `/doctor` `/help` …
|
|
22
|
+
- In-hub `/` autocomplete (↑↓ select, Tab complete, Enter run)
|
|
14
23
|
- Interactive prompts via **@clack/prompts** (Inquirer removed)
|
|
15
24
|
- Legacy Clack menu fallback when TUI cannot run; `gflows viz` for scrollback panel
|
|
16
25
|
- `gflows init`: optional `package.json` script alias (`--script-alias g`, TTY prompt); docs recommend shell `alias g=gflows` for shortest DX
|
|
17
|
-
- Hub wizards (`/start`, `/finish`, `/sync`) run **inside Ink** (fewer
|
|
26
|
+
- Hub wizards (`/start`, `/finish`, `/sync`, `/list`) run **inside Ink** (fewer drop-outs); only git dispatch leaves the TUI
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Hub no longer exits after dispatch (`stdin.ref` after Ink unmount; alternate screen)
|
|
31
|
+
- `/list` renders a styled in-hub branch view instead of plain stdout under a tall gap
|
|
18
32
|
|
|
19
33
|
## [1.0.0] - 2026-07-24
|
|
20
34
|
|
package/package.json
CHANGED
package/src/commands/doctor.ts
CHANGED
|
@@ -17,34 +17,30 @@ import { hint, success } from "../out.js";
|
|
|
17
17
|
import { readActiveRun } from "../run-state.js";
|
|
18
18
|
import type { ParsedArgs } from "../types.js";
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** One doctor health check row. */
|
|
21
|
+
export interface DoctorCheck {
|
|
21
22
|
ok: boolean;
|
|
22
23
|
name: string;
|
|
23
24
|
detail: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/** Structured doctor report (CLI + hub). */
|
|
28
|
+
export interface DoctorReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
checks: DoctorCheck[];
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
/**
|
|
27
|
-
*
|
|
34
|
+
* Collects repo health checks without printing or exiting.
|
|
28
35
|
*/
|
|
29
|
-
export async function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.log(JSON.stringify({ ok: false, error: String(err) }, null, 2));
|
|
36
|
-
process.exit(2);
|
|
37
|
-
}
|
|
38
|
-
throw err;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const config = resolveConfig(
|
|
42
|
-
repoRoot,
|
|
43
|
-
{ main: args.main, dev: args.dev, remote: args.remote },
|
|
44
|
-
{ verbose: args.verbose },
|
|
45
|
-
);
|
|
36
|
+
export async function collectDoctorReport(
|
|
37
|
+
cwd: string,
|
|
38
|
+
overrides?: { main?: string; dev?: string; remote?: string },
|
|
39
|
+
): Promise<DoctorReport> {
|
|
40
|
+
const repoRoot = await resolveRepoRoot(cwd);
|
|
41
|
+
const config = resolveConfig(repoRoot, overrides ?? {}, {});
|
|
46
42
|
const cfgRead = readConfigFile(repoRoot);
|
|
47
|
-
const checks:
|
|
43
|
+
const checks: DoctorCheck[] = [];
|
|
48
44
|
|
|
49
45
|
checks.push({
|
|
50
46
|
ok: true,
|
|
@@ -121,9 +117,37 @@ export async function run(args: ParsedArgs): Promise<void> {
|
|
|
121
117
|
detail: clean ? "clean" : "dirty (uncommitted changes)",
|
|
122
118
|
});
|
|
123
119
|
|
|
124
|
-
|
|
120
|
+
return { ok: checks.every((c) => c.ok), checks };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Runs health checks and prints a report (or JSON).
|
|
125
|
+
*/
|
|
126
|
+
export async function run(args: ParsedArgs): Promise<void> {
|
|
127
|
+
let report: DoctorReport;
|
|
128
|
+
try {
|
|
129
|
+
report = await collectDoctorReport(args.cwd, {
|
|
130
|
+
main: args.main,
|
|
131
|
+
dev: args.dev,
|
|
132
|
+
remote: args.remote,
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (args.json) {
|
|
136
|
+
console.log(JSON.stringify({ ok: false, error: String(err) }, null, 2));
|
|
137
|
+
process.exit(2);
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const { ok, checks } = report;
|
|
125
143
|
|
|
126
144
|
if (args.json) {
|
|
145
|
+
const repoRoot = await resolveRepoRoot(args.cwd);
|
|
146
|
+
const config = resolveConfig(
|
|
147
|
+
repoRoot,
|
|
148
|
+
{ main: args.main, dev: args.dev, remote: args.remote },
|
|
149
|
+
{ verbose: args.verbose },
|
|
150
|
+
);
|
|
127
151
|
console.log(JSON.stringify({ ok, config, checks }, null, 2));
|
|
128
152
|
if (!ok) process.exit(2);
|
|
129
153
|
return;
|
package/src/commands/help.ts
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import type { ParsedArgs } from "../types.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
9
|
+
* Full help text (CLI stdout + hub scroll panel).
|
|
10
10
|
*/
|
|
11
|
-
export
|
|
12
|
-
|
|
11
|
+
export function getHelpText(): string {
|
|
12
|
+
return `
|
|
13
13
|
gflows — Modern Git branching workflow CLI
|
|
14
14
|
|
|
15
15
|
Usage: gflows <command> [type] [name] [flags]
|
|
@@ -68,6 +68,12 @@ Exit codes: 0 success, 1 usage/validation, 2 Git or system error.
|
|
|
68
68
|
|
|
69
69
|
Stuck? gflows continue | gflows abort | gflows undo | gflows doctor
|
|
70
70
|
Agents: see AGENTS.md, gflows schema, gflows mcp
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
`.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Runs the help command: prints usage, commands, types, flags, and exit codes to stdout.
|
|
76
|
+
*/
|
|
77
|
+
export async function run(_args: ParsedArgs): Promise<void> {
|
|
78
|
+
console.log(getHelpText());
|
|
73
79
|
}
|
package/src/commands/mcp.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { EXIT_OK } from "../constants.js";
|
|
7
7
|
import type { ParsedArgs } from "../types.js";
|
|
8
|
+
import { getVersion } from "../version.js";
|
|
8
9
|
|
|
9
10
|
interface JsonRpcRequest {
|
|
10
11
|
jsonrpc?: string;
|
|
@@ -197,7 +198,7 @@ export async function run(_args: ParsedArgs): Promise<void> {
|
|
|
197
198
|
respond(req.id, {
|
|
198
199
|
protocolVersion: "2024-11-05",
|
|
199
200
|
capabilities: { tools: {} },
|
|
200
|
-
serverInfo: { name: "gflows", version:
|
|
201
|
+
serverInfo: { name: "gflows", version: getVersion() },
|
|
201
202
|
});
|
|
202
203
|
continue;
|
|
203
204
|
}
|
package/src/commands/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ParsedArgs } from "../types.js";
|
|
7
7
|
import { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "../types.js";
|
|
8
|
+
import { getVersion } from "../version.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Emits JSON schema for agents and tooling.
|
|
@@ -12,7 +13,7 @@ import { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "../types.js";
|
|
|
12
13
|
export async function run(_args: ParsedArgs): Promise<void> {
|
|
13
14
|
const schema = {
|
|
14
15
|
name: "gflows",
|
|
15
|
-
version:
|
|
16
|
+
version: getVersion(),
|
|
16
17
|
description: "Git branching workflow CLI (main + dev + typed short-lived branches)",
|
|
17
18
|
exitCodes: {
|
|
18
19
|
"0": "success",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured status lines for CLI-adjacent hub panels.
|
|
3
|
+
* Kept separate so `status.ts` stays focused on CLI printing.
|
|
4
|
+
* @module commands/status-lines
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolveConfig } from "../config.js";
|
|
8
|
+
import {
|
|
9
|
+
classifyBranch,
|
|
10
|
+
formatMergeTarget,
|
|
11
|
+
getBaseBranchName,
|
|
12
|
+
resolveMergeTarget,
|
|
13
|
+
} from "../flow.js";
|
|
14
|
+
import { getAheadBehind, getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
15
|
+
import { readActiveRun } from "../run-state.js";
|
|
16
|
+
|
|
17
|
+
/** One status line with optional tone for the hub panel. */
|
|
18
|
+
export interface StatusLine {
|
|
19
|
+
text: string;
|
|
20
|
+
tone?: "default" | "ok" | "bad" | "muted" | "accent";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Collects human-readable status rows for the current branch.
|
|
25
|
+
*/
|
|
26
|
+
export async function collectStatusLines(cwd: string): Promise<StatusLine[]> {
|
|
27
|
+
const root = await resolveRepoRoot(cwd);
|
|
28
|
+
const config = resolveConfig(root, {}, {});
|
|
29
|
+
const current = await getCurrentBranch(root, {});
|
|
30
|
+
const active = readActiveRun(root);
|
|
31
|
+
const lines: StatusLine[] = [];
|
|
32
|
+
|
|
33
|
+
if (active) {
|
|
34
|
+
lines.push({
|
|
35
|
+
text: `Suspended: ${active.command} (${active.status})`,
|
|
36
|
+
tone: "bad",
|
|
37
|
+
});
|
|
38
|
+
lines.push({
|
|
39
|
+
text: "Run /continue after conflicts, or gflows abort / undo",
|
|
40
|
+
tone: "muted",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (current === null) {
|
|
45
|
+
lines.push({ text: "HEAD is detached.", tone: "bad" });
|
|
46
|
+
lines.push({ text: "Try: git checkout dev", tone: "muted" });
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lines.push({ text: `Branch: ${current}`, tone: "accent" });
|
|
51
|
+
|
|
52
|
+
const classification = classifyBranch(current, config);
|
|
53
|
+
|
|
54
|
+
if (classification === "main") {
|
|
55
|
+
lines.push({ text: "Type: long-lived (main)" });
|
|
56
|
+
lines.push({ text: "Next: /start feature <name> or hotfix", tone: "muted" });
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (classification === "dev") {
|
|
61
|
+
lines.push({ text: "Type: long-lived (dev)" });
|
|
62
|
+
lines.push({ text: "Next: /start feature <name>", tone: "muted" });
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (classification === null) {
|
|
67
|
+
lines.push({ text: "Type: unknown" });
|
|
68
|
+
lines.push({ text: "Use a typed prefix or /start …", tone: "muted" });
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const mergeTarget = await resolveMergeTarget(root, current, classification, config, {});
|
|
73
|
+
const baseBranch = getBaseBranchName(
|
|
74
|
+
classification,
|
|
75
|
+
mergeTarget === "main-then-dev" && classification === "bugfix",
|
|
76
|
+
config,
|
|
77
|
+
);
|
|
78
|
+
const mergeTargetDisplay = formatMergeTarget(mergeTarget, config);
|
|
79
|
+
const { ahead, behind } = await getAheadBehind(root, baseBranch, current, {});
|
|
80
|
+
|
|
81
|
+
lines.push({ text: `Type: ${classification}` });
|
|
82
|
+
lines.push({ text: `Base: ${baseBranch}` });
|
|
83
|
+
lines.push({ text: `Merge target(s): ${mergeTargetDisplay}` });
|
|
84
|
+
lines.push({ text: `Ahead/behind: ${ahead} ahead, ${behind} behind` });
|
|
85
|
+
lines.push({
|
|
86
|
+
text:
|
|
87
|
+
ahead === 0 ? "No commits to finish yet — commit first" : `Next: /finish ${classification}`,
|
|
88
|
+
tone: ahead === 0 ? "muted" : "ok",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return lines;
|
|
92
|
+
}
|
package/src/tui/HubHome.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
|
11
11
|
import { type RecommendAction, recommend } from "../recommend.js";
|
|
12
12
|
import { getVersion } from "../version.js";
|
|
13
13
|
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
14
|
+
import { completeSlashInput, filterSlashCommands } from "./slash.js";
|
|
14
15
|
|
|
15
16
|
const ACCENT = "#E88C4A";
|
|
16
17
|
const MUTED = "#8A8A8A";
|
|
@@ -50,9 +51,12 @@ export function HubHome({
|
|
|
50
51
|
const [repoPath, setRepoPath] = useState(cwd);
|
|
51
52
|
const [selected, setSelected] = useState(0);
|
|
52
53
|
const [input, setInput] = useState("");
|
|
54
|
+
const [slashIndex, setSlashIndex] = useState(0);
|
|
53
55
|
const [showHelp, setShowHelp] = useState(false);
|
|
54
56
|
const [loading, setLoading] = useState(true);
|
|
55
57
|
const version = useMemo(() => getVersion(), []);
|
|
58
|
+
const slashMatches = useMemo(() => filterSlashCommands(input), [input]);
|
|
59
|
+
const slashMode = input.startsWith("/");
|
|
56
60
|
|
|
57
61
|
useEffect(() => {
|
|
58
62
|
let cancelled = false;
|
|
@@ -87,6 +91,17 @@ export function HubHome({
|
|
|
87
91
|
if (selected >= actions.length) setSelected(Math.max(0, actions.length - 1));
|
|
88
92
|
}, [actions.length, selected]);
|
|
89
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (slashIndex >= slashMatches.length) {
|
|
96
|
+
setSlashIndex(Math.max(0, slashMatches.length - 1));
|
|
97
|
+
}
|
|
98
|
+
}, [slashMatches.length, slashIndex]);
|
|
99
|
+
|
|
100
|
+
const setSlashInput = (next: string) => {
|
|
101
|
+
setInput(next);
|
|
102
|
+
setSlashIndex(0);
|
|
103
|
+
};
|
|
104
|
+
|
|
90
105
|
useInput((ch, key) => {
|
|
91
106
|
if (key.ctrl && ch === "c") {
|
|
92
107
|
onQuit();
|
|
@@ -94,7 +109,7 @@ export function HubHome({
|
|
|
94
109
|
}
|
|
95
110
|
if (key.escape) {
|
|
96
111
|
if (input || showHelp) {
|
|
97
|
-
|
|
112
|
+
setSlashInput("");
|
|
98
113
|
setShowHelp(false);
|
|
99
114
|
return;
|
|
100
115
|
}
|
|
@@ -109,23 +124,41 @@ export function HubHome({
|
|
|
109
124
|
onQuit();
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
127
|
+
if (key.tab && slashMode) {
|
|
128
|
+
const next = completeSlashInput(input, slashIndex);
|
|
129
|
+
if (next !== null) setSlashInput(next);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
112
132
|
if (key.upArrow) {
|
|
133
|
+
if (slashMode && slashMatches.length > 0) {
|
|
134
|
+
setSlashIndex((i) => Math.max(0, i - 1));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
113
137
|
setSelected((i) => Math.max(0, i - 1));
|
|
114
138
|
return;
|
|
115
139
|
}
|
|
116
140
|
if (key.downArrow) {
|
|
141
|
+
if (slashMode && slashMatches.length > 0) {
|
|
142
|
+
setSlashIndex((i) => Math.min(slashMatches.length - 1, i + 1));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
117
145
|
setSelected((i) => Math.min(actions.length - 1, i + 1));
|
|
118
146
|
return;
|
|
119
147
|
}
|
|
120
148
|
if (key.backspace || key.delete) {
|
|
121
|
-
|
|
149
|
+
setSlashInput([...input].slice(0, -1).join(""));
|
|
122
150
|
return;
|
|
123
151
|
}
|
|
124
152
|
if (key.return) {
|
|
125
153
|
const cmd = input.trim();
|
|
126
154
|
if (cmd.startsWith("/")) {
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
const match = slashMatches[slashIndex];
|
|
156
|
+
const body = cmd.slice(1);
|
|
157
|
+
const token = (body.split(/\s+/)[0] ?? "").toLowerCase();
|
|
158
|
+
const hasArgs = body.includes(" ");
|
|
159
|
+
const resolved = match && !hasArgs && token !== match.name ? `/${match.name}` : cmd;
|
|
160
|
+
setSlashInput("");
|
|
161
|
+
onSlash(resolved);
|
|
129
162
|
return;
|
|
130
163
|
}
|
|
131
164
|
const action = actions[selected];
|
|
@@ -137,7 +170,7 @@ export function HubHome({
|
|
|
137
170
|
return;
|
|
138
171
|
}
|
|
139
172
|
if (ch && !key.ctrl && !key.meta && ch >= " ") {
|
|
140
|
-
|
|
173
|
+
setSlashInput(input + ch);
|
|
141
174
|
}
|
|
142
175
|
});
|
|
143
176
|
|
|
@@ -237,14 +270,29 @@ export function HubHome({
|
|
|
237
270
|
<Box marginTop={1} flexDirection="column">
|
|
238
271
|
<Text>
|
|
239
272
|
<Text color={ACCENT}>❯ </Text>
|
|
240
|
-
{input || <Text color={MUTED}>type /
|
|
273
|
+
{input || <Text color={MUTED}>type / for commands…</Text>}
|
|
241
274
|
{input ? <Text color={ACCENT}>█</Text> : null}
|
|
242
275
|
</Text>
|
|
243
|
-
|
|
244
|
-
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
276
|
+
{slashMode && slashMatches.length > 0 ? (
|
|
277
|
+
<Box flexDirection="column" marginTop={0}>
|
|
278
|
+
{slashMatches.slice(0, 8).map((item, i) => {
|
|
279
|
+
const active = i === slashIndex;
|
|
280
|
+
return (
|
|
281
|
+
<Text key={item.name} color={active ? FG : MUTED} bold={active}>
|
|
282
|
+
{active ? <Text color={ACCENT}>› </Text> : " "}/{item.name}
|
|
283
|
+
<Text color={MUTED}>{` ${item.hint}`}</Text>
|
|
284
|
+
</Text>
|
|
285
|
+
);
|
|
286
|
+
})}
|
|
287
|
+
<Text color={MUTED}>↑↓ · tab complete · enter run · esc clear</Text>
|
|
288
|
+
</Box>
|
|
289
|
+
) : (
|
|
290
|
+
<Text color={MUTED}>
|
|
291
|
+
{showHelp
|
|
292
|
+
? "/init /start /sync /pr /finish /doctor /help · esc clear · ctrl+c quit"
|
|
293
|
+
: "? for shortcuts · / for command menu"}
|
|
294
|
+
</Text>
|
|
295
|
+
)}
|
|
248
296
|
</Box>
|
|
249
297
|
</Box>
|
|
250
298
|
|
package/src/tui/HubShell.tsx
CHANGED
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
import { Box, Text, useApp, useInput } from "ink";
|
|
7
7
|
import type React from "react";
|
|
8
8
|
import { useState } from "react";
|
|
9
|
-
import { FinishFlow, StartFlow, SyncFlow } from "./flows.js";
|
|
9
|
+
import { FinishFlow, ListFlow, StartFlow, SyncFlow } from "./flows.js";
|
|
10
10
|
import { HubHome } from "./HubHome.js";
|
|
11
11
|
import { WizardFrame } from "./prompts.js";
|
|
12
|
+
import { SLASH_COMMANDS } from "./slash.js";
|
|
13
|
+
import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
|
|
12
14
|
|
|
13
15
|
const ACCENT = "#E88C4A";
|
|
14
16
|
const MUTED = "#8A8A8A";
|
|
@@ -22,8 +24,29 @@ type Screen =
|
|
|
22
24
|
| { id: "start" }
|
|
23
25
|
| { id: "finish" }
|
|
24
26
|
| { id: "sync" }
|
|
27
|
+
| { id: "list" }
|
|
28
|
+
| { id: "doctor" }
|
|
29
|
+
| { id: "help" }
|
|
30
|
+
| { id: "status" }
|
|
31
|
+
| { id: "config" }
|
|
32
|
+
| { id: "version" }
|
|
25
33
|
| { id: "notice"; message: string };
|
|
26
34
|
|
|
35
|
+
/** Commands that leave Ink for git / side effects. */
|
|
36
|
+
const DISPATCH_COMMANDS = new Set([
|
|
37
|
+
"init",
|
|
38
|
+
"pr",
|
|
39
|
+
"continue",
|
|
40
|
+
"switch",
|
|
41
|
+
"bump",
|
|
42
|
+
"delete",
|
|
43
|
+
"undo",
|
|
44
|
+
"abort",
|
|
45
|
+
"schema",
|
|
46
|
+
"mcp",
|
|
47
|
+
"completion",
|
|
48
|
+
]);
|
|
49
|
+
|
|
27
50
|
/**
|
|
28
51
|
* Props for the hub shell.
|
|
29
52
|
*/
|
|
@@ -47,6 +70,43 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
47
70
|
const cancelWizard = () => setScreen({ id: "home" });
|
|
48
71
|
const runArgv = (argv: string[]) => finish({ kind: "run", argv });
|
|
49
72
|
|
|
73
|
+
const openInHub = (cmd: string): boolean => {
|
|
74
|
+
switch (cmd) {
|
|
75
|
+
case "start":
|
|
76
|
+
setScreen({ id: "start" });
|
|
77
|
+
return true;
|
|
78
|
+
case "finish":
|
|
79
|
+
setScreen({ id: "finish" });
|
|
80
|
+
return true;
|
|
81
|
+
case "sync":
|
|
82
|
+
setScreen({ id: "sync" });
|
|
83
|
+
return true;
|
|
84
|
+
case "list":
|
|
85
|
+
setScreen({ id: "list" });
|
|
86
|
+
return true;
|
|
87
|
+
case "doctor":
|
|
88
|
+
setScreen({ id: "doctor" });
|
|
89
|
+
return true;
|
|
90
|
+
case "help":
|
|
91
|
+
setScreen({ id: "help" });
|
|
92
|
+
return true;
|
|
93
|
+
case "status":
|
|
94
|
+
setScreen({ id: "status" });
|
|
95
|
+
return true;
|
|
96
|
+
case "config":
|
|
97
|
+
setScreen({ id: "config" });
|
|
98
|
+
return true;
|
|
99
|
+
case "version":
|
|
100
|
+
setScreen({ id: "version" });
|
|
101
|
+
return true;
|
|
102
|
+
case "viz":
|
|
103
|
+
setScreen({ id: "home", flash: "Branch map is shown on this screen." });
|
|
104
|
+
return true;
|
|
105
|
+
default:
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
50
110
|
const handleSlash = (raw: string) => {
|
|
51
111
|
const parts = raw.slice(1).trim().split(/\s+/);
|
|
52
112
|
const cmd = (parts[0] ?? "").toLowerCase();
|
|
@@ -83,27 +143,25 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
83
143
|
setScreen({ id: "sync" });
|
|
84
144
|
return;
|
|
85
145
|
}
|
|
86
|
-
if (cmd === "
|
|
87
|
-
|
|
146
|
+
if (cmd === "config" && rest.length > 0) {
|
|
147
|
+
runArgv(["config", ...rest]);
|
|
88
148
|
return;
|
|
89
149
|
}
|
|
90
150
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
"continue",
|
|
95
|
-
"switch",
|
|
96
|
-
"list",
|
|
97
|
-
"doctor",
|
|
98
|
-
"help",
|
|
99
|
-
"config",
|
|
100
|
-
"bump",
|
|
101
|
-
"status",
|
|
102
|
-
]);
|
|
103
|
-
if (!known.has(cmd)) {
|
|
151
|
+
if (openInHub(cmd)) return;
|
|
152
|
+
|
|
153
|
+
if (!DISPATCH_COMMANDS.has(cmd) && !SLASH_COMMANDS.some((c) => c.name === cmd)) {
|
|
104
154
|
setScreen({
|
|
105
155
|
id: "notice",
|
|
106
|
-
message: `Unknown /${cmd} —
|
|
156
|
+
message: `Unknown /${cmd} — type / for command hints.`,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!DISPATCH_COMMANDS.has(cmd)) {
|
|
162
|
+
setScreen({
|
|
163
|
+
id: "notice",
|
|
164
|
+
message: `/${cmd} is not available from the hub yet.`,
|
|
107
165
|
});
|
|
108
166
|
return;
|
|
109
167
|
}
|
|
@@ -116,18 +174,7 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
116
174
|
finish({ kind: "quit" });
|
|
117
175
|
return;
|
|
118
176
|
}
|
|
119
|
-
if (action
|
|
120
|
-
setScreen({ id: "start" });
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (action === "finish") {
|
|
124
|
-
setScreen({ id: "finish" });
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
if (action === "sync") {
|
|
128
|
-
setScreen({ id: "sync" });
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
177
|
+
if (openInHub(action)) return;
|
|
131
178
|
runArgv([action]);
|
|
132
179
|
};
|
|
133
180
|
|
|
@@ -140,6 +187,24 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
|
|
|
140
187
|
if (screen.id === "sync") {
|
|
141
188
|
return <SyncFlow onCancel={cancelWizard} onDone={runArgv} />;
|
|
142
189
|
}
|
|
190
|
+
if (screen.id === "list") {
|
|
191
|
+
return <ListFlow cwd={cwd} onDone={cancelWizard} />;
|
|
192
|
+
}
|
|
193
|
+
if (screen.id === "doctor") {
|
|
194
|
+
return <DoctorView cwd={cwd} onDone={cancelWizard} />;
|
|
195
|
+
}
|
|
196
|
+
if (screen.id === "help") {
|
|
197
|
+
return <HelpView onDone={cancelWizard} />;
|
|
198
|
+
}
|
|
199
|
+
if (screen.id === "status") {
|
|
200
|
+
return <StatusView cwd={cwd} onDone={cancelWizard} />;
|
|
201
|
+
}
|
|
202
|
+
if (screen.id === "config") {
|
|
203
|
+
return <ConfigView cwd={cwd} onDone={cancelWizard} />;
|
|
204
|
+
}
|
|
205
|
+
if (screen.id === "version") {
|
|
206
|
+
return <VersionView onDone={cancelWizard} />;
|
|
207
|
+
}
|
|
143
208
|
if (screen.id === "notice") {
|
|
144
209
|
return (
|
|
145
210
|
<PressEnter
|
package/src/tui/flows.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Multi-step Ink wizards for hub actions (start / finish / sync).
|
|
2
|
+
* Multi-step Ink wizards for hub actions (start / finish / sync / list).
|
|
3
3
|
* @module tui/flows
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type React from "react";
|
|
7
|
-
import { useState } from "react";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
8
|
import type { BranchType } from "../types.js";
|
|
9
|
+
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
10
|
+
import { type HubPanelLine, HubScrollPanel } from "./panels.js";
|
|
9
11
|
import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
|
|
10
12
|
|
|
11
13
|
const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
|
|
@@ -138,3 +140,66 @@ export function SyncFlow({
|
|
|
138
140
|
</WizardFrame>
|
|
139
141
|
);
|
|
140
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Styled branch list inside the hub (no drop-out to plain stdout).
|
|
146
|
+
*/
|
|
147
|
+
export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
|
|
148
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
149
|
+
const [error, setError] = useState<string | null>(null);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
let cancelled = false;
|
|
153
|
+
void (async () => {
|
|
154
|
+
try {
|
|
155
|
+
const snap = await collectVizSnapshot(cwd);
|
|
156
|
+
if (!cancelled) {
|
|
157
|
+
setLines(
|
|
158
|
+
formatListLines(snap).map((text, i) => ({
|
|
159
|
+
id: `b${i}`,
|
|
160
|
+
text,
|
|
161
|
+
tone: text.includes("●") ? ("ok" as const) : ("default" as const),
|
|
162
|
+
})),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (!cancelled) {
|
|
167
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
168
|
+
setLines([]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
return () => {
|
|
173
|
+
cancelled = true;
|
|
174
|
+
};
|
|
175
|
+
}, [cwd]);
|
|
176
|
+
|
|
177
|
+
return <HubScrollPanel title="Branches" lines={lines} error={error} onDone={onDone} />;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Formats a viz snapshot into hub list rows.
|
|
182
|
+
*/
|
|
183
|
+
function formatListLines(snap: VizSnapshot): string[] {
|
|
184
|
+
const lines: string[] = [];
|
|
185
|
+
if (snap.suspended) lines.push(`⚠ suspended: ${snap.suspended}`);
|
|
186
|
+
for (const row of snap.rows) {
|
|
187
|
+
if (row.type !== "main" && row.type !== "dev") continue;
|
|
188
|
+
const mark = row.current ? "●" : "○";
|
|
189
|
+
lines.push(`${mark} ${row.name} [${row.type}]`);
|
|
190
|
+
}
|
|
191
|
+
const workflow = snap.rows.filter((r) => r.type !== "main" && r.type !== "dev");
|
|
192
|
+
if (workflow.length === 0) {
|
|
193
|
+
lines.push(" (no workflow branches)");
|
|
194
|
+
} else {
|
|
195
|
+
for (let i = 0; i < workflow.length; i++) {
|
|
196
|
+
const row = workflow[i];
|
|
197
|
+
if (!row) continue;
|
|
198
|
+
const elbow = i === workflow.length - 1 ? "└─" : "├─";
|
|
199
|
+
const mark = row.current ? "●" : "○";
|
|
200
|
+
const ab = row.ahead === 0 && row.behind === 0 ? "synced" : `+${row.ahead}/-${row.behind}`;
|
|
201
|
+
lines.push(`${elbow} ${mark} ${row.name} (${row.type}) ${ab}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return lines;
|
|
205
|
+
}
|
package/src/tui/hub.ts
CHANGED
|
@@ -57,6 +57,12 @@ function runHubSession(cwd: string): Promise<HubSessionResult> {
|
|
|
57
57
|
instance.unmount();
|
|
58
58
|
},
|
|
59
59
|
}),
|
|
60
|
+
{
|
|
61
|
+
// Keep hub off the primary scrollback so command output isn't stranded
|
|
62
|
+
// under a full-height cleared frame when we drop out for dispatch.
|
|
63
|
+
alternateScreen: true,
|
|
64
|
+
exitOnCtrlC: false,
|
|
65
|
+
},
|
|
60
66
|
);
|
|
61
67
|
void instance.waitUntilExit().then(() => {
|
|
62
68
|
done({ kind: "quit" });
|
|
@@ -66,24 +72,43 @@ function runHubSession(cwd: string): Promise<HubSessionResult> {
|
|
|
66
72
|
|
|
67
73
|
/**
|
|
68
74
|
* Raw stdin “press enter” (no Clack / no Ink).
|
|
75
|
+
* Ink unrefs stdin on unmount — we must ref it again or the process exits
|
|
76
|
+
* before the user can return to the hub.
|
|
69
77
|
*/
|
|
70
78
|
function waitEnterRaw(label: string): Promise<void> {
|
|
71
79
|
return new Promise((resolve) => {
|
|
80
|
+
const stdin = process.stdin;
|
|
72
81
|
process.stdout.write(`${label}\n`);
|
|
73
|
-
if (typeof
|
|
74
|
-
|
|
82
|
+
if (typeof stdin.setRawMode === "function") {
|
|
83
|
+
stdin.setRawMode(true);
|
|
75
84
|
}
|
|
76
|
-
|
|
85
|
+
stdin.resume();
|
|
86
|
+
stdin.ref();
|
|
87
|
+
|
|
88
|
+
// Drop buffered Enter from the hub key that launched this command.
|
|
89
|
+
if (typeof stdin.read === "function") {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const pending = stdin.read();
|
|
92
|
+
if (pending === null) break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
stdin.off("data", onData);
|
|
98
|
+
if (typeof stdin.setRawMode === "function") {
|
|
99
|
+
stdin.setRawMode(false);
|
|
100
|
+
}
|
|
101
|
+
stdin.pause();
|
|
102
|
+
stdin.unref();
|
|
103
|
+
};
|
|
104
|
+
|
|
77
105
|
const onData = (chunk: string | Buffer) => {
|
|
78
106
|
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
79
|
-
if (s
|
|
80
|
-
|
|
81
|
-
if (typeof process.stdin.setRawMode === "function") {
|
|
82
|
-
process.stdin.setRawMode(false);
|
|
83
|
-
}
|
|
107
|
+
if (s.includes("\r") || s.includes("\n") || s.includes("\x03")) {
|
|
108
|
+
cleanup();
|
|
84
109
|
resolve();
|
|
85
110
|
}
|
|
86
111
|
};
|
|
87
|
-
|
|
112
|
+
stdin.on("data", onData);
|
|
88
113
|
});
|
|
89
114
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Ink panels for in-hub result screens (doctor / help / status / …).
|
|
3
|
+
* @module tui/panels
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { WizardFrame } from "./prompts.js";
|
|
10
|
+
|
|
11
|
+
const ACCENT = "#E88C4A";
|
|
12
|
+
const MUTED = "#8A8A8A";
|
|
13
|
+
const FG = "#E6E6E6";
|
|
14
|
+
const GREEN = "#78C88C";
|
|
15
|
+
const RED = "#E06C75";
|
|
16
|
+
|
|
17
|
+
/** One styled line for {@link HubScrollPanel}. */
|
|
18
|
+
export interface HubPanelLine {
|
|
19
|
+
/** Stable React key for the row. */
|
|
20
|
+
id: string;
|
|
21
|
+
text: string;
|
|
22
|
+
tone?: "default" | "ok" | "bad" | "muted" | "accent";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scrollable framed panel that returns to the hub on enter/esc.
|
|
27
|
+
*/
|
|
28
|
+
export function HubScrollPanel({
|
|
29
|
+
title,
|
|
30
|
+
lines,
|
|
31
|
+
loading,
|
|
32
|
+
error,
|
|
33
|
+
onDone,
|
|
34
|
+
}: {
|
|
35
|
+
title: string;
|
|
36
|
+
lines: HubPanelLine[] | null;
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
error?: string | null;
|
|
39
|
+
onDone: () => void;
|
|
40
|
+
}): React.ReactElement {
|
|
41
|
+
const { stdout } = useStdout();
|
|
42
|
+
const rows = stdout?.rows ?? 24;
|
|
43
|
+
const lineCount = lines?.length ?? 0;
|
|
44
|
+
const budget = Math.max(6, Math.min(lineCount || 6, rows - 8));
|
|
45
|
+
const [offset, setOffset] = useState(0);
|
|
46
|
+
const maxOffset = Math.max(0, lineCount - budget);
|
|
47
|
+
const safeOffset = Math.min(offset, maxOffset);
|
|
48
|
+
const visible = lines?.slice(safeOffset, safeOffset + budget) ?? [];
|
|
49
|
+
|
|
50
|
+
useInput((ch, key) => {
|
|
51
|
+
if (key.return || key.escape || (key.ctrl && ch === "c")) {
|
|
52
|
+
onDone();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (key.upArrow || ch === "k") {
|
|
56
|
+
setOffset((o) => Math.max(0, o - 1));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (key.downArrow || ch === "j") {
|
|
60
|
+
setOffset((o) => Math.min(maxOffset, o + 1));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (key.pageUp) {
|
|
64
|
+
setOffset((o) => Math.max(0, o - budget));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (key.pageDown) {
|
|
68
|
+
setOffset((o) => Math.min(maxOffset, o + budget));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<WizardFrame title={title}>
|
|
74
|
+
{loading || lines === null ? (
|
|
75
|
+
<Text color={MUTED}>Loading…</Text>
|
|
76
|
+
) : error ? (
|
|
77
|
+
<Text color={RED}>{error}</Text>
|
|
78
|
+
) : (
|
|
79
|
+
<Box flexDirection="column">
|
|
80
|
+
{visible.map((line) => (
|
|
81
|
+
<Text key={line.id} color={toneColor(line.tone)} bold={line.tone === "ok"}>
|
|
82
|
+
{line.text}
|
|
83
|
+
</Text>
|
|
84
|
+
))}
|
|
85
|
+
</Box>
|
|
86
|
+
)}
|
|
87
|
+
<Box marginTop={1}>
|
|
88
|
+
<Text color={MUTED}>
|
|
89
|
+
{maxOffset > 0 ? "↑↓ scroll · " : ""}
|
|
90
|
+
enter / esc return to hub
|
|
91
|
+
</Text>
|
|
92
|
+
<Text color={ACCENT}> █</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
</WizardFrame>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function toneColor(tone: HubPanelLine["tone"]): string {
|
|
99
|
+
switch (tone) {
|
|
100
|
+
case "ok":
|
|
101
|
+
return GREEN;
|
|
102
|
+
case "bad":
|
|
103
|
+
return RED;
|
|
104
|
+
case "muted":
|
|
105
|
+
return MUTED;
|
|
106
|
+
case "accent":
|
|
107
|
+
return ACCENT;
|
|
108
|
+
default:
|
|
109
|
+
return FG;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/tui/slash.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command catalog for the Ink hub prompt.
|
|
3
|
+
* @module tui/slash
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** One slash command shown in hub autocomplete. */
|
|
7
|
+
export interface SlashCommand {
|
|
8
|
+
/** Command name without leading `/`. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** Short description for the suggestion list. */
|
|
11
|
+
hint: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hub slash commands (order = suggestion priority).
|
|
16
|
+
*/
|
|
17
|
+
export const SLASH_COMMANDS: readonly SlashCommand[] = [
|
|
18
|
+
{ name: "init", hint: "Initialize repo" },
|
|
19
|
+
{ name: "start", hint: "Start new work (wizard)" },
|
|
20
|
+
{ name: "sync", hint: "Sync with base (wizard)" },
|
|
21
|
+
{ name: "pr", hint: "Open pull request" },
|
|
22
|
+
{ name: "finish", hint: "Finish / merge (wizard)" },
|
|
23
|
+
{ name: "continue", hint: "Continue suspended run" },
|
|
24
|
+
{ name: "switch", hint: "Switch branch" },
|
|
25
|
+
{ name: "list", hint: "List branches" },
|
|
26
|
+
{ name: "doctor", hint: "Doctor checks" },
|
|
27
|
+
{ name: "help", hint: "Show help" },
|
|
28
|
+
{ name: "status", hint: "Repo status" },
|
|
29
|
+
{ name: "config", hint: "Show config" },
|
|
30
|
+
{ name: "version", hint: "Show version" },
|
|
31
|
+
{ name: "bump", hint: "Bump version" },
|
|
32
|
+
{ name: "viz", hint: "Branch map (on home)" },
|
|
33
|
+
{ name: "quit", hint: "Leave hub" },
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Filters slash commands by the typed buffer (e.g. `/st` → start, status, switch).
|
|
38
|
+
*/
|
|
39
|
+
export function filterSlashCommands(input: string): SlashCommand[] {
|
|
40
|
+
if (!input.startsWith("/")) return [];
|
|
41
|
+
const body = input.slice(1);
|
|
42
|
+
const token = (body.split(/\s+/)[0] ?? "").toLowerCase();
|
|
43
|
+
// After a completed command + space, stop suggesting names
|
|
44
|
+
if (body.includes(" ") && token.length > 0) {
|
|
45
|
+
const exact = SLASH_COMMANDS.some((c) => c.name === token);
|
|
46
|
+
if (exact) return [];
|
|
47
|
+
}
|
|
48
|
+
if (token.length === 0) return [...SLASH_COMMANDS];
|
|
49
|
+
return SLASH_COMMANDS.filter((c) => c.name.startsWith(token));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Completes the command portion of a slash buffer (Tab).
|
|
54
|
+
* @returns updated input, or null if nothing to complete.
|
|
55
|
+
*/
|
|
56
|
+
export function completeSlashInput(input: string, selectedIndex = 0): string | null {
|
|
57
|
+
const matches = filterSlashCommands(input);
|
|
58
|
+
if (matches.length === 0) return null;
|
|
59
|
+
const pick = matches[Math.min(selectedIndex, matches.length - 1)];
|
|
60
|
+
if (!pick) return null;
|
|
61
|
+
const rest = input.includes(" ") ? input.slice(input.indexOf(" ")) : "";
|
|
62
|
+
if (rest.trim().length > 0) return null;
|
|
63
|
+
return `/${pick.name}${matches.length === 1 ? " " : ""}`;
|
|
64
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only Ink screens that stay inside the hub (doctor / help / status / config).
|
|
3
|
+
* @module tui/views
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type React from "react";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import { collectDoctorReport } from "../commands/doctor.js";
|
|
9
|
+
import { getHelpText } from "../commands/help.js";
|
|
10
|
+
import { collectStatusLines } from "../commands/status-lines.js";
|
|
11
|
+
import { resolveConfig } from "../config.js";
|
|
12
|
+
import { resolveRepoRoot } from "../git.js";
|
|
13
|
+
import { getVersion } from "../version.js";
|
|
14
|
+
import { type HubPanelLine, HubScrollPanel } from "./panels.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Doctor checks inside the hub.
|
|
18
|
+
*/
|
|
19
|
+
export function DoctorView({
|
|
20
|
+
cwd,
|
|
21
|
+
onDone,
|
|
22
|
+
}: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
onDone: () => void;
|
|
25
|
+
}): React.ReactElement {
|
|
26
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
void (async () => {
|
|
32
|
+
try {
|
|
33
|
+
const report = await collectDoctorReport(cwd);
|
|
34
|
+
if (cancelled) return;
|
|
35
|
+
const rows: HubPanelLine[] = report.checks.map((c) => ({
|
|
36
|
+
id: c.name,
|
|
37
|
+
text: `${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`,
|
|
38
|
+
tone: c.ok ? "ok" : "bad",
|
|
39
|
+
}));
|
|
40
|
+
rows.push({
|
|
41
|
+
id: "summary",
|
|
42
|
+
text: report.ok
|
|
43
|
+
? "gflows doctor: all critical checks passed."
|
|
44
|
+
: "gflows doctor: some checks failed.",
|
|
45
|
+
tone: report.ok ? "ok" : "bad",
|
|
46
|
+
});
|
|
47
|
+
setLines(rows);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (!cancelled) {
|
|
50
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
51
|
+
setLines([]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
};
|
|
58
|
+
}, [cwd]);
|
|
59
|
+
|
|
60
|
+
return <HubScrollPanel title="Doctor" lines={lines} error={error} onDone={onDone} />;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Scrollable help inside the hub.
|
|
65
|
+
*/
|
|
66
|
+
export function HelpView({ onDone }: { onDone: () => void }): React.ReactElement {
|
|
67
|
+
const lines: HubPanelLine[] = getHelpText()
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((text, i) => ({
|
|
70
|
+
id: `h${i}`,
|
|
71
|
+
text: text.length === 0 ? " " : text,
|
|
72
|
+
tone: text.endsWith(":") || text.startsWith("gflows") ? "accent" : "default",
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
return <HubScrollPanel title="Help" lines={lines} onDone={onDone} />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Branch status inside the hub.
|
|
80
|
+
*/
|
|
81
|
+
export function StatusView({
|
|
82
|
+
cwd,
|
|
83
|
+
onDone,
|
|
84
|
+
}: {
|
|
85
|
+
cwd: string;
|
|
86
|
+
onDone: () => void;
|
|
87
|
+
}): React.ReactElement {
|
|
88
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
let cancelled = false;
|
|
93
|
+
void (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const rows = await collectStatusLines(cwd);
|
|
96
|
+
if (!cancelled) {
|
|
97
|
+
setLines(
|
|
98
|
+
rows.map((row, i) => ({
|
|
99
|
+
id: `s${i}`,
|
|
100
|
+
text: row.text,
|
|
101
|
+
tone: row.tone,
|
|
102
|
+
})),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (!cancelled) {
|
|
107
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
108
|
+
setLines([]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
return () => {
|
|
113
|
+
cancelled = true;
|
|
114
|
+
};
|
|
115
|
+
}, [cwd]);
|
|
116
|
+
|
|
117
|
+
return <HubScrollPanel title="Status" lines={lines} error={error} onDone={onDone} />;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolved config snapshot inside the hub (read-only).
|
|
122
|
+
*/
|
|
123
|
+
export function ConfigView({
|
|
124
|
+
cwd,
|
|
125
|
+
onDone,
|
|
126
|
+
}: {
|
|
127
|
+
cwd: string;
|
|
128
|
+
onDone: () => void;
|
|
129
|
+
}): React.ReactElement {
|
|
130
|
+
const [lines, setLines] = useState<HubPanelLine[] | null>(null);
|
|
131
|
+
const [error, setError] = useState<string | null>(null);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
let cancelled = false;
|
|
135
|
+
void (async () => {
|
|
136
|
+
try {
|
|
137
|
+
const root = await resolveRepoRoot(cwd);
|
|
138
|
+
const config = resolveConfig(root, {}, {});
|
|
139
|
+
if (cancelled) return;
|
|
140
|
+
setLines([
|
|
141
|
+
{ id: "main", text: `main: ${config.main}`, tone: "accent" },
|
|
142
|
+
{ id: "dev", text: `dev: ${config.dev}` },
|
|
143
|
+
{ id: "remote", text: `remote: ${config.remote}` },
|
|
144
|
+
{ id: "prefixes", text: "prefixes:", tone: "muted" },
|
|
145
|
+
...Object.entries(config.prefixes).map(([type, prefix]) => ({
|
|
146
|
+
id: `p-${type}`,
|
|
147
|
+
text: ` ${type}: ${prefix}`,
|
|
148
|
+
})),
|
|
149
|
+
{ id: "blank", text: " ", tone: "muted" },
|
|
150
|
+
{
|
|
151
|
+
id: "hint",
|
|
152
|
+
text: "Use: gflows config set <key> <value>",
|
|
153
|
+
tone: "muted",
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (!cancelled) {
|
|
158
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
159
|
+
setLines([]);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
})();
|
|
163
|
+
return () => {
|
|
164
|
+
cancelled = true;
|
|
165
|
+
};
|
|
166
|
+
}, [cwd]);
|
|
167
|
+
|
|
168
|
+
return <HubScrollPanel title="Config" lines={lines} error={error} onDone={onDone} />;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Version chip inside the hub.
|
|
173
|
+
*/
|
|
174
|
+
export function VersionView({ onDone }: { onDone: () => void }): React.ReactElement {
|
|
175
|
+
return (
|
|
176
|
+
<HubScrollPanel
|
|
177
|
+
title="Version"
|
|
178
|
+
lines={[{ id: "v", text: `gflows v${getVersion()}`, tone: "accent" }]}
|
|
179
|
+
onDone={onDone}
|
|
180
|
+
/>
|
|
181
|
+
);
|
|
182
|
+
}
|