gflows 1.0.0 → 1.0.1

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,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.1] - 2026-07-25
11
+
10
12
  ### Added
11
13
 
12
14
  - Fullscreen **Ink** TUI hub (`gflows` on TTY): bordered frame, tips / what’s next, actions, `❯` slash prompt, status bar
13
15
  - Slash commands: `/init` `/start` `/sync` `/pr` `/finish` `/doctor` `/help` …
16
+ - In-hub `/` autocomplete (↑↓ select, Tab complete, Enter run)
14
17
  - Interactive prompts via **@clack/prompts** (Inquirer removed)
15
18
  - Legacy Clack menu fallback when TUI cannot run; `gflows viz` for scrollback panel
16
19
  - `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
20
+ - Hub wizards (`/start`, `/finish`, `/sync`, `/list`) run **inside Ink** (fewer drop-outs); only git dispatch leaves the TUI
21
+
22
+ ### Fixed
23
+
24
+ - Hub no longer exits after dispatch (`stdin.ref` after Ink unmount; alternate screen)
25
+ - `/list` renders a styled in-hub branch view instead of plain stdout under a tall gap
18
26
 
19
27
  ## [1.0.0] - 2026-07-24
20
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gflows",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "author": "Ali AlNaghmoush (https://github.com/alialnaghmoush)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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",
@@ -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,10 @@
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";
12
13
 
13
14
  const ACCENT = "#E88C4A";
14
15
  const MUTED = "#8A8A8A";
@@ -22,8 +23,15 @@ type Screen =
22
23
  | { id: "start" }
23
24
  | { id: "finish" }
24
25
  | { id: "sync" }
26
+ | { id: "list" }
25
27
  | { id: "notice"; message: string };
26
28
 
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
+ );
34
+
27
35
  /**
28
36
  * Props for the hub shell.
29
37
  */
@@ -83,27 +91,19 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
83
91
  setScreen({ id: "sync" });
84
92
  return;
85
93
  }
94
+ if (cmd === "list") {
95
+ setScreen({ id: "list" });
96
+ return;
97
+ }
86
98
  if (cmd === "viz") {
87
99
  setScreen({ id: "home", flash: "Branch map is shown on this screen." });
88
100
  return;
89
101
  }
90
102
 
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)) {
103
+ if (!DISPATCH_COMMANDS.has(cmd)) {
104
104
  setScreen({
105
105
  id: "notice",
106
- message: `Unknown /${cmd} — press ? on the home screen for shortcuts.`,
106
+ message: `Unknown /${cmd} — type / for command hints.`,
107
107
  });
108
108
  return;
109
109
  }
@@ -128,6 +128,10 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
128
128
  setScreen({ id: "sync" });
129
129
  return;
130
130
  }
131
+ if (action === "list") {
132
+ setScreen({ id: "list" });
133
+ return;
134
+ }
131
135
  runArgv([action]);
132
136
  };
133
137
 
@@ -140,6 +144,9 @@ export function HubShell({ cwd, onDone }: HubShellProps): React.ReactElement {
140
144
  if (screen.id === "sync") {
141
145
  return <SyncFlow onCancel={cancelWizard} onDone={runArgv} />;
142
146
  }
147
+ if (screen.id === "list") {
148
+ return <ListFlow cwd={cwd} onDone={cancelWizard} />;
149
+ }
143
150
  if (screen.id === "notice") {
144
151
  return (
145
152
  <PressEnter
package/src/tui/flows.tsx CHANGED
@@ -1,13 +1,20 @@
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
+ import { Box, Text, useInput } from "ink";
6
7
  import type React from "react";
7
- import { useState } from "react";
8
+ import { useEffect, useState } from "react";
8
9
  import type { BranchType } from "../types.js";
10
+ import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
9
11
  import { InkConfirm, InkSelect, InkText, WizardFrame } from "./prompts.js";
10
12
 
13
+ const MUTED = "#8A8A8A";
14
+ const FG = "#E6E6E6";
15
+ const GREEN = "#78C88C";
16
+ const ACCENT = "#E88C4A";
17
+
11
18
  const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
12
19
 
13
20
  /**
@@ -138,3 +145,85 @@ export function SyncFlow({
138
145
  </WizardFrame>
139
146
  );
140
147
  }
148
+
149
+ /**
150
+ * Styled branch list inside the hub (no drop-out to plain stdout).
151
+ */
152
+ export function ListFlow({ cwd, onDone }: { cwd: string; onDone: () => void }): React.ReactElement {
153
+ const [lines, setLines] = useState<string[] | null>(null);
154
+ const [error, setError] = useState<string | null>(null);
155
+
156
+ useEffect(() => {
157
+ let cancelled = false;
158
+ void (async () => {
159
+ try {
160
+ const snap = await collectVizSnapshot(cwd);
161
+ if (!cancelled) setLines(formatListLines(snap));
162
+ } catch (err) {
163
+ if (!cancelled) {
164
+ setError(err instanceof Error ? err.message : String(err));
165
+ setLines([]);
166
+ }
167
+ }
168
+ })();
169
+ return () => {
170
+ cancelled = true;
171
+ };
172
+ }, [cwd]);
173
+
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
+ );
202
+ }
203
+
204
+ /**
205
+ * Formats a viz snapshot into hub list rows.
206
+ */
207
+ function formatListLines(snap: VizSnapshot): string[] {
208
+ const lines: string[] = [];
209
+ if (snap.suspended) lines.push(`⚠ suspended: ${snap.suspended}`);
210
+ for (const row of snap.rows) {
211
+ if (row.type !== "main" && row.type !== "dev") continue;
212
+ const mark = row.current ? "●" : "○";
213
+ lines.push(`${mark} ${row.name} [${row.type}]`);
214
+ }
215
+ const workflow = snap.rows.filter((r) => r.type !== "main" && r.type !== "dev");
216
+ if (workflow.length === 0) {
217
+ lines.push(" (no workflow branches)");
218
+ } else {
219
+ for (let i = 0; i < workflow.length; i++) {
220
+ const row = workflow[i];
221
+ if (!row) continue;
222
+ const elbow = i === workflow.length - 1 ? "└─" : "├─";
223
+ const mark = row.current ? "●" : "○";
224
+ const ab = row.ahead === 0 && row.behind === 0 ? "synced" : `+${row.ahead}/-${row.behind}`;
225
+ lines.push(`${elbow} ${mark} ${row.name} (${row.type}) ${ab}`);
226
+ }
227
+ }
228
+ return lines;
229
+ }
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,63 @@
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: "bump", hint: "Bump version" },
31
+ { name: "viz", hint: "Branch map (on home)" },
32
+ { name: "quit", hint: "Leave hub" },
33
+ ] as const;
34
+
35
+ /**
36
+ * Filters slash commands by the typed buffer (e.g. `/st` → start, status, switch).
37
+ */
38
+ export function filterSlashCommands(input: string): SlashCommand[] {
39
+ if (!input.startsWith("/")) return [];
40
+ const body = input.slice(1);
41
+ const token = (body.split(/\s+/)[0] ?? "").toLowerCase();
42
+ // After a completed command + space, stop suggesting names
43
+ if (body.includes(" ") && token.length > 0) {
44
+ const exact = SLASH_COMMANDS.some((c) => c.name === token);
45
+ if (exact) return [];
46
+ }
47
+ if (token.length === 0) return [...SLASH_COMMANDS];
48
+ return SLASH_COMMANDS.filter((c) => c.name.startsWith(token));
49
+ }
50
+
51
+ /**
52
+ * Completes the command portion of a slash buffer (Tab).
53
+ * @returns updated input, or null if nothing to complete.
54
+ */
55
+ export function completeSlashInput(input: string, selectedIndex = 0): string | null {
56
+ const matches = filterSlashCommands(input);
57
+ if (matches.length === 0) return null;
58
+ const pick = matches[Math.min(selectedIndex, matches.length - 1)];
59
+ if (!pick) return null;
60
+ const rest = input.includes(" ") ? input.slice(input.indexOf(" ")) : "";
61
+ if (rest.trim().length > 0) return null;
62
+ return `/${pick.name}${matches.length === 1 ? " " : ""}`;
63
+ }