gflows 1.0.1 → 1.0.3

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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.3] - 2026-07-25
11
+
12
+ ### Fixed
13
+
14
+ - Hub interactive actions no longer freeze/exit after Ink unmount (`stdin.ref` before dispatch; Clack cancel no longer `process.exit`s the hub)
15
+ - `/switch`, `/init`, `/bump` wizards run inside Ink (same as start/list)
16
+
17
+ ### Changed
18
+
19
+ - Local `bun run g` script invokes `src/cli.ts` so the hub gets a real TTY
20
+
21
+ ## [1.0.2] - 2026-07-25
22
+
23
+ ### Changed
24
+
25
+ - Hub read-only screens stay in Ink: `/doctor`, `/help`, `/status`, `/config`, `/version` (enter/esc return)
26
+
10
27
  ## [1.0.1] - 2026-07-25
11
28
 
12
29
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,7 +39,8 @@
39
39
  "gflows": "bun run src/cli.ts",
40
40
  "publish:all": "bun run scripts/publish.ts",
41
41
  "publish:npm": "bun run scripts/publish.ts -- --npm-only",
42
- "publish:jsr": "bun run scripts/publish.ts -- --jsr-only"
42
+ "publish:jsr": "bun run scripts/publish.ts -- --jsr-only",
43
+ "g": "bun run src/cli.ts"
43
44
  },
44
45
  "type": "module",
45
46
  "dependencies": {
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { EXIT_GIT, EXIT_OK, EXIT_USER } from "./constants.js";
10
10
  import { exitCodeForError, printError } from "./errors.js";
11
11
  import { parse } from "./parse.js";
12
+ import { PromptCancelledError } from "./prompts.js";
12
13
  import type { ParsedArgs } from "./types.js";
13
14
 
14
15
  /** Last parsed args, set at start of run(); used by catch/rejection to respect -v for stack trace. */
@@ -27,7 +28,10 @@ async function run(): Promise<void> {
27
28
  await runHub(process.cwd());
28
29
  return;
29
30
  }
30
- console.error("gflows: missing command. Use 'gflows help' for usage.");
31
+ console.error(
32
+ "gflows: no interactive TTY. Run `gflows` directly (or `alias g=gflows`), not via a bun/npm script that swallows stdin.",
33
+ );
34
+ console.error("Or pass a command: gflows help");
31
35
  process.exit(EXIT_USER);
32
36
  }
33
37
 
@@ -76,6 +80,10 @@ function main(): void {
76
80
  })
77
81
  .catch((err: unknown) => {
78
82
  if (exitCode !== null) return;
83
+ if (err instanceof PromptCancelledError) {
84
+ process.exit(EXIT_OK);
85
+ return;
86
+ }
79
87
  printError(err);
80
88
  const verbose = lastParsedArgs?.verbose ?? !!process.env.GFLOWS_VERBOSE;
81
89
  if (verbose && err instanceof Error && err.stack) {
@@ -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
+ }
package/src/prompts.ts CHANGED
@@ -6,12 +6,26 @@
6
6
  import * as clack from "@clack/prompts";
7
7
 
8
8
  /**
9
- * Exits the process when the user cancels a prompt (Ctrl+C / Escape).
9
+ * Thrown when the user cancels a Clack prompt (Ctrl+C / Escape).
10
+ * Callers (CLI / hub) should treat this as a soft abort — do not kill the hub loop via process.exit.
11
+ */
12
+ export class PromptCancelledError extends Error {
13
+ /**
14
+ * Creates a cancellation error.
15
+ */
16
+ constructor() {
17
+ super("Cancelled.");
18
+ this.name = "PromptCancelledError";
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Converts Clack cancel symbols into {@link PromptCancelledError}.
10
24
  */
11
25
  function exitIfCancel<T>(value: T | symbol): asserts value is T {
12
26
  if (clack.isCancel(value)) {
13
27
  clack.cancel("Cancelled.");
14
- process.exit(0);
28
+ throw new PromptCancelledError();
15
29
  }
16
30
  }
17
31
 
@@ -6,10 +6,19 @@
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, ListFlow, StartFlow, SyncFlow } from "./flows.js";
9
+ import {
10
+ BumpFlow,
11
+ FinishFlow,
12
+ InitFlow,
13
+ ListFlow,
14
+ StartFlow,
15
+ SwitchFlow,
16
+ SyncFlow,
17
+ } from "./flows.js";
10
18
  import { HubHome } from "./HubHome.js";
11
19
  import { WizardFrame } from "./prompts.js";
12
20
  import { SLASH_COMMANDS } from "./slash.js";
21
+ import { ConfigView, DoctorView, HelpView, StatusView, VersionView } from "./views.js";
13
22
 
14
23
  const ACCENT = "#E88C4A";
15
24
  const MUTED = "#8A8A8A";
@@ -24,13 +33,27 @@ type Screen =
24
33
  | { id: "finish" }
25
34
  | { id: "sync" }
26
35
  | { id: "list" }
36
+ | { id: "switch" }
37
+ | { id: "init" }
38
+ | { id: "bump" }
39
+ | { id: "doctor" }
40
+ | { id: "help" }
41
+ | { id: "status" }
42
+ | { id: "config" }
43
+ | { id: "version" }
27
44
  | { id: "notice"; message: string };
28
45
 
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
- );
46
+ /** Commands that leave Ink for git / side effects (no interactive Clack). */
47
+ const DISPATCH_COMMANDS = new Set([
48
+ "pr",
49
+ "continue",
50
+ "delete",
51
+ "undo",
52
+ "abort",
53
+ "schema",
54
+ "mcp",
55
+ "completion",
56
+ ]);
34
57
 
35
58
  /**
36
59
  * Props for the hub shell.
@@ -55,6 +78,52 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
55
78
  const cancelWizard = () => setScreen({ id: "home" });
56
79
  const runArgv = (argv: string[]) => finish({ kind: "run", argv });
57
80
 
81
+ const openInHub = (cmd: string): boolean => {
82
+ switch (cmd) {
83
+ case "start":
84
+ setScreen({ id: "start" });
85
+ return true;
86
+ case "finish":
87
+ setScreen({ id: "finish" });
88
+ return true;
89
+ case "sync":
90
+ setScreen({ id: "sync" });
91
+ return true;
92
+ case "list":
93
+ setScreen({ id: "list" });
94
+ return true;
95
+ case "switch":
96
+ setScreen({ id: "switch" });
97
+ return true;
98
+ case "init":
99
+ setScreen({ id: "init" });
100
+ return true;
101
+ case "bump":
102
+ setScreen({ id: "bump" });
103
+ return true;
104
+ case "doctor":
105
+ setScreen({ id: "doctor" });
106
+ return true;
107
+ case "help":
108
+ setScreen({ id: "help" });
109
+ return true;
110
+ case "status":
111
+ setScreen({ id: "status" });
112
+ return true;
113
+ case "config":
114
+ setScreen({ id: "config" });
115
+ return true;
116
+ case "version":
117
+ setScreen({ id: "version" });
118
+ return true;
119
+ case "viz":
120
+ setScreen({ id: "home", flash: "Branch map is shown on this screen." });
121
+ return true;
122
+ default:
123
+ return false;
124
+ }
125
+ };
126
+
58
127
  const handleSlash = (raw: string) => {
59
128
  const parts = raw.slice(1).trim().split(/\s+/);
60
129
  const cmd = (parts[0] ?? "").toLowerCase();
@@ -91,16 +160,26 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
91
160
  setScreen({ id: "sync" });
92
161
  return;
93
162
  }
94
- if (cmd === "list") {
95
- setScreen({ id: "list" });
163
+ if (cmd === "config" && rest.length > 0) {
164
+ runArgv(["config", ...rest]);
165
+ return;
166
+ }
167
+ if (cmd === "switch" && rest.length > 0) {
168
+ runArgv(["switch", ...rest]);
96
169
  return;
97
170
  }
98
- if (cmd === "viz") {
99
- setScreen({ id: "home", flash: "Branch map is shown on this screen." });
171
+ if (cmd === "bump" && rest.length > 0) {
172
+ runArgv(["bump", ...rest]);
173
+ return;
174
+ }
175
+ if (cmd === "init" && rest.length > 0) {
176
+ runArgv(["init", ...rest]);
100
177
  return;
101
178
  }
102
179
 
103
- if (!DISPATCH_COMMANDS.has(cmd)) {
180
+ if (openInHub(cmd)) return;
181
+
182
+ if (!DISPATCH_COMMANDS.has(cmd) && !SLASH_COMMANDS.some((c) => c.name === cmd)) {
104
183
  setScreen({
105
184
  id: "notice",
106
185
  message: `Unknown /${cmd} — type / for command hints.`,
@@ -108,6 +187,14 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
108
187
  return;
109
188
  }
110
189
 
190
+ if (!DISPATCH_COMMANDS.has(cmd)) {
191
+ setScreen({
192
+ id: "notice",
193
+ message: `/${cmd} is not available from the hub yet.`,
194
+ });
195
+ return;
196
+ }
197
+
111
198
  runArgv([cmd, ...rest]);
112
199
  };
113
200
 
@@ -116,22 +203,7 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
116
203
  finish({ kind: "quit" });
117
204
  return;
118
205
  }
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
- }
206
+ if (openInHub(action)) return;
135
207
  runArgv([action]);
136
208
  };
137
209
 
@@ -147,6 +219,30 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
147
219
  if (screen.id === "list") {
148
220
  return <ListFlow cwd={cwd} onDone={cancelWizard} />;
149
221
  }
222
+ if (screen.id === "switch") {
223
+ return <SwitchFlow cwd={cwd} onCancel={cancelWizard} onDone={runArgv} />;
224
+ }
225
+ if (screen.id === "init") {
226
+ return <InitFlow onCancel={cancelWizard} onDone={runArgv} />;
227
+ }
228
+ if (screen.id === "bump") {
229
+ return <BumpFlow onCancel={cancelWizard} onDone={runArgv} />;
230
+ }
231
+ if (screen.id === "doctor") {
232
+ return <DoctorView cwd={cwd} onDone={cancelWizard} />;
233
+ }
234
+ if (screen.id === "help") {
235
+ return <HelpView onDone={cancelWizard} />;
236
+ }
237
+ if (screen.id === "status") {
238
+ return <StatusView cwd={cwd} onDone={cancelWizard} />;
239
+ }
240
+ if (screen.id === "config") {
241
+ return <ConfigView cwd={cwd} onDone={cancelWizard} />;
242
+ }
243
+ if (screen.id === "version") {
244
+ return <VersionView onDone={cancelWizard} />;
245
+ }
150
246
  if (screen.id === "notice") {
151
247
  return (
152
248
  <PressEnter
package/src/tui/flows.tsx CHANGED
@@ -1,22 +1,24 @@
1
1
  /**
2
- * Multi-step Ink wizards for hub actions (start / finish / sync / list).
2
+ * Multi-step Ink wizards for hub actions (start / finish / sync / list / switch / …).
3
3
  * @module tui/flows
4
4
  */
5
5
 
6
- import { Box, Text, useInput } from "ink";
6
+ import { Text } from "ink";
7
7
  import type React from "react";
8
8
  import { useEffect, useState } from "react";
9
+ import { resolveConfig } from "../config.js";
10
+ import { branchList, getCurrentBranch, isClean, resolveRepoRoot } from "../git.js";
9
11
  import type { BranchType } from "../types.js";
10
12
  import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
13
+ import { type HubPanelLine, HubScrollPanel } from "./panels.js";
11
14
  import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
12
15
 
13
16
  const MUTED = "#8A8A8A";
14
- const FG = "#E6E6E6";
15
- const GREEN = "#78C88C";
16
- const ACCENT = "#E88C4A";
17
17
 
18
18
  const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
19
19
 
20
+ type SwitchMode = "move" | "restore" | "clean" | "destroy" | "cancel";
21
+
20
22
  /**
21
23
  * Collects argv for `gflows start …` entirely inside Ink.
22
24
  */
@@ -147,22 +149,53 @@ export function SyncFlow({
147
149
  }
148
150
 
149
151
  /**
150
- * Styled branch list inside the hub (no drop-out to plain stdout).
152
+ * Switch-branch wizard entirely inside Ink (avoids dead Clack after Ink unmount).
151
153
  */
152
- export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
153
- const [lines, setLines] = useState<string[] | null>(null);
154
+ export function SwitchFlow({
155
+ cwd,
156
+ onDone,
157
+ onCancel,
158
+ }: {
159
+ cwd: string;
160
+ onDone: (argv: string[]) => void;
161
+ onCancel: () => void;
162
+ }): React.ReactElement {
163
+ const [step, setStep] = useState<"load" | "pick" | "dirty" | "error">("load");
164
+ const [choices, setChoices] = useState<{ value: string; label: string }[]>([]);
165
+ const [target, setTarget] = useState("");
154
166
  const [error, setError] = useState<string | null>(null);
167
+ const [initialIndex, setInitialIndex] = useState(0);
155
168
 
156
169
  useEffect(() => {
157
170
  let cancelled = false;
158
171
  void (async () => {
159
172
  try {
160
- const snap = await collectVizSnapshot(cwd);
161
- if (!cancelled) setLines(formatListLines(snap));
173
+ const root = await resolveRepoRoot(cwd);
174
+ const config = resolveConfig(root, {}, {});
175
+ const allLocal = await branchList(root, { dryRun: false, verbose: false });
176
+ const workflow = BRANCH_TYPES.map((t) => config.prefixes[t])
177
+ .filter(Boolean)
178
+ .flatMap((prefix) => allLocal.filter((b) => b.startsWith(prefix)));
179
+ const mainAndDev = [config.main, config.dev].filter((b) => allLocal.includes(b));
180
+ const names = [...mainAndDev, ...[...new Set(workflow)].sort()];
181
+ const current = await getCurrentBranch(root, {});
182
+ if (cancelled) return;
183
+ if (names.length === 0) {
184
+ setError("No branches found.");
185
+ setStep("error");
186
+ return;
187
+ }
188
+ const opts = names.map((b) => ({
189
+ value: b,
190
+ label: current && b === current ? `${b} (current)` : b,
191
+ }));
192
+ setChoices(opts);
193
+ setInitialIndex(Math.max(0, current ? names.indexOf(current) : 0));
194
+ setStep("pick");
162
195
  } catch (err) {
163
196
  if (!cancelled) {
164
197
  setError(err instanceof Error ? err.message : String(err));
165
- setLines([]);
198
+ setStep("error");
166
199
  }
167
200
  }
168
201
  })();
@@ -171,36 +204,248 @@ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }):
171
204
  };
172
205
  }, [cwd]);
173
206
 
174
- useInput((_ch, key) => {
175
- if (key.return || key.escape || (key.ctrl && _ch === "c")) onDone();
176
- });
207
+ if (step === "load") {
208
+ return (
209
+ <WizardFrame title="Switch branch">
210
+ <Text color={MUTED}>Loading…</Text>
211
+ </WizardFrame>
212
+ );
213
+ }
177
214
 
215
+ if (step === "error") {
216
+ return (
217
+ <HubScrollPanel
218
+ title="Switch branch"
219
+ lines={[{ id: "e", text: error ?? "Unknown error", tone: "bad" }]}
220
+ onDone={onCancel}
221
+ />
222
+ );
223
+ }
224
+
225
+ if (step === "pick") {
226
+ return (
227
+ <WizardFrame title="Switch branch">
228
+ <InkSelect
229
+ message="Switch to branch"
230
+ options={choices}
231
+ initialIndex={initialIndex}
232
+ onCancel={onCancel}
233
+ onSubmit={(branch) => {
234
+ void (async () => {
235
+ setTarget(branch);
236
+ try {
237
+ const root = await resolveRepoRoot(cwd);
238
+ const clean = await isClean(root, {});
239
+ if (clean) {
240
+ onDone(["switch", branch]);
241
+ return;
242
+ }
243
+ setStep("dirty");
244
+ } catch (err) {
245
+ setError(err instanceof Error ? err.message : String(err));
246
+ setStep("error");
247
+ }
248
+ })();
249
+ }}
250
+ />
251
+ </WizardFrame>
252
+ );
253
+ }
254
+
255
+ // dirty
178
256
  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>
257
+ <WizardFrame title={`Switch · ${target}`}>
258
+ <InkSelect<SwitchMode>
259
+ message="Working tree has uncommitted changes"
260
+ options={[
261
+ { value: "move", label: "Move", hint: "carry changes to target" },
262
+ { value: "restore", label: "Restore", hint: "stash here, restore target" },
263
+ { value: "clean", label: "Clean", hint: "discard changes" },
264
+ { value: "destroy", label: "Destroy", hint: "delete current branch" },
265
+ { value: "cancel", label: "Cancel", hint: "abort switch" },
266
+ ]}
267
+ onCancel={onCancel}
268
+ onSubmit={(mode) => {
269
+ if (mode === "cancel") {
270
+ onCancel();
271
+ return;
272
+ }
273
+ onDone(["switch", target, `--${mode}`]);
274
+ }}
275
+ />
200
276
  </WizardFrame>
201
277
  );
202
278
  }
203
279
 
280
+ /**
281
+ * Init wizard inside Ink; dispatches with -y so CLI skips Clack prompts.
282
+ */
283
+ export function InitFlow({
284
+ onDone,
285
+ onCancel,
286
+ }: {
287
+ onDone: (argv: string[]) => void;
288
+ onCancel: () => void;
289
+ }): React.ReactElement {
290
+ const [step, setStep] = useState<"main" | "dev" | "remote" | "alias">("main");
291
+ const [main, setMain] = useState("main");
292
+ const [dev, setDev] = useState("dev");
293
+ const [remote, setRemote] = useState("origin");
294
+
295
+ if (step === "main") {
296
+ return (
297
+ <WizardFrame title="Initialize repo">
298
+ <InkText
299
+ message="Main branch name"
300
+ placeholder="main"
301
+ initialValue="main"
302
+ onCancel={onCancel}
303
+ onSubmit={(v) => {
304
+ setMain(v || "main");
305
+ setStep("dev");
306
+ }}
307
+ />
308
+ </WizardFrame>
309
+ );
310
+ }
311
+ if (step === "dev") {
312
+ return (
313
+ <WizardFrame title="Initialize repo">
314
+ <InkText
315
+ message="Dev branch name"
316
+ placeholder="dev"
317
+ initialValue="dev"
318
+ onCancel={onCancel}
319
+ onSubmit={(v) => {
320
+ setDev(v || "dev");
321
+ setStep("remote");
322
+ }}
323
+ />
324
+ </WizardFrame>
325
+ );
326
+ }
327
+ if (step === "remote") {
328
+ return (
329
+ <WizardFrame title="Initialize repo">
330
+ <InkText
331
+ message="Remote name"
332
+ placeholder="origin"
333
+ initialValue="origin"
334
+ onCancel={onCancel}
335
+ onSubmit={(v) => {
336
+ setRemote(v || "origin");
337
+ setStep("alias");
338
+ }}
339
+ />
340
+ </WizardFrame>
341
+ );
342
+ }
343
+
344
+ return (
345
+ <WizardFrame title="Initialize repo">
346
+ <InkSelect
347
+ message="Add package.json script alias?"
348
+ options={[
349
+ { value: "g", label: 'script "g"', hint: "prefer: alias g=gflows in shell" },
350
+ { value: "gflows", label: 'script "gflows"' },
351
+ { value: "skip", label: "Skip", hint: "shell alias is best for hub TTY" },
352
+ ]}
353
+ onCancel={onCancel}
354
+ onSubmit={(alias) => {
355
+ const argv = ["init", "-y", "-P", "--main", main, "--dev", dev, "--remote", remote];
356
+ if (alias === "skip") argv.push("--no-script-alias");
357
+ else argv.push("--script-alias", alias);
358
+ onDone(argv);
359
+ }}
360
+ />
361
+ </WizardFrame>
362
+ );
363
+ }
364
+
365
+ /**
366
+ * Bump wizard inside Ink.
367
+ */
368
+ export function BumpFlow({
369
+ onDone,
370
+ onCancel,
371
+ }: {
372
+ onDone: (argv: string[]) => void;
373
+ onCancel: () => void;
374
+ }): React.ReactElement {
375
+ const [step, setStep] = useState<"dir" | "type">("dir");
376
+ const [direction, setDirection] = useState<"up" | "down">("up");
377
+
378
+ if (step === "dir") {
379
+ return (
380
+ <WizardFrame title="Bump version">
381
+ <InkSelect<"up" | "down">
382
+ message="Direction"
383
+ options={[
384
+ { value: "up", label: "Up (bump)" },
385
+ { value: "down", label: "Down (rollback)" },
386
+ ]}
387
+ onCancel={onCancel}
388
+ onSubmit={(d) => {
389
+ setDirection(d);
390
+ setStep("type");
391
+ }}
392
+ />
393
+ </WizardFrame>
394
+ );
395
+ }
396
+
397
+ return (
398
+ <WizardFrame title={`Bump · ${direction}`}>
399
+ <InkSelect<"patch" | "minor" | "major">
400
+ message="Semver part"
401
+ options={[
402
+ { value: "patch", label: "patch" },
403
+ { value: "minor", label: "minor" },
404
+ { value: "major", label: "major" },
405
+ ]}
406
+ onCancel={onCancel}
407
+ onSubmit={(type) => onDone(["bump", direction, type])}
408
+ />
409
+ </WizardFrame>
410
+ );
411
+ }
412
+
413
+ /**
414
+ * Styled branch list inside the hub (no drop-out to plain stdout).
415
+ */
416
+ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
417
+ const [lines, setLines] = useState<HubPanelLine[] | null>(null);
418
+ const [error, setError] = useState<string | null>(null);
419
+
420
+ useEffect(() => {
421
+ let cancelled = false;
422
+ void (async () => {
423
+ try {
424
+ const snap = await collectVizSnapshot(cwd);
425
+ if (!cancelled) {
426
+ setLines(
427
+ formatListLines(snap).map((text, i) => ({
428
+ id: `b${i}`,
429
+ text,
430
+ tone: text.includes("●") ? ("ok" as const) : ("default" as const),
431
+ })),
432
+ );
433
+ }
434
+ } catch (err) {
435
+ if (!cancelled) {
436
+ setError(err instanceof Error ? err.message : String(err));
437
+ setLines([]);
438
+ }
439
+ }
440
+ })();
441
+ return () => {
442
+ cancelled = true;
443
+ };
444
+ }, [cwd]);
445
+
446
+ return <HubScrollPanel title="Branches" lines={lines} error={error} onDone={onDone} />;
447
+ }
448
+
204
449
  /**
205
450
  * Formats a viz snapshot into hub list rows.
206
451
  */
package/src/tui/hub.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  import { render } from "ink";
7
7
  import React from "react";
8
8
  import { dispatch } from "../dispatch.js";
9
+ import { PromptCancelledError } from "../prompts.js";
9
10
  import { type HubSessionResult, HubShell } from "./HubShell.js";
11
+ import { prepareStdinAfterInk } from "./stdin.js";
10
12
 
11
13
  /**
12
14
  * Whether stdin/stdout can host the Ink hub.
@@ -27,11 +29,15 @@ export async function runTuiHub(cwd: string): Promise<boolean> {
27
29
  if (result.kind === "quit") break;
28
30
 
29
31
  if (result.kind === "run") {
32
+ // Ink unrefs stdin on unmount — re-arm before Clack / git prompts.
33
+ prepareStdinAfterInk();
30
34
  console.log("");
31
35
  try {
32
36
  await dispatch(cwd, result.argv);
33
37
  } catch (err) {
34
- console.error("gflows:", err instanceof Error ? err.message : String(err));
38
+ if (!(err instanceof PromptCancelledError)) {
39
+ console.error("gflows:", err instanceof Error ? err.message : String(err));
40
+ }
35
41
  }
36
42
  console.log("");
37
43
  await waitEnterRaw("Press enter to return to hub…");
@@ -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,28 @@
1
+ /**
2
+ * Stdin helpers for surviving Ink teardown before Clack / dispatch.
3
+ * @module tui/stdin
4
+ */
5
+
6
+ /**
7
+ * Re-arm process.stdin after Ink unmounts (Ink calls `stdin.unref()`).
8
+ * Without this, interactive Clack prompts paint once then the process exits.
9
+ */
10
+ export function prepareStdinAfterInk(): void {
11
+ const stdin = process.stdin;
12
+ if (typeof stdin.setRawMode === "function") {
13
+ try {
14
+ stdin.setRawMode(false);
15
+ } catch {
16
+ // ignore — may already be non-raw / closed
17
+ }
18
+ }
19
+ stdin.resume();
20
+ stdin.ref();
21
+
22
+ if (typeof stdin.read === "function") {
23
+ for (;;) {
24
+ const pending = stdin.read();
25
+ if (pending === null) break;
26
+ }
27
+ }
28
+ }
@@ -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
+ }