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 +9 -1
- package/package.json +1 -1
- package/src/commands/mcp.ts +2 -1
- package/src/commands/schema.ts +2 -1
- package/src/tui/HubHome.tsx +59 -11
- package/src/tui/HubShell.tsx +22 -15
- package/src/tui/flows.tsx +91 -2
- package/src/tui/hub.ts +34 -9
- package/src/tui/slash.ts +63 -0
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
|
|
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
package/src/commands/mcp.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { EXIT_OK } from "../constants.js";
|
|
7
7
|
import type { ParsedArgs } from "../types.js";
|
|
8
|
+
import { getVersion } from "../version.js";
|
|
8
9
|
|
|
9
10
|
interface JsonRpcRequest {
|
|
10
11
|
jsonrpc?: string;
|
|
@@ -197,7 +198,7 @@ export async function run(_args: ParsedArgs): Promise<void> {
|
|
|
197
198
|
respond(req.id, {
|
|
198
199
|
protocolVersion: "2024-11-05",
|
|
199
200
|
capabilities: { tools: {} },
|
|
200
|
-
serverInfo: { name: "gflows", version:
|
|
201
|
+
serverInfo: { name: "gflows", version: getVersion() },
|
|
201
202
|
});
|
|
202
203
|
continue;
|
|
203
204
|
}
|
package/src/commands/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { ParsedArgs } from "../types.js";
|
|
7
7
|
import { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "../types.js";
|
|
8
|
+
import { getVersion } from "../version.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Emits JSON schema for agents and tooling.
|
|
@@ -12,7 +13,7 @@ import { ALL_COMMANDS, BRANCH_TYPE_SHORTS } from "../types.js";
|
|
|
12
13
|
export async function run(_args: ParsedArgs): Promise<void> {
|
|
13
14
|
const schema = {
|
|
14
15
|
name: "gflows",
|
|
15
|
-
version:
|
|
16
|
+
version: getVersion(),
|
|
16
17
|
description: "Git branching workflow CLI (main + dev + typed short-lived branches)",
|
|
17
18
|
exitCodes: {
|
|
18
19
|
"0": "success",
|
package/src/tui/HubHome.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
|
11
11
|
import { type RecommendAction, recommend } from "../recommend.js";
|
|
12
12
|
import { getVersion } from "../version.js";
|
|
13
13
|
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
14
|
+
import { completeSlashInput, filterSlashCommands } from "./slash.js";
|
|
14
15
|
|
|
15
16
|
const ACCENT = "#E88C4A";
|
|
16
17
|
const MUTED = "#8A8A8A";
|
|
@@ -50,9 +51,12 @@ export function HubHome({
|
|
|
50
51
|
const [repoPath, setRepoPath] = useState(cwd);
|
|
51
52
|
const [selected, setSelected] = useState(0);
|
|
52
53
|
const [input, setInput] = useState("");
|
|
54
|
+
const [slashIndex, setSlashIndex] = useState(0);
|
|
53
55
|
const [showHelp, setShowHelp] = useState(false);
|
|
54
56
|
const [loading, setLoading] = useState(true);
|
|
55
57
|
const version = useMemo(() => getVersion(), []);
|
|
58
|
+
const slashMatches = useMemo(() => filterSlashCommands(input), [input]);
|
|
59
|
+
const slashMode = input.startsWith("/");
|
|
56
60
|
|
|
57
61
|
useEffect(() => {
|
|
58
62
|
let cancelled = false;
|
|
@@ -87,6 +91,17 @@ export function HubHome({
|
|
|
87
91
|
if (selected >= actions.length) setSelected(Math.max(0, actions.length - 1));
|
|
88
92
|
}, [actions.length, selected]);
|
|
89
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (slashIndex >= slashMatches.length) {
|
|
96
|
+
setSlashIndex(Math.max(0, slashMatches.length - 1));
|
|
97
|
+
}
|
|
98
|
+
}, [slashMatches.length, slashIndex]);
|
|
99
|
+
|
|
100
|
+
const setSlashInput = (next: string) => {
|
|
101
|
+
setInput(next);
|
|
102
|
+
setSlashIndex(0);
|
|
103
|
+
};
|
|
104
|
+
|
|
90
105
|
useInput((ch, key) => {
|
|
91
106
|
if (key.ctrl && ch === "c") {
|
|
92
107
|
onQuit();
|
|
@@ -94,7 +109,7 @@ export function HubHome({
|
|
|
94
109
|
}
|
|
95
110
|
if (key.escape) {
|
|
96
111
|
if (input || showHelp) {
|
|
97
|
-
|
|
112
|
+
setSlashInput("");
|
|
98
113
|
setShowHelp(false);
|
|
99
114
|
return;
|
|
100
115
|
}
|
|
@@ -109,23 +124,41 @@ export function HubHome({
|
|
|
109
124
|
onQuit();
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
127
|
+
if (key.tab && slashMode) {
|
|
128
|
+
const next = completeSlashInput(input, slashIndex);
|
|
129
|
+
if (next !== null) setSlashInput(next);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
112
132
|
if (key.upArrow) {
|
|
133
|
+
if (slashMode && slashMatches.length > 0) {
|
|
134
|
+
setSlashIndex((i) => Math.max(0, i - 1));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
113
137
|
setSelected((i) => Math.max(0, i - 1));
|
|
114
138
|
return;
|
|
115
139
|
}
|
|
116
140
|
if (key.downArrow) {
|
|
141
|
+
if (slashMode && slashMatches.length > 0) {
|
|
142
|
+
setSlashIndex((i) => Math.min(slashMatches.length - 1, i + 1));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
117
145
|
setSelected((i) => Math.min(actions.length - 1, i + 1));
|
|
118
146
|
return;
|
|
119
147
|
}
|
|
120
148
|
if (key.backspace || key.delete) {
|
|
121
|
-
|
|
149
|
+
setSlashInput([...input].slice(0, -1).join(""));
|
|
122
150
|
return;
|
|
123
151
|
}
|
|
124
152
|
if (key.return) {
|
|
125
153
|
const cmd = input.trim();
|
|
126
154
|
if (cmd.startsWith("/")) {
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
const match = slashMatches[slashIndex];
|
|
156
|
+
const body = cmd.slice(1);
|
|
157
|
+
const token = (body.split(/\s+/)[0] ?? "").toLowerCase();
|
|
158
|
+
const hasArgs = body.includes(" ");
|
|
159
|
+
const resolved = match && !hasArgs && token !== match.name ? `/${match.name}` : cmd;
|
|
160
|
+
setSlashInput("");
|
|
161
|
+
onSlash(resolved);
|
|
129
162
|
return;
|
|
130
163
|
}
|
|
131
164
|
const action = actions[selected];
|
|
@@ -137,7 +170,7 @@ export function HubHome({
|
|
|
137
170
|
return;
|
|
138
171
|
}
|
|
139
172
|
if (ch && !key.ctrl && !key.meta && ch >= " ") {
|
|
140
|
-
|
|
173
|
+
setSlashInput(input + ch);
|
|
141
174
|
}
|
|
142
175
|
});
|
|
143
176
|
|
|
@@ -237,14 +270,29 @@ export function HubHome({
|
|
|
237
270
|
<Box marginTop={1} flexDirection="column">
|
|
238
271
|
<Text>
|
|
239
272
|
<Text color={ACCENT}>❯ </Text>
|
|
240
|
-
{input || <Text color={MUTED}>type /
|
|
273
|
+
{input || <Text color={MUTED}>type / for commands…</Text>}
|
|
241
274
|
{input ? <Text color={ACCENT}>█</Text> : null}
|
|
242
275
|
</Text>
|
|
243
|
-
|
|
244
|
-
{
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
276
|
+
{slashMode && slashMatches.length > 0 ? (
|
|
277
|
+
<Box flexDirection="column" marginTop={0}>
|
|
278
|
+
{slashMatches.slice(0, 8).map((item, i) => {
|
|
279
|
+
const active = i === slashIndex;
|
|
280
|
+
return (
|
|
281
|
+
<Text key={item.name} color={active ? FG : MUTED} bold={active}>
|
|
282
|
+
{active ? <Text color={ACCENT}>› </Text> : " "}/{item.name}
|
|
283
|
+
<Text color={MUTED}>{` ${item.hint}`}</Text>
|
|
284
|
+
</Text>
|
|
285
|
+
);
|
|
286
|
+
})}
|
|
287
|
+
<Text color={MUTED}>↑↓ · tab complete · enter run · esc clear</Text>
|
|
288
|
+
</Box>
|
|
289
|
+
) : (
|
|
290
|
+
<Text color={MUTED}>
|
|
291
|
+
{showHelp
|
|
292
|
+
? "/init /start /sync /pr /finish /doctor /help · esc clear · ctrl+c quit"
|
|
293
|
+
: "? for shortcuts · / for command menu"}
|
|
294
|
+
</Text>
|
|
295
|
+
)}
|
|
248
296
|
</Box>
|
|
249
297
|
</Box>
|
|
250
298
|
|
package/src/tui/HubShell.tsx
CHANGED
|
@@ -6,9 +6,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
|
-
|
|
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} —
|
|
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
|
|
74
|
-
|
|
82
|
+
if (typeof stdin.setRawMode === "function") {
|
|
83
|
+
stdin.setRawMode(true);
|
|
75
84
|
}
|
|
76
|
-
|
|
85
|
+
stdin.resume();
|
|
86
|
+
stdin.ref();
|
|
87
|
+
|
|
88
|
+
// Drop buffered Enter from the hub key that launched this command.
|
|
89
|
+
if (typeof stdin.read === "function") {
|
|
90
|
+
for (;;) {
|
|
91
|
+
const pending = stdin.read();
|
|
92
|
+
if (pending === null) break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cleanup = () => {
|
|
97
|
+
stdin.off("data", onData);
|
|
98
|
+
if (typeof stdin.setRawMode === "function") {
|
|
99
|
+
stdin.setRawMode(false);
|
|
100
|
+
}
|
|
101
|
+
stdin.pause();
|
|
102
|
+
stdin.unref();
|
|
103
|
+
};
|
|
104
|
+
|
|
77
105
|
const onData = (chunk: string | Buffer) => {
|
|
78
106
|
const s = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
79
|
-
if (s
|
|
80
|
-
|
|
81
|
-
if (typeof process.stdin.setRawMode === "function") {
|
|
82
|
-
process.stdin.setRawMode(false);
|
|
83
|
-
}
|
|
107
|
+
if (s.includes("\r") || s.includes("\n") || s.includes("\x03")) {
|
|
108
|
+
cleanup();
|
|
84
109
|
resolve();
|
|
85
110
|
}
|
|
86
111
|
};
|
|
87
|
-
|
|
112
|
+
stdin.on("data", onData);
|
|
88
113
|
});
|
|
89
114
|
}
|
package/src/tui/slash.ts
ADDED
|
@@ -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
|
+
}
|