gflows 1.0.1 → 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 CHANGED
@@ -7,6 +7,12 @@ 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
+
10
16
  ## [1.0.1] - 2026-07-25
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- interface Check {
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
- * Runs health checks and prints a report (or JSON).
34
+ * Collects repo health checks without printing or exiting.
28
35
  */
29
- export async function run(args: ParsedArgs): Promise<void> {
30
- let repoRoot: string;
31
- try {
32
- repoRoot = await resolveRepoRoot(args.cwd);
33
- } catch (err) {
34
- if (args.json) {
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: Check[] = [];
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
- const ok = checks.every((c) => c.ok);
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;
@@ -6,10 +6,10 @@
6
6
  import type { ParsedArgs } from "../types.js";
7
7
 
8
8
  /**
9
- * Runs the help command: prints usage, commands, types, flags, and exit codes to stdout.
9
+ * Full help text (CLI stdout + hub scroll panel).
10
10
  */
11
- export async function run(_args: ParsedArgs): Promise<void> {
12
- const out = `
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
- console.log(out.trim());
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
  }
@@ -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
+ }
@@ -10,6 +10,7 @@ import { FinishFlow, ListFlow, StartFlow, SyncFlow } from "./flows.js";
10
10
  import { HubHome } from "./HubHome.js";
11
11
  import { WizardFrame } from "./prompts.js";
12
12
  import { SLASH_COMMANDS } from "./slash.js";
13
+ import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
13
14
 
14
15
  const ACCENT = "#E88C4A";
15
16
  const MUTED = "#8A8A8A";
@@ -24,13 +25,27 @@ type Screen =
24
25
  | { id: "finish" }
25
26
  | { id: "sync" }
26
27
  | { id: "list" }
28
+ | { id: "doctor" }
29
+ | { id: "help" }
30
+ | { id: "status" }
31
+ | { id: "config" }
32
+ | { id: "version" }
27
33
  | { id: "notice"; message: string };
28
34
 
29
- const DISPATCH_COMMANDS = new Set(
30
- SLASH_COMMANDS.map((c) => c.name).filter(
31
- (name) => !["start", "finish", "sync", "list", "viz", "quit", "exit", "q"].includes(name),
32
- ),
33
- );
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
+ ]);
34
49
 
35
50
  /**
36
51
  * Props for the hub shell.
@@ -55,6 +70,43 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
55
70
  const cancelWizard = () => setScreen({ id: "home" });
56
71
  const runArgv = (argv: string[]) => finish({ kind: "run", argv });
57
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
+
58
110
  const handleSlash = (raw: string) => {
59
111
  const parts = raw.slice(1).trim().split(/\s+/);
60
112
  const cmd = (parts[0] ?? "").toLowerCase();
@@ -91,19 +143,25 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
91
143
  setScreen({ id: "sync" });
92
144
  return;
93
145
  }
94
- if (cmd === "list") {
95
- setScreen({ id: "list" });
146
+ if (cmd === "config" && rest.length > 0) {
147
+ runArgv(["config", ...rest]);
96
148
  return;
97
149
  }
98
- if (cmd === "viz") {
99
- setScreen({ id: "home", flash: "Branch map is shown on this screen." });
150
+
151
+ if (openInHub(cmd)) return;
152
+
153
+ if (!DISPATCH_COMMANDS.has(cmd) && !SLASH_COMMANDS.some((c) => c.name === cmd)) {
154
+ setScreen({
155
+ id: "notice",
156
+ message: `Unknown /${cmd} — type / for command hints.`,
157
+ });
100
158
  return;
101
159
  }
102
160
 
103
161
  if (!DISPATCH_COMMANDS.has(cmd)) {
104
162
  setScreen({
105
163
  id: "notice",
106
- message: `Unknown /${cmd} type / for command hints.`,
164
+ message: `/${cmd} is not available from the hub yet.`,
107
165
  });
108
166
  return;
109
167
  }
@@ -116,22 +174,7 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
116
174
  finish({ kind: "quit" });
117
175
  return;
118
176
  }
119
- if (action === "start") {
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
- }
131
- if (action === "list") {
132
- setScreen({ id: "list" });
133
- return;
134
- }
177
+ if (openInHub(action)) return;
135
178
  runArgv([action]);
136
179
  };
137
180
 
@@ -147,6 +190,21 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
147
190
  if (screen.id === "list") {
148
191
  return <ListFlow cwd={cwd} onDone={cancelWizard} />;
149
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
+ }
150
208
  if (screen.id === "notice") {
151
209
  return (
152
210
  <PressEnter
package/src/tui/flows.tsx CHANGED
@@ -3,18 +3,13 @@
3
3
  * @module tui/flows
4
4
  */
5
5
 
6
- import { Box, Text, useInput } from "ink";
7
6
  import type React from "react";
8
7
  import { useEffect, useState } from "react";
9
8
  import type { BranchType } from "../types.js";
10
9
  import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
10
+ import { type HubPanelLine, HubScrollPanel } from "./panels.js";
11
11
  import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
12
12
 
13
- const MUTED = "#8A8A8A";
14
- const FG = "#E6E6E6";
15
- const GREEN = "#78C88C";
16
- const ACCENT = "#E88C4A";
17
-
18
13
  const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
19
14
 
20
15
  /**
@@ -150,7 +145,7 @@ export function SyncFlow({
150
145
  * Styled branch list inside the hub (no drop-out to plain stdout).
151
146
  */
152
147
  export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
153
- const [lines, setLines] = useState<string[] | null>(null);
148
+ const [lines, setLines] = useState<HubPanelLine[] | null>(null);
154
149
  const [error, setError] = useState<string | null>(null);
155
150
 
156
151
  useEffect(() => {
@@ -158,7 +153,15 @@ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }):
158
153
  void (async () => {
159
154
  try {
160
155
  const snap = await collectVizSnapshot(cwd);
161
- if (!cancelled) setLines(formatListLines(snap));
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
+ }
162
165
  } catch (err) {
163
166
  if (!cancelled) {
164
167
  setError(err instanceof Error ? err.message : String(err));
@@ -171,34 +174,7 @@ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }):
171
174
  };
172
175
  }, [cwd]);
173
176
 
174
- useInput((_ch, key) => {
175
- if (key.return || key.escape || (key.ctrl && _ch === "c")) onDone();
176
- });
177
-
178
- return (
179
- <WizardFrame title="Branches">
180
- {lines === null ? (
181
- <Text color={MUTED}>Loading…</Text>
182
- ) : error ? (
183
- <Text color={FG}>{error}</Text>
184
- ) : (
185
- <Box flexDirection="column">
186
- {lines.map((line) => {
187
- const current = line.includes("●");
188
- return (
189
- <Text key={line} color={current ? GREEN : FG} bold={current}>
190
- {line}
191
- </Text>
192
- );
193
- })}
194
- </Box>
195
- )}
196
- <Box marginTop={1}>
197
- <Text color={MUTED}>enter / esc return to hub</Text>
198
- <Text color={ACCENT}> █</Text>
199
- </Box>
200
- </WizardFrame>
201
- );
177
+ return <HubScrollPanel title="Branches" lines={lines} error={error} onDone={onDone} />;
202
178
  }
203
179
 
204
180
  /**
@@ -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 CHANGED
@@ -27,6 +27,7 @@ export const SLASH_COMMANDS: readonly SlashCommand[] = [
27
27
  { name: "help", hint: "Show help" },
28
28
  { name: "status", hint: "Repo status" },
29
29
  { name: "config", hint: "Show config" },
30
+ { name: "version", hint: "Show version" },
30
31
  { name: "bump", hint: "Bump version" },
31
32
  { name: "viz", hint: "Branch map (on home)" },
32
33
  { name: "quit", hint: "Leave hub" },
@@ -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
+ }