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 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 Clack drop-outs); only git dispatch leaves the TUI
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.0.0",
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
  }
@@ -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: "1.0.0" },
201
+ serverInfo: { name: "gflows", version: getVersion() },
201
202
  });
202
203
  continue;
203
204
  }
@@ -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: "1.0.0",
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
+ }
@@ -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
- setInput("");
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
- setInput((s) => [...s].slice(0, -1).join(""));
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
- setInput("");
128
- onSlash(cmd);
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
- setInput((s) => s + ch);
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 /start /finish /sync …</Text>}
273
+ {input || <Text color={MUTED}>type / for commands…</Text>}
241
274
  {input ? <Text color={ACCENT}>█</Text> : null}
242
275
  </Text>
243
- <Text color={MUTED}>
244
- {showHelp
245
- ? "/init /start /sync /pr /finish /doctor /help · esc clear · ctrl+c quit"
246
- : "? for shortcuts"}
247
- </Text>
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
 
@@ -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 === "viz") {
87
- setScreen({ id: "home", flash: "Branch map is shown on this screen." });
146
+ if (cmd === "config" && rest.length > 0) {
147
+ runArgv(["config", ...rest]);
88
148
  return;
89
149
  }
90
150
 
91
- const known = new Set([
92
- "init",
93
- "pr",
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} — press ? on the home screen for shortcuts.`,
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 === "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
- }
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 process.stdin.setRawMode === "function") {
74
- process.stdin.setRawMode(true);
82
+ if (typeof stdin.setRawMode === "function") {
83
+ stdin.setRawMode(true);
75
84
  }
76
- process.stdin.resume();
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 === "\r" || s === "\n" || s === "\x03") {
80
- process.stdin.off("data", onData);
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
- process.stdin.on("data", onData);
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
+ }
@@ -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
+ }