gflows 0.1.18 → 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/AGENTS.md +78 -0
- package/CHANGELOG.md +48 -0
- package/README.md +279 -505
- package/llms.txt +22 -0
- package/package.json +29 -23
- package/src/cli.ts +21 -367
- package/src/commands/abort.ts +39 -0
- package/src/commands/bump.ts +10 -10
- package/src/commands/completion.ts +14 -4
- package/src/commands/config.ts +123 -0
- package/src/commands/continue.ts +40 -0
- package/src/commands/delete.ts +4 -4
- package/src/commands/doctor.ts +143 -0
- package/src/commands/finish.ts +363 -137
- package/src/commands/help.ts +29 -21
- package/src/commands/init.ts +99 -18
- package/src/commands/list.ts +6 -1
- package/src/commands/mcp.ts +239 -0
- package/src/commands/pr.ts +120 -0
- package/src/commands/schema.ts +99 -0
- package/src/commands/start.ts +33 -31
- package/src/commands/status.ts +70 -51
- package/src/commands/switch.ts +14 -19
- package/src/commands/sync.ts +160 -0
- package/src/commands/undo.ts +78 -0
- package/src/commands/version.ts +3 -15
- package/src/commands/viz.ts +18 -0
- package/src/dispatch.ts +21 -0
- package/src/errors.ts +55 -12
- package/src/flow.ts +157 -0
- package/src/git.ts +135 -8
- package/src/index.ts +24 -1
- package/src/interactive.ts +209 -0
- package/src/out.ts +11 -4
- package/src/package-scripts.ts +73 -0
- package/src/parse.ts +430 -0
- package/src/prompts.ts +89 -0
- package/src/recommend.ts +132 -0
- package/src/run-state.ts +165 -0
- package/src/tui/HubHome.tsx +391 -0
- package/src/tui/HubShell.tsx +193 -0
- package/src/tui/flows.tsx +229 -0
- package/src/tui/hub.ts +114 -0
- package/src/tui/prompts.tsx +207 -0
- package/src/tui/slash.ts +63 -0
- package/src/types.ts +62 -3
- package/src/ui.ts +132 -0
- package/src/version.ts +24 -0
- package/src/viz.ts +294 -0
package/src/run-state.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist multi-step gflows operations so continue / undo / abort can recover.
|
|
3
|
+
* State lives under .git/gflows/ (gitignored by nature of .git).
|
|
4
|
+
* @module run-state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** One planned step in a multi-step operation. */
|
|
11
|
+
export interface RunStep {
|
|
12
|
+
/** Step id for resume (e.g. merge-main, tag, merge-dev, delete, push). */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Human description. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Opaque payload for the step executor. */
|
|
17
|
+
data?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Suspended or completed run record. */
|
|
21
|
+
export interface GflowsRunState {
|
|
22
|
+
/** Command that created this run (finish, sync, …). */
|
|
23
|
+
command: string;
|
|
24
|
+
/** ISO timestamp when started. */
|
|
25
|
+
startedAt: string;
|
|
26
|
+
/** Status of the run. */
|
|
27
|
+
status: "running" | "suspended" | "completed";
|
|
28
|
+
/** Index of next step to execute. */
|
|
29
|
+
nextStep: number;
|
|
30
|
+
/** Planned steps. */
|
|
31
|
+
steps: RunStep[];
|
|
32
|
+
/** Snapshot for undo (branch tips, tag created, previous HEAD, etc.). */
|
|
33
|
+
undo: Record<string, unknown>;
|
|
34
|
+
/** Extra context (branch, type, config names, flags). */
|
|
35
|
+
context: Record<string, unknown>;
|
|
36
|
+
/** Last error message when suspended. */
|
|
37
|
+
lastError?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STATE_DIR = "gflows";
|
|
41
|
+
const ACTIVE_FILE = "run.json";
|
|
42
|
+
const LAST_FILE = "last.json";
|
|
43
|
+
|
|
44
|
+
function gitGflowsDir(repoRoot: string): string {
|
|
45
|
+
return join(repoRoot, ".git", STATE_DIR);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function activePath(repoRoot: string): string {
|
|
49
|
+
return join(gitGflowsDir(repoRoot), ACTIVE_FILE);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function lastPath(repoRoot: string): string {
|
|
53
|
+
return join(gitGflowsDir(repoRoot), LAST_FILE);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ensureDir(repoRoot: string): void {
|
|
57
|
+
const dir = gitGflowsDir(repoRoot);
|
|
58
|
+
if (!existsSync(dir)) {
|
|
59
|
+
mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads the active (suspended/running) run state, or null.
|
|
65
|
+
*/
|
|
66
|
+
export function readActiveRun(repoRoot: string): GflowsRunState | null {
|
|
67
|
+
const path = activePath(repoRoot);
|
|
68
|
+
if (!existsSync(path)) return null;
|
|
69
|
+
try {
|
|
70
|
+
const raw = readFileSync(path, "utf-8");
|
|
71
|
+
return JSON.parse(raw) as GflowsRunState;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reads the last completed run (for undo), or null.
|
|
79
|
+
*/
|
|
80
|
+
export function readLastRun(repoRoot: string): GflowsRunState | null {
|
|
81
|
+
const path = lastPath(repoRoot);
|
|
82
|
+
if (!existsSync(path)) return null;
|
|
83
|
+
try {
|
|
84
|
+
const raw = readFileSync(path, "utf-8");
|
|
85
|
+
return JSON.parse(raw) as GflowsRunState;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Writes active run state.
|
|
93
|
+
*/
|
|
94
|
+
export function writeActiveRun(repoRoot: string, state: GflowsRunState): void {
|
|
95
|
+
ensureDir(repoRoot);
|
|
96
|
+
writeFileSync(activePath(repoRoot), `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clears active run state (after abort or successful complete move to last).
|
|
101
|
+
*/
|
|
102
|
+
export function clearActiveRun(repoRoot: string): void {
|
|
103
|
+
const path = activePath(repoRoot);
|
|
104
|
+
if (existsSync(path)) {
|
|
105
|
+
unlinkSync(path);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Marks run completed and moves it to last.json for undo; clears active.
|
|
111
|
+
*/
|
|
112
|
+
export function completeRun(repoRoot: string, state: GflowsRunState): void {
|
|
113
|
+
ensureDir(repoRoot);
|
|
114
|
+
const done: GflowsRunState = {
|
|
115
|
+
...state,
|
|
116
|
+
status: "completed",
|
|
117
|
+
nextStep: state.steps.length,
|
|
118
|
+
};
|
|
119
|
+
writeFileSync(lastPath(repoRoot), `${JSON.stringify(done, null, 2)}\n`, "utf-8");
|
|
120
|
+
clearActiveRun(repoRoot);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Clears last completed run (after undo).
|
|
125
|
+
*/
|
|
126
|
+
export function clearLastRun(repoRoot: string): void {
|
|
127
|
+
const path = lastPath(repoRoot);
|
|
128
|
+
if (existsSync(path)) {
|
|
129
|
+
unlinkSync(path);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Creates a new running state and persists it.
|
|
135
|
+
*/
|
|
136
|
+
export function startRun(
|
|
137
|
+
repoRoot: string,
|
|
138
|
+
command: string,
|
|
139
|
+
steps: RunStep[],
|
|
140
|
+
context: Record<string, unknown>,
|
|
141
|
+
undo: Record<string, unknown>,
|
|
142
|
+
): GflowsRunState {
|
|
143
|
+
const state: GflowsRunState = {
|
|
144
|
+
command,
|
|
145
|
+
startedAt: new Date().toISOString(),
|
|
146
|
+
status: "running",
|
|
147
|
+
nextStep: 0,
|
|
148
|
+
steps,
|
|
149
|
+
undo,
|
|
150
|
+
context,
|
|
151
|
+
};
|
|
152
|
+
writeActiveRun(repoRoot, state);
|
|
153
|
+
return state;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Suspends the active run after a failure (e.g. merge conflict).
|
|
158
|
+
*/
|
|
159
|
+
export function suspendRun(repoRoot: string, state: GflowsRunState, lastError: string): void {
|
|
160
|
+
writeActiveRun(repoRoot, {
|
|
161
|
+
...state,
|
|
162
|
+
status: "suspended",
|
|
163
|
+
lastError,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub home screen — status map, actions, slash prompt (Ink).
|
|
3
|
+
* @module tui/HubHome
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
8
|
+
import type React from "react";
|
|
9
|
+
import { useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { getCurrentBranch, resolveRepoRoot } from "../git.js";
|
|
11
|
+
import { type RecommendAction, recommend } from "../recommend.js";
|
|
12
|
+
import { getVersion } from "../version.js";
|
|
13
|
+
import { collectVizSnapshot, type VizSnapshot } from "../viz.js";
|
|
14
|
+
import { completeSlashInput, filterSlashCommands } from "./slash.js";
|
|
15
|
+
|
|
16
|
+
const ACCENT = "#E88C4A";
|
|
17
|
+
const MUTED = "#8A8A8A";
|
|
18
|
+
const FG = "#E6E6E6";
|
|
19
|
+
const GREEN = "#78C88C";
|
|
20
|
+
const YELLOW = "#DCB450";
|
|
21
|
+
|
|
22
|
+
type ActionItem = { id: string; label: string; hint?: string };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Props for the hub home view.
|
|
26
|
+
*/
|
|
27
|
+
export interface HubHomeProps {
|
|
28
|
+
cwd: string;
|
|
29
|
+
flash?: string;
|
|
30
|
+
onAction: (id: string) => void;
|
|
31
|
+
onSlash: (command: string) => void;
|
|
32
|
+
onQuit: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Main hub dashboard.
|
|
37
|
+
*/
|
|
38
|
+
export function HubHome({
|
|
39
|
+
cwd,
|
|
40
|
+
flash,
|
|
41
|
+
onAction,
|
|
42
|
+
onSlash,
|
|
43
|
+
onQuit,
|
|
44
|
+
}: HubHomeProps): React.ReactElement {
|
|
45
|
+
const { stdout } = useStdout();
|
|
46
|
+
const cols = stdout?.columns ?? 80;
|
|
47
|
+
const rows = stdout?.rows ?? 24;
|
|
48
|
+
|
|
49
|
+
const [snap, setSnap] = useState<VizSnapshot | null>(null);
|
|
50
|
+
const [branch, setBranch] = useState("—");
|
|
51
|
+
const [repoPath, setRepoPath] = useState(cwd);
|
|
52
|
+
const [selected, setSelected] = useState(0);
|
|
53
|
+
const [input, setInput] = useState("");
|
|
54
|
+
const [slashIndex, setSlashIndex] = useState(0);
|
|
55
|
+
const [showHelp, setShowHelp] = useState(false);
|
|
56
|
+
const [loading, setLoading] = useState(true);
|
|
57
|
+
const version = useMemo(() => getVersion(), []);
|
|
58
|
+
const slashMatches = useMemo(() => filterSlashCommands(input), [input]);
|
|
59
|
+
const slashMode = input.startsWith("/");
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
void (async () => {
|
|
64
|
+
try {
|
|
65
|
+
const root = await resolveRepoRoot(cwd);
|
|
66
|
+
const b = (await getCurrentBranch(root, {})) ?? "detached";
|
|
67
|
+
const s = await collectVizSnapshot(cwd);
|
|
68
|
+
if (!cancelled) {
|
|
69
|
+
setRepoPath(root);
|
|
70
|
+
setBranch(b);
|
|
71
|
+
setSnap(s);
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
if (!cancelled) {
|
|
75
|
+
setSnap(null);
|
|
76
|
+
setBranch("—");
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
if (!cancelled) setLoading(false);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
return () => {
|
|
83
|
+
cancelled = true;
|
|
84
|
+
};
|
|
85
|
+
}, [cwd]);
|
|
86
|
+
|
|
87
|
+
const rec = snap ? recommend(snap) : null;
|
|
88
|
+
const actions = useMemo(() => buildActions(snap, rec?.action ?? null), [snap, rec?.action]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (selected >= actions.length) setSelected(Math.max(0, actions.length - 1));
|
|
92
|
+
}, [actions.length, selected]);
|
|
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
|
+
|
|
105
|
+
useInput((ch, key) => {
|
|
106
|
+
if (key.ctrl && ch === "c") {
|
|
107
|
+
onQuit();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (key.escape) {
|
|
111
|
+
if (input || showHelp) {
|
|
112
|
+
setSlashInput("");
|
|
113
|
+
setShowHelp(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
onQuit();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (ch === "?" && input === "") {
|
|
120
|
+
setShowHelp((v) => !v);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (ch === "q" && input === "") {
|
|
124
|
+
onQuit();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (key.tab && slashMode) {
|
|
128
|
+
const next = completeSlashInput(input, slashIndex);
|
|
129
|
+
if (next !== null) setSlashInput(next);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (key.upArrow) {
|
|
133
|
+
if (slashMode && slashMatches.length > 0) {
|
|
134
|
+
setSlashIndex((i) => Math.max(0, i - 1));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
setSelected((i) => Math.max(0, i - 1));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key.downArrow) {
|
|
141
|
+
if (slashMode && slashMatches.length > 0) {
|
|
142
|
+
setSlashIndex((i) => Math.min(slashMatches.length - 1, i + 1));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
setSelected((i) => Math.min(actions.length - 1, i + 1));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (key.backspace || key.delete) {
|
|
149
|
+
setSlashInput([...input].slice(0, -1).join(""));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (key.return) {
|
|
153
|
+
const cmd = input.trim();
|
|
154
|
+
if (cmd.startsWith("/")) {
|
|
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);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const action = actions[selected];
|
|
165
|
+
if (!action || action.id === "quit") {
|
|
166
|
+
onQuit();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
onAction(action.id);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (ch && !key.ctrl && !key.meta && ch >= " ") {
|
|
173
|
+
setSlashInput(input + ch);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const pathShort = shortPath(repoPath);
|
|
178
|
+
const tips = tipLines(snap);
|
|
179
|
+
const next = nextLines(rec, snap);
|
|
180
|
+
const branches = branchLines(snap);
|
|
181
|
+
const listBudget = Math.max(3, Math.min(8, rows - 18));
|
|
182
|
+
const start = Math.max(0, Math.min(selected - listBudget + 2, actions.length - listBudget));
|
|
183
|
+
const visible = actions.slice(start, start + listBudget);
|
|
184
|
+
const frameWidth = Math.max(40, cols - 2);
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<Box flexDirection="column" width={cols} height={rows}>
|
|
188
|
+
<Box
|
|
189
|
+
flexDirection="column"
|
|
190
|
+
borderStyle="round"
|
|
191
|
+
borderColor={ACCENT}
|
|
192
|
+
width={frameWidth}
|
|
193
|
+
paddingX={1}
|
|
194
|
+
>
|
|
195
|
+
<Text bold color={FG}>
|
|
196
|
+
Welcome to gflows{" "}
|
|
197
|
+
<Text color={MUTED} dimColor>
|
|
198
|
+
v{version}
|
|
199
|
+
</Text>
|
|
200
|
+
</Text>
|
|
201
|
+
<Text color={MUTED}>Git branching workflow · main + dev + short-lived branches</Text>
|
|
202
|
+
<Text color={MUTED}>{pathShort}</Text>
|
|
203
|
+
|
|
204
|
+
{flash ? (
|
|
205
|
+
<Box marginTop={1}>
|
|
206
|
+
<Text color={YELLOW}>│ {flash}</Text>
|
|
207
|
+
</Box>
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
<Box marginTop={1} flexDirection="row">
|
|
211
|
+
<Box width="50%" flexDirection="column" paddingRight={1}>
|
|
212
|
+
<Text bold color={ACCENT}>
|
|
213
|
+
Tips for getting started
|
|
214
|
+
</Text>
|
|
215
|
+
{tips.map((line) => (
|
|
216
|
+
<Text key={line} color={MUTED}>
|
|
217
|
+
{line}
|
|
218
|
+
</Text>
|
|
219
|
+
))}
|
|
220
|
+
</Box>
|
|
221
|
+
<Box width="50%" flexDirection="column">
|
|
222
|
+
<Text bold color={ACCENT}>
|
|
223
|
+
What's next
|
|
224
|
+
</Text>
|
|
225
|
+
{next.map((line) => (
|
|
226
|
+
<Text
|
|
227
|
+
key={line}
|
|
228
|
+
color={line.startsWith("→") ? GREEN : MUTED}
|
|
229
|
+
bold={line.startsWith("→")}
|
|
230
|
+
>
|
|
231
|
+
{line}
|
|
232
|
+
</Text>
|
|
233
|
+
))}
|
|
234
|
+
</Box>
|
|
235
|
+
</Box>
|
|
236
|
+
|
|
237
|
+
<Box marginY={1} flexDirection="column">
|
|
238
|
+
<Text bold color={FG}>
|
|
239
|
+
Branches
|
|
240
|
+
</Text>
|
|
241
|
+
{loading ? (
|
|
242
|
+
<Text color={MUTED}>Loading…</Text>
|
|
243
|
+
) : (
|
|
244
|
+
branches.slice(0, 7).map((line) => (
|
|
245
|
+
<Text key={line} color={MUTED}>
|
|
246
|
+
{line}
|
|
247
|
+
</Text>
|
|
248
|
+
))
|
|
249
|
+
)}
|
|
250
|
+
</Box>
|
|
251
|
+
|
|
252
|
+
<Text color={MUTED}>Actions · ↑↓ select · enter · /command (wizards stay in-app)</Text>
|
|
253
|
+
{visible.map((a, i) => {
|
|
254
|
+
const abs = start + i;
|
|
255
|
+
const active = abs === selected;
|
|
256
|
+
return (
|
|
257
|
+
<Text key={a.id} color={active ? FG : MUTED} bold={active}>
|
|
258
|
+
{active ? <Text color={ACCENT}>❯ </Text> : " "}
|
|
259
|
+
{a.label}
|
|
260
|
+
{a.hint ? (
|
|
261
|
+
<Text color={MUTED}>
|
|
262
|
+
{" "}
|
|
263
|
+
{a.hint}
|
|
264
|
+
</Text>
|
|
265
|
+
) : null}
|
|
266
|
+
</Text>
|
|
267
|
+
);
|
|
268
|
+
})}
|
|
269
|
+
|
|
270
|
+
<Box marginTop={1} flexDirection="column">
|
|
271
|
+
<Text>
|
|
272
|
+
<Text color={ACCENT}>❯ </Text>
|
|
273
|
+
{input || <Text color={MUTED}>type / for commands…</Text>}
|
|
274
|
+
{input ? <Text color={ACCENT}>█</Text> : null}
|
|
275
|
+
</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
|
+
)}
|
|
296
|
+
</Box>
|
|
297
|
+
</Box>
|
|
298
|
+
|
|
299
|
+
<Box width={cols} justifyContent="space-between">
|
|
300
|
+
<Text backgroundColor="#282828" color={MUTED}>
|
|
301
|
+
{" "}
|
|
302
|
+
<Text color={ACCENT}>/init</Text> start sync pr finish{" "}
|
|
303
|
+
</Text>
|
|
304
|
+
<Text backgroundColor="#282828" color={MUTED}>
|
|
305
|
+
{" "}
|
|
306
|
+
{pathShort} <Text color={ACCENT}></Text> <Text color={FG}>{branch}</Text>{" "}
|
|
307
|
+
</Text>
|
|
308
|
+
</Box>
|
|
309
|
+
{rec?.action === "init" ? <Text color={YELLOW}> Setup incomplete — run /init</Text> : null}
|
|
310
|
+
</Box>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function shortPath(p: string): string {
|
|
315
|
+
const home = homedir();
|
|
316
|
+
if (p.startsWith(home)) return `~${p.slice(home.length)}`;
|
|
317
|
+
return p;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function tipLines(snap: VizSnapshot | null): string[] {
|
|
321
|
+
if (!snap || snap.needsInit) {
|
|
322
|
+
return [
|
|
323
|
+
"1. /init or select Initialize",
|
|
324
|
+
"2. /start (wizard in hub)",
|
|
325
|
+
"3. commit → /sync · /pr · /finish",
|
|
326
|
+
];
|
|
327
|
+
}
|
|
328
|
+
return [
|
|
329
|
+
"• /start — wizard stays in this UI",
|
|
330
|
+
"• /sync — update from base",
|
|
331
|
+
"• /pr then /finish when ready",
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function nextLines(rec: ReturnType<typeof recommend> | null, snap: VizSnapshot | null): string[] {
|
|
336
|
+
if (!rec) return ["Not a git repo", "cd into a repo, then reopen"];
|
|
337
|
+
return [`→ ${rec.label}`, rec.detail, snap?.current ? `on ${snap.current}` : ""].filter(Boolean);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function branchLines(snap: VizSnapshot | null): string[] {
|
|
341
|
+
if (!snap) return ["(no repo)"];
|
|
342
|
+
const lines: string[] = [];
|
|
343
|
+
if (snap.suspended) lines.push(`⚠ suspended: ${snap.suspended}`);
|
|
344
|
+
for (const row of snap.rows) {
|
|
345
|
+
if (row.type !== "main" && row.type !== "dev") continue;
|
|
346
|
+
const mark = row.current ? "●" : "○";
|
|
347
|
+
lines.push(`${mark} ${row.name} [${row.type}]`);
|
|
348
|
+
}
|
|
349
|
+
const workflow = snap.rows.filter((r) => r.type !== "main" && r.type !== "dev");
|
|
350
|
+
if (workflow.length === 0) {
|
|
351
|
+
lines.push(" (no workflow branches)");
|
|
352
|
+
} else {
|
|
353
|
+
for (let i = 0; i < workflow.length; i++) {
|
|
354
|
+
const row = workflow[i];
|
|
355
|
+
if (!row) continue;
|
|
356
|
+
const elbow = i === workflow.length - 1 ? "└─" : "├─";
|
|
357
|
+
const mark = row.current ? "●" : "○";
|
|
358
|
+
const ab = row.ahead === 0 && row.behind === 0 ? "synced" : `+${row.ahead}/-${row.behind}`;
|
|
359
|
+
lines.push(`${elbow} ${mark} ${row.name} (${row.type}) ${ab}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return lines;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function buildActions(snap: VizSnapshot | null, recommended: RecommendAction | null): ActionItem[] {
|
|
366
|
+
const items: ActionItem[] = [];
|
|
367
|
+
const push = (id: string, label: string, hint?: string) => {
|
|
368
|
+
items.push({ id, label, hint });
|
|
369
|
+
};
|
|
370
|
+
if (snap?.needsInit) push("init", "Initialize repo", "/init");
|
|
371
|
+
push("start", "Start new work", "/start");
|
|
372
|
+
push("sync", "Sync with base", "/sync");
|
|
373
|
+
push("pr", "Open pull request", "/pr");
|
|
374
|
+
push("finish", "Finish / merge branch", "/finish");
|
|
375
|
+
if (snap?.suspended) push("continue", "Continue suspended", "/continue");
|
|
376
|
+
push("switch", "Switch branch", "/switch");
|
|
377
|
+
push("list", "List branches", "/list");
|
|
378
|
+
push("doctor", "Doctor", "/doctor");
|
|
379
|
+
push("help", "Help", "/help");
|
|
380
|
+
push("quit", "Quit", "q");
|
|
381
|
+
|
|
382
|
+
const prefer = recommended && recommended !== "commit" ? recommended : null;
|
|
383
|
+
if (prefer) {
|
|
384
|
+
const idx = items.findIndex((a) => a.id === prefer);
|
|
385
|
+
if (idx >= 0) {
|
|
386
|
+
const [item] = items.splice(idx, 1);
|
|
387
|
+
if (item) items.unshift({ ...item, label: `★ ${item.label.replace(/^★ /, "")}` });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return items;
|
|
391
|
+
}
|