kaizen-agent 1.7.1 → 1.7.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/LICENSE +21 -21
- package/README.md +657 -657
- package/agents/kaizen-builder.md +77 -77
- package/agents/kaizen-planner.md +250 -250
- package/agents/kaizen-reviewer.md +127 -127
- package/cli/dashboard.ts +1187 -1187
- package/cli/install.ts +947 -947
- package/cli/publish-gh.sh +27 -27
- package/cli/release.sh +45 -45
- package/cli/settings.ts +415 -415
- package/cli/state.test.ts +197 -197
- package/cli/state.ts +704 -704
- package/cli/web.ts +423 -423
- package/commands/kaizen-abort.md +5 -5
- package/commands/kaizen-approve.md +5 -5
- package/commands/kaizen-auto.md +6 -6
- package/commands/kaizen-backlog.md +5 -5
- package/commands/kaizen-config.md +5 -5
- package/commands/kaizen-full.md +5 -5
- package/commands/kaizen-init.md +5 -5
- package/commands/kaizen-install.md +5 -5
- package/commands/kaizen-lite.md +5 -5
- package/commands/kaizen-plan.md +6 -6
- package/commands/kaizen-reject.md +6 -6
- package/commands/kaizen-review.md +6 -6
- package/commands/kaizen-run.md +5 -5
- package/commands/kaizen-status.md +5 -5
- package/package.json +1 -1
- package/skills/kaizen/README.md +281 -281
- package/skills/kaizen/SKILL.md +440 -440
- package/skills/kaizen/adapters.md +77 -77
- package/skills/kaizen/config.default.yml +167 -167
- package/skills/kaizen/spec-build.md +119 -119
- package/skills/kaizen/spec-main.md +104 -104
- package/skills/kaizen/spec-plan.md +201 -201
- package/skills/kaizen/spec-review.md +121 -121
- package/skills/kaizen/spec.md +153 -153
- package/skills/kaizen-help/SKILL.md +129 -129
- package/web/board.html +520 -513
package/cli/dashboard.ts
CHANGED
|
@@ -1,1187 +1,1187 @@
|
|
|
1
|
-
// What `kaizen` shows once it is installed: the state of this project's runs, and
|
|
2
|
-
// the few things you would have opened a terminal to do.
|
|
3
|
-
import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
|
|
4
|
-
import { join, dirname, basename } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
home, type Run, type Item, type Card, type InboxLine, type Status,
|
|
7
|
-
KNOWN_AGENTS, detectDefaultAgent, findTerminal,
|
|
8
|
-
searchRoots, knownProjects, remember, findProjects, locate,
|
|
9
|
-
readRuns, backlog, version, label, tilde, section, readFindings, parseItem, tidy, readBacklog, allBacklog,
|
|
10
|
-
startedRuns, normalise, statusOf, columnOf, readInbox, writeInbox, notify, short,
|
|
11
|
-
abandonRun, replaceIdea, boardCards, launchRun, appendNote, parseNotes,
|
|
12
|
-
} from "./state.ts";
|
|
13
|
-
const rgb = (r: number, g: number, b: number, s: string) =>
|
|
14
|
-
`\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
15
|
-
|
|
16
|
-
const c = {
|
|
17
|
-
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
18
|
-
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
19
|
-
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
20
|
-
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
21
|
-
amber: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
22
|
-
// Red is spent on one thing only: a run that cannot move until the user acts.
|
|
23
|
-
red: (s: string) => rgb(232, 80, 80, s),
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// The mascot, rendered from assets/kaizen.jpg. Braille packs 2x4 dots into one
|
|
27
|
-
// character, which is the only way outline art survives a downsample this far.
|
|
28
|
-
const TANUKI = [
|
|
29
|
-
"⠀⠀⠀⣰⠖⠾⣟⣛⠋⢉⣩⠽⢛⡽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
30
|
-
"⠀⠀⢰⢻⠀⠀⢀⡬⠟⠉⢀⠴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
31
|
-
"⠀⢠⠇⣾⡠⠞⢩⠤⢄⡚⣁⣀⣀⣀⢀⣠⠤⡄⠀⠀⠀⠀⠀⠀",
|
|
32
|
-
"⢠⣏⠔⣫⣀⡤⢸⠀⠀⠉⠁⠀⠀⠈⠉⠀⠀⡇⠀⠀⠀⠀⠀⠀",
|
|
33
|
-
"⢾⠥⢾⡅⢧⠀⢸⠃⡤⠖⠢⡀⢀⠔⠲⢤⠘⡇⠀⠀⣀⣀⠀⠀",
|
|
34
|
-
"⠀⠀⠈⣇⠈⢠⣫⠞⠀⠘⢱⢣⡜⡎⠃⠀⠳⣝⡄⢠⠇⢻⢉⣷",
|
|
35
|
-
"⠀⠀⠀⠘⢆⠀⠑⢦⣀⡀⠈⠓⠚⠁⢀⣀⣤⣊⣠⢾⡀⢠⣿⠃",
|
|
36
|
-
"⠀⠀⠀⠀⠈⠓⢄⠀⠀⠉⣉⣭⣭⣉⠉⠀⠈⢀⣠⢔⡩⠥⢥⡀",
|
|
37
|
-
"⠀⠀⠀⠀⠀⠀⢠⡇⠀⡞⠁⠀⠀⠈⢳⠀⢰⣍⣠⠚⠧⡀⠀⡷",
|
|
38
|
-
"⠀⠀⠀⠀⠀⠀⠸⡇⠀⣇⠀⠀⠀⠀⣸⠀⢠⡏⠀⠀⢀⣳⠞⠁",
|
|
39
|
-
"⠀⠀⠀⠀⠀⠀⠀⠳⣀⣨⠥⠤⠶⠾⣅⣀⠞⠉⠉⠉⠉⠀⠀⠀",
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
// Compact block wordmark (28 columns x 3 rows).
|
|
43
|
-
const WORDMARK = [
|
|
44
|
-
"█ █ ▄▀█ █ ▀▀█ █▀▀ █▄ █",
|
|
45
|
-
"█▀▄ █▀█ █ ▄▀ ██▄ █ ▀█",
|
|
46
|
-
"█ █ █ █ █ █▄▄ █▄▄ █ █",
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
// ---------------------------------------------------------------- mouse
|
|
50
|
-
|
|
51
|
-
// SGR reporting (1006), not X10: X10 encodes coordinates as single bytes and gives
|
|
52
|
-
// up past column 223, which a five-column board reaches on any wide terminal.
|
|
53
|
-
type MouseEv = { button: number; x: number; y: number; press: boolean };
|
|
54
|
-
|
|
55
|
-
let mouseWanted = false; // what config.yml asked for
|
|
56
|
-
let mouseArmed = false; // what the terminal is actually in
|
|
57
|
-
|
|
58
|
-
function readMouse(rest: string): { ev: MouseEv; len: number } | null {
|
|
59
|
-
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
|
|
60
|
-
if (!m) return null;
|
|
61
|
-
return {
|
|
62
|
-
ev: { button: +m[1]!, x: +m[2]!, y: +m[3]!, press: m[4] === "M" },
|
|
63
|
-
len: m[0].length,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const WHEEL_UP = 64, WHEEL_DOWN = 65;
|
|
68
|
-
const isClick = (e: MouseEv) => e.press && e.button === 0;
|
|
69
|
-
|
|
70
|
-
// Re-sent even when this module thinks it is already armed: settings runs as an
|
|
71
|
-
// action, tracks arming with its own flag, and turns reporting off in the terminal
|
|
72
|
-
// on the way out. Enabling twice costs nothing; trusting the stale flag costs the
|
|
73
|
-
// mouse for the rest of the session.
|
|
74
|
-
function mouseOn() {
|
|
75
|
-
if (!mouseWanted) return;
|
|
76
|
-
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
|
77
|
-
mouseArmed = true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function mouseOff() {
|
|
81
|
-
if (!mouseArmed) return;
|
|
82
|
-
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
83
|
-
mouseArmed = false;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Ctrl-C calls process.exit, which unwinds no finally block anywhere. A terminal
|
|
87
|
-
// left reporting spits escape junk at every click until `reset`, so this guard has
|
|
88
|
-
// to survive an abrupt exit.
|
|
89
|
-
process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
|
|
90
|
-
|
|
91
|
-
// Off unless the config says otherwise: while reporting is on, the terminal cannot
|
|
92
|
-
// select text with the mouse.
|
|
93
|
-
function mouseFromConfig(state: string | null) {
|
|
94
|
-
for (const f of [state ? join(state, "config.yml") : null, join(home, ".kaizen", "config.yml")]) {
|
|
95
|
-
if (!f || !existsSync(f)) continue;
|
|
96
|
-
const m = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(f, "utf8"));
|
|
97
|
-
if (m) return m[1] === "true";
|
|
98
|
-
}
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export async function dashboard(
|
|
103
|
-
repo: string,
|
|
104
|
-
agents: string[],
|
|
105
|
-
run: (a: string) => Promise<string[] | void>,
|
|
106
|
-
) {
|
|
107
|
-
const { stdin, stdout } = process;
|
|
108
|
-
const state = locate();
|
|
109
|
-
mouseWanted = mouseFromConfig(state);
|
|
110
|
-
// Opening a project is what makes it known; nothing else asks the user to register.
|
|
111
|
-
if (state && dirname(state) !== home) remember(dirname(state));
|
|
112
|
-
|
|
113
|
-
// Every known project's runs and open items, tagged with where they came from.
|
|
114
|
-
// draw() runs on every keypress, so this is gathered when the dashboard opens and
|
|
115
|
-
// after an action returns -- never inside draw().
|
|
116
|
-
type Seen = { runs: (Run & { where: string })[]; items: { text: string; where: string }[] };
|
|
117
|
-
let seen: Seen = { runs: [], items: [] };
|
|
118
|
-
|
|
119
|
-
const survey = () => {
|
|
120
|
-
const dirs = [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
121
|
-
.filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
|
|
122
|
-
const runs: Seen["runs"] = [], items: Seen["items"] = [];
|
|
123
|
-
for (const dir of dirs) {
|
|
124
|
-
const where = label(dir);
|
|
125
|
-
for (const r of readRuns(dir)) runs.push({ ...r, where });
|
|
126
|
-
for (const g of allBacklog(dir)) for (const t of g.items) items.push({ text: t, where });
|
|
127
|
-
}
|
|
128
|
-
seen = { runs, items };
|
|
129
|
-
};
|
|
130
|
-
survey();
|
|
131
|
-
|
|
132
|
-
const actions = () => [
|
|
133
|
-
...(knownProjects().length || !state ? [
|
|
134
|
-
{ key: "projects", label: "All projects", hint: "every project kaizen knows about" },
|
|
135
|
-
] : []),
|
|
136
|
-
...(state ? [
|
|
137
|
-
{ key: "board", label: "Board", hint: "ideas and runs, by stage" },
|
|
138
|
-
{ key: "runs", label: "Runs", hint: "every run, and what each is waiting on" },
|
|
139
|
-
{ key: "backlog", label: "Backlog", hint: "things to do later" },
|
|
140
|
-
] : []),
|
|
141
|
-
{ key: "settings", label: "Settings", hint: "change how kaizen works" },
|
|
142
|
-
// ~/.kaizen is the global state, not a project: there is nothing to set up
|
|
143
|
-
// there, and it never holds the spec files a project copy does.
|
|
144
|
-
...(state && state !== join(home, ".kaizen") && !existsSync(join(state, "spec.md"))
|
|
145
|
-
? [{ key: "init", label: "Set up this project", hint: "add kaizen to this folder" }] : []),
|
|
146
|
-
...(!state ? [{ key: "init", label: "Set up this project", hint: "add kaizen to this folder" }] : []),
|
|
147
|
-
{ key: "upgrade", label: "Upgrade", hint: "get the latest version" },
|
|
148
|
-
{ key: "quit", label: "Quit", hint: "" },
|
|
149
|
-
];
|
|
150
|
-
|
|
151
|
-
let active = 0;
|
|
152
|
-
mouseOn();
|
|
153
|
-
try {
|
|
154
|
-
for (;;) {
|
|
155
|
-
const chosen = await menu();
|
|
156
|
-
if (chosen === "quit") return;
|
|
157
|
-
if (chosen === "projects") { await projectsView(); continue; }
|
|
158
|
-
if (chosen === "board") { await boardView(); continue; }
|
|
159
|
-
if (chosen === "runs") { await runsView(); continue; }
|
|
160
|
-
if (chosen === "backlog") { await backlogView(); continue; }
|
|
161
|
-
const lines = await run(chosen);
|
|
162
|
-
if (lines?.length) await report(lines);
|
|
163
|
-
survey(); // an action may have changed what exists
|
|
164
|
-
// Settings is one of those actions, and ui.mouse is one of its keys: without
|
|
165
|
-
// this the toggle does nothing until the next launch.
|
|
166
|
-
mouseWanted = mouseFromConfig(state);
|
|
167
|
-
if (mouseWanted) mouseOn(); else mouseOff();
|
|
168
|
-
}
|
|
169
|
-
} finally {
|
|
170
|
-
mouseOff();
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// An action that has something to say says it here, on its own screen, rather
|
|
174
|
-
// than printing to a terminal the dashboard is about to paint over.
|
|
175
|
-
async function report(lines: string[], footer = "any key to go back"): Promise<string> {
|
|
176
|
-
stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
|
|
177
|
-
stdout.write("\n");
|
|
178
|
-
for (const line of lines) stdout.write(` ${line}\n`);
|
|
179
|
-
stdout.write(`\n ${c.dim(footer)}\n`);
|
|
180
|
-
stdin.setRawMode(true);
|
|
181
|
-
stdin.resume();
|
|
182
|
-
let key = "";
|
|
183
|
-
await new Promise<void>((resolve) => {
|
|
184
|
-
const once = (chunk: Buffer) => {
|
|
185
|
-
const s = chunk.toString();
|
|
186
|
-
if (s.includes("\x03")) process.exit(130);
|
|
187
|
-
const mouse = readMouse(s);
|
|
188
|
-
// A click is a keypress on an any-key screen; the release that follows it
|
|
189
|
-
// must not dismiss the next screen too.
|
|
190
|
-
if (mouse && !isClick(mouse.ev)) return;
|
|
191
|
-
key = mouse ? "" : s;
|
|
192
|
-
stdin.off("data", once);
|
|
193
|
-
resolve();
|
|
194
|
-
};
|
|
195
|
-
stdin.on("data", once);
|
|
196
|
-
});
|
|
197
|
-
stdin.setRawMode(false);
|
|
198
|
-
stdin.pause();
|
|
199
|
-
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
200
|
-
return key;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ---------------------------------------------------------------- views
|
|
204
|
-
|
|
205
|
-
// One scrolling list, used by every view. Returns the chosen index, or null
|
|
206
|
-
// when the user backs out.
|
|
207
|
-
async function pick(
|
|
208
|
-
title: string,
|
|
209
|
-
rows: string[],
|
|
210
|
-
footer = "enter open · backspace back",
|
|
211
|
-
skip: (i: number) => boolean = () => false,
|
|
212
|
-
extra = "", // keys the caller wants to hear about
|
|
213
|
-
): Promise<{ index: number | null; key?: string }> {
|
|
214
|
-
if (!rows.length) { await report([c.bold(title), "", c.dim("nothing here yet")]); return { index: null }; }
|
|
215
|
-
|
|
216
|
-
// Headings are rows too, but they are not items: they are not counted, and
|
|
217
|
-
// moving passes over them rather than landing on them.
|
|
218
|
-
const pickable = rows.map((_, i) => i).filter((i) => !skip(i));
|
|
219
|
-
if (!pickable.length) { await report([c.bold(title), "", c.dim("nothing here yet")]); return { index: null }; }
|
|
220
|
-
let cursor = 0; // index into pickable
|
|
221
|
-
const move = (d: number) => { cursor = Math.min(pickable.length - 1, Math.max(0, cursor + d)); };
|
|
222
|
-
let top = 0;
|
|
223
|
-
const height = () => Math.max(5, (stdout.rows ?? 24) - 8);
|
|
224
|
-
|
|
225
|
-
const draw = () => {
|
|
226
|
-
const h = height();
|
|
227
|
-
const at = pickable[cursor]!;
|
|
228
|
-
if (at < top) top = at;
|
|
229
|
-
if (at >= top + h) top = at - h + 1;
|
|
230
|
-
stdout.write("\x1b[H\x1b[2J");
|
|
231
|
-
stdout.write(`\n ${c.bold(title)} ${c.dim(`${cursor + 1}/${pickable.length}`)}\n\n`);
|
|
232
|
-
for (const [i, row] of rows.slice(top, top + h).entries()) {
|
|
233
|
-
const real = top + i;
|
|
234
|
-
stdout.write(real === at ? ` ${c.cyan("›")} ${fit(row)}\n` : ` ${fit(row)}\n`);
|
|
235
|
-
}
|
|
236
|
-
if (rows.length > h) stdout.write(`\n ${c.dim(top + h < rows.length ? "↓ more" : "")}`);
|
|
237
|
-
stdout.write(`\n\n ${c.dim("↑↓ move · " + footer)}\n`);
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
241
|
-
stdin.setRawMode(true);
|
|
242
|
-
stdin.resume();
|
|
243
|
-
draw();
|
|
244
|
-
let chosen: number | null = null, hit: string | undefined;
|
|
245
|
-
await new Promise<void>((resolve) => {
|
|
246
|
-
const onData = (chunk: Buffer) => {
|
|
247
|
-
const keys = chunk.toString();
|
|
248
|
-
for (let i = 0; i < keys.length; i++) {
|
|
249
|
-
const rest = keys.slice(i);
|
|
250
|
-
if (rest.startsWith("\x03")) process.exit(130);
|
|
251
|
-
// Mouse reports are consumed here whether or not they mean anything, so
|
|
252
|
-
// an unexpected one is never read as a burst of keystrokes.
|
|
253
|
-
const mouse = readMouse(rest);
|
|
254
|
-
if (mouse) {
|
|
255
|
-
i += mouse.len - 1;
|
|
256
|
-
const { ev } = mouse;
|
|
257
|
-
if (ev.button === WHEEL_UP) move(-3);
|
|
258
|
-
else if (ev.button === WHEEL_DOWN) move(3);
|
|
259
|
-
else if (isClick(ev)) {
|
|
260
|
-
// Rows begin on the fourth line: blank, title, blank, then the list.
|
|
261
|
-
const real = top + (ev.y - 4);
|
|
262
|
-
const at = pickable.indexOf(real);
|
|
263
|
-
if (at < 0) continue; // a heading, or empty space
|
|
264
|
-
// Click to select; click the selected one to act.
|
|
265
|
-
if (at === cursor) { chosen = real; stdin.off("data", onData); return resolve(); }
|
|
266
|
-
cursor = at;
|
|
267
|
-
} else continue;
|
|
268
|
-
draw();
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (rest.startsWith("q") || rest === "\x1b" || rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
272
|
-
stdin.off("data", onData); return resolve();
|
|
273
|
-
}
|
|
274
|
-
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
275
|
-
chosen = pickable[cursor]!; stdin.off("data", onData); return resolve();
|
|
276
|
-
}
|
|
277
|
-
const k = [...extra].find((x) => rest.startsWith(x));
|
|
278
|
-
if (k) { hit = k; stdin.off("data", onData); return resolve(); }
|
|
279
|
-
if (rest.startsWith("\x1b[A")) { move(-1); i += 2; }
|
|
280
|
-
else if (rest.startsWith("\x1b[B")) { move(1); i += 2; }
|
|
281
|
-
else if (rest.startsWith("k")) move(-1);
|
|
282
|
-
else if (rest.startsWith("j")) move(1);
|
|
283
|
-
else continue;
|
|
284
|
-
draw();
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
stdin.on("data", onData);
|
|
288
|
-
});
|
|
289
|
-
stdin.setRawMode(false);
|
|
290
|
-
stdin.pause();
|
|
291
|
-
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
292
|
-
return { index: hit ? pickable[cursor]! : chosen, key: hit };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Every project kaizen knows about, with what each is waiting on. The global
|
|
296
|
-
// ~/.kaizen is one of them: it holds runs made outside any project.
|
|
297
|
-
async function projectsView() {
|
|
298
|
-
for (;;) {
|
|
299
|
-
const dirs = [...knownProjects(), join(home, ".kaizen")].filter(
|
|
300
|
-
(d, i, all) => all.indexOf(d) === i);
|
|
301
|
-
|
|
302
|
-
const rows: string[] = [];
|
|
303
|
-
const open: (string | null)[] = [];
|
|
304
|
-
const width = Math.max(...dirs.map((d) => tilde(d).length));
|
|
305
|
-
|
|
306
|
-
for (const dir of dirs) {
|
|
307
|
-
const st = dir.endsWith(".kaizen") ? dir : join(dir, ".kaizen");
|
|
308
|
-
if (!existsSync(st)) {
|
|
309
|
-
// Listed, not dropped: a project that moved should be visible and fixable,
|
|
310
|
-
// and the registry is a file the user can edit.
|
|
311
|
-
rows.push(`${c.dim(tilde(dir).padEnd(width))} ${c.amber("missing")}`);
|
|
312
|
-
open.push(null);
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
const runs = readRuns(st);
|
|
316
|
-
const waiting = runs.filter((r) => r.awaiting && r.stage !== "abandoned").length;
|
|
317
|
-
const flight = runs.filter((r) => !r.awaiting && !["done", "abandoned"].includes(r.stage)).length;
|
|
318
|
-
const done = runs.filter((r) => r.stage === "done").length;
|
|
319
|
-
const items = allBacklog(st).reduce((n, g) => n + g.items.length, 0);
|
|
320
|
-
const bits = [
|
|
321
|
-
waiting ? c.amber(`${waiting} waiting`) : "",
|
|
322
|
-
flight ? c.cyan(`${flight} running`) : "",
|
|
323
|
-
done ? c.dim(`${done} done`) : "",
|
|
324
|
-
items ? c.dim(`${items} open`) : "",
|
|
325
|
-
].filter(Boolean);
|
|
326
|
-
rows.push(`${tilde(dir).padEnd(width)} ${bits.join(c.dim(" · ")) || c.dim("nothing yet")}`);
|
|
327
|
-
open.push(st);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const { index, key } = await pick("All projects", rows,
|
|
331
|
-
"enter runs · b backlog · s find more · backspace back", () => false, "sb");
|
|
332
|
-
if (index === null) return;
|
|
333
|
-
if (key === "s") { await sync(); continue; } // the list rebuilds on the next pass
|
|
334
|
-
if (!open[index]) continue; // a project whose state has gone
|
|
335
|
-
if (key === "b") await backlogView(open[index]!);
|
|
336
|
-
else await runsView(open[index]!);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
async function sync() {
|
|
341
|
-
const before = knownProjects().length;
|
|
342
|
-
const started = Date.now();
|
|
343
|
-
// Only searching under $HOME misses every project on another drive or mount,
|
|
344
|
-
// which on Windows is most of them. The folder the dashboard was opened in is
|
|
345
|
-
// searched too, and it is where the projects being worked on actually are.
|
|
346
|
-
const roots = searchRoots([dirname(state ?? process.cwd()), process.cwd()]);
|
|
347
|
-
const found = [...new Set(roots.flatMap((r) => findProjects(r)))];
|
|
348
|
-
for (const dir of found) remember(dir);
|
|
349
|
-
const added = knownProjects().length - before;
|
|
350
|
-
await report([
|
|
351
|
-
c.bold(added ? `Found ${added} project${added === 1 ? "" : "s"}` : "Nothing new"),
|
|
352
|
-
"",
|
|
353
|
-
...found.map((d) => " " + c.dim(tilde(d))),
|
|
354
|
-
"",
|
|
355
|
-
c.dim(`${found.length} found in ${((Date.now() - started) / 1000).toFixed(1)}s under ${roots.map(tilde).join(", ")}`),
|
|
356
|
-
c.dim(`the list lives in ${tilde(join(home, ".kaizen", "projects"))} and can be edited`),
|
|
357
|
-
]);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function runsView(from?: string) {
|
|
361
|
-
for (;;) {
|
|
362
|
-
const here = from ?? state!;
|
|
363
|
-
const runs = readRuns(here);
|
|
364
|
-
const now = Date.now();
|
|
365
|
-
// The dot the board already uses, a four-letter word for anyone not reading
|
|
366
|
-
// colour, and the date dropped from the id: every run made the same day
|
|
367
|
-
// repeated it, which is ten columns saying nothing.
|
|
368
|
-
const width = Math.max(0, ...runs.map((r) => short(r.id).length));
|
|
369
|
-
const rows = runs.map((r) => {
|
|
370
|
-
const { dot, status } = dotFor(r, now);
|
|
371
|
-
const word = { waiting: "wait", running: "run", stalled: "idle", done: "done", abandoned: "drop" }[status] ?? status;
|
|
372
|
-
const mark = status === "waiting" ? c.amber(word.padEnd(4)) : c.dim(word.padEnd(4));
|
|
373
|
-
// The mark already says done or dropped; repeating the stage beside it is noise.
|
|
374
|
-
const tail = r.stage === "done" || r.stage === "abandoned" ? "" : (r.awaiting ?? r.stage);
|
|
375
|
-
return `${dot} ${mark} ${short(r.id).padEnd(width)} ${c.dim(tail)}`;
|
|
376
|
-
});
|
|
377
|
-
const { index } = await pick(from ? `Runs — ${label(from)}` : "Runs", rows);
|
|
378
|
-
if (index === null) return;
|
|
379
|
-
await runDetail(runs[index]!, here);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// What a run is, read off its own files rather than summarised from memory.
|
|
384
|
-
async function runDetail(r: Run, from?: string) {
|
|
385
|
-
const dir = join(from ?? state!, "runs", r.id);
|
|
386
|
-
const lines: string[] = [c.bold(r.id), ""];
|
|
387
|
-
lines.push(` stage ${r.stage}`);
|
|
388
|
-
lines.push(` waiting ${r.awaiting ?? c.dim("nothing — it can carry on")}`);
|
|
389
|
-
|
|
390
|
-
const request = section(join(dir, "00-request.md"), 6);
|
|
391
|
-
if (request.length) lines.push("", c.bold(" Request"), ...request.map((l) => " " + l));
|
|
392
|
-
|
|
393
|
-
const findings = readFindings(join(dir, "04-review.md"));
|
|
394
|
-
if (findings.length) {
|
|
395
|
-
lines.push("", c.bold(` Findings (${findings.length})`));
|
|
396
|
-
for (const f of findings.slice(0, 8)) lines.push(" " + f);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const items = readBacklog(join(dir, "06-backlog.md"));
|
|
400
|
-
if (items.length) {
|
|
401
|
-
lines.push("", c.bold(` Backlog (${items.length} open)`));
|
|
402
|
-
for (const it of items.slice(0, 8)) lines.push(" " + it);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
lines.push("", c.dim(` files in ${tilde(dir)}`));
|
|
406
|
-
await report(lines);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Cut to the window, counting printable characters only -- a row is mostly colour
|
|
410
|
-
// codes by the time it gets here, and they take no space on screen.
|
|
411
|
-
function fit(row: string) {
|
|
412
|
-
const room = (stdout.columns ?? 80) - 8;
|
|
413
|
-
const plain = row.replace(/\x1b\[[0-9;]*m/g, "");
|
|
414
|
-
if (plain.length <= room) return row;
|
|
415
|
-
let out = "", seen = 0, i = 0;
|
|
416
|
-
while (i < row.length && seen < room - 1) {
|
|
417
|
-
const esc = /^\x1b\[[0-9;]*m/.exec(row.slice(i));
|
|
418
|
-
if (esc) { out += esc[0]; i += esc[0].length; continue; }
|
|
419
|
-
out += row[i]; i++; seen++;
|
|
420
|
-
}
|
|
421
|
-
return out + "…\x1b[0m";
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
async function backlogView(from?: string) {
|
|
425
|
-
const groups = allBacklog(from ?? state!);
|
|
426
|
-
const parsed = groups.map((g) => ({ run: g.run, items: g.items.map(parseItem) }));
|
|
427
|
-
const sevWidth = Math.max(0, ...parsed.flatMap((g) => g.items.map((i) => i.severity?.length ?? 0)));
|
|
428
|
-
const whereWidth = Math.max(0, ...parsed.flatMap((g) => g.items.map((i) => i.where?.length ?? 0)));
|
|
429
|
-
|
|
430
|
-
const rows: string[] = [];
|
|
431
|
-
const full: (Item | null)[] = []; // null where the row is a heading
|
|
432
|
-
for (const g of parsed) {
|
|
433
|
-
rows.push(c.bold(g.run.replace(/^\d{4}-\d{2}-\d{2}-/, ""))); full.push(null);
|
|
434
|
-
for (const it of g.items) {
|
|
435
|
-
// Columns only where there is something to put in them: an item written as
|
|
436
|
-
// prose should not be pushed across the screen by other items' severities.
|
|
437
|
-
const cols: string[] = [];
|
|
438
|
-
if (sevWidth) {
|
|
439
|
-
const sev = (it.severity ?? "").padEnd(sevWidth);
|
|
440
|
-
cols.push(it.severity === "critical" || it.severity === "high" ? c.amber(sev) : c.dim(sev));
|
|
441
|
-
}
|
|
442
|
-
if (whereWidth) cols.push(c.dim((it.where ?? "").padEnd(whereWidth)));
|
|
443
|
-
rows.push(" " + [...cols, it.text].join(" "));
|
|
444
|
-
full.push(it);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
for (;;) {
|
|
448
|
-
const { index, key } = await pick(
|
|
449
|
-
from ? `Backlog — ${label(from)}` : "Backlog — open items",
|
|
450
|
-
rows,
|
|
451
|
-
"enter read · r run in agent · backspace back",
|
|
452
|
-
(n) => full[n] === null,
|
|
453
|
-
"rR",
|
|
454
|
-
);
|
|
455
|
-
if (index === null) return;
|
|
456
|
-
// Rows are cut to the window, so reading one means opening it.
|
|
457
|
-
const it = full[index];
|
|
458
|
-
if (!it) continue;
|
|
459
|
-
if (key === "r" || key === "R") {
|
|
460
|
-
await startBacklogItem(it, from ?? state!);
|
|
461
|
-
continue;
|
|
462
|
-
}
|
|
463
|
-
const k = await report([
|
|
464
|
-
c.bold(it.where ?? "Backlog item"),
|
|
465
|
-
it.severity ? c.dim(` ${it.severity}`) : "",
|
|
466
|
-
"",
|
|
467
|
-
...wrap(it.raw.replace(/`/g, ""), 2),
|
|
468
|
-
], "r run in agent · any other key to go back");
|
|
469
|
-
if (k === "r" || k === "R") {
|
|
470
|
-
await startBacklogItem(it, from ?? state!);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ------------------------------------------------------------------ board
|
|
476
|
-
|
|
477
|
-
// Ideas and runs on one screen, in stage columns. A run's stage belongs to
|
|
478
|
-
// kaizen and cannot be edited here -- a board that could drag one backwards
|
|
479
|
-
// would be lying about what happened. The one exception is abandoning a run,
|
|
480
|
-
// which is the user's decision either way.
|
|
481
|
-
async function boardView() {
|
|
482
|
-
let allProjects = false;
|
|
483
|
-
let cards: Drawn[] = [];
|
|
484
|
-
let col = 0, row = 0;
|
|
485
|
-
let top = 0; // first visible line, board-wide
|
|
486
|
-
// Where each card actually landed on screen, rebuilt by whichever draw ran.
|
|
487
|
-
// Recording the frame beats recomputing its geometry: the two layouts place
|
|
488
|
-
// cards differently, and a stale guess after a resize clicks the wrong card.
|
|
489
|
-
type Hit = { y: number; x0: number; x1: number; col: number; row: number };
|
|
490
|
-
let hits: Hit[] = [];
|
|
491
|
-
// What each run was awaiting last time we looked, so a redraw can tell the
|
|
492
|
-
// difference between "still blocked" and "just became blocked".
|
|
493
|
-
const wasAwaiting = new Map<string, string | null>();
|
|
494
|
-
let first = true;
|
|
495
|
-
|
|
496
|
-
const states = () => (allProjects
|
|
497
|
-
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
498
|
-
: [state!]
|
|
499
|
-
).filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
|
|
500
|
-
|
|
501
|
-
const refresh = () => {
|
|
502
|
-
const now = Date.now();
|
|
503
|
-
const next: Drawn[] = [];
|
|
504
|
-
for (const k of boardCards(states(), now)) {
|
|
505
|
-
if (k.archived) continue; // cleared from the board; kaizen web shows the archive
|
|
506
|
-
if (k.kind === "run") {
|
|
507
|
-
const key = `${k.state}/${k.id}`;
|
|
508
|
-
const before = wasAwaiting.get(key);
|
|
509
|
-
if (!first && before === null && k.awaiting) {
|
|
510
|
-
notify("kaizen — waiting on you", `${k.text} · ${k.awaiting}`);
|
|
511
|
-
}
|
|
512
|
-
wasAwaiting.set(key, k.awaiting);
|
|
513
|
-
}
|
|
514
|
-
next.push({ ...k, dot: DOTS[k.status as Status | "idea" | "starting"]() });
|
|
515
|
-
}
|
|
516
|
-
cards = next;
|
|
517
|
-
first = false;
|
|
518
|
-
};
|
|
519
|
-
|
|
520
|
-
const inColumn = (n: number) => cards.filter((k) => k.column === n);
|
|
521
|
-
const current = () => inColumn(col)[row];
|
|
522
|
-
|
|
523
|
-
const draw = () => {
|
|
524
|
-
const width = stdout.columns ?? 80;
|
|
525
|
-
// Five columns need room to say anything. Below that the board is worse
|
|
526
|
-
// than the list it replaces, so it becomes one.
|
|
527
|
-
if (width < 100) { drawNarrow(); return; }
|
|
528
|
-
const inner = Math.floor((width - 4) / COLUMNS.length) - 2;
|
|
529
|
-
const cols = COLUMNS.map((meta, n) => {
|
|
530
|
-
const lines: string[] = [];
|
|
531
|
-
const starts: number[] = [];
|
|
532
|
-
for (const k of inColumn(n)) {
|
|
533
|
-
starts.push(lines.length);
|
|
534
|
-
const chosen = n === col && inColumn(n)[row] === k;
|
|
535
|
-
const body = wrapTo(k.text, inner - 4);
|
|
536
|
-
for (const [i, line] of body.entries()) {
|
|
537
|
-
const marker = i === 0 ? (chosen ? c.cyan("›") : " ") : " ";
|
|
538
|
-
const dot = i === 0 ? k.dot : " ";
|
|
539
|
-
const painted = k.awaiting ? c.red(line) : k.dim ? c.dim(line) : line;
|
|
540
|
-
lines.push(`${marker} ${dot} ${painted}`);
|
|
541
|
-
}
|
|
542
|
-
if (allProjects && k.where !== "no project") lines.push(" " + c.dim(cut(basename(k.where), inner - 4)));
|
|
543
|
-
// First note line only; the rest is for the web board's detail panel.
|
|
544
|
-
if (k.notes) { const n = parseNotes(k.notes); const l = n[n.length - 1]?.text.split("\n")[0]; if (l) lines.push(" " + c.dim(cut(l, inner - 4))); }
|
|
545
|
-
lines.push("");
|
|
546
|
-
}
|
|
547
|
-
return { meta, lines, starts };
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
stdout.write("\x1b[H\x1b[2J");
|
|
551
|
-
const scope = allProjects ? "all projects" : label(state!);
|
|
552
|
-
stdout.write(`\n ${c.bold("Board")} ${c.dim(scope)}\n\n`);
|
|
553
|
-
stdout.write(" " + cols.map(({ meta }, n) =>
|
|
554
|
-
pad(n === col ? c.cyan(meta.title) : c.dim(meta.title), inner)).join(" ") + "\n");
|
|
555
|
-
// Only the column you are in gets a solid rule; the rest fade to a dotted one,
|
|
556
|
-
// which says where the cursor is without painting a block over the card.
|
|
557
|
-
stdout.write(" " + cols.map((_, n) =>
|
|
558
|
-
n === col ? c.cyan("─".repeat(inner)) : c.dim("┈".repeat(inner))).join(" ") + "\n");
|
|
559
|
-
const height = Math.max(4, (stdout.rows ?? 24) - 11);
|
|
560
|
-
const deep = Math.max(0, ...cols.map((x) => x.lines.length));
|
|
561
|
-
// The selected card drags the viewport with it; a cursor that can leave the
|
|
562
|
-
// screen is a cursor that deletes cards the user cannot see.
|
|
563
|
-
const sel = cols[col]!.starts[row] ?? 0;
|
|
564
|
-
if (sel < top) top = sel;
|
|
565
|
-
if (sel >= top + height) top = sel - height + 1;
|
|
566
|
-
top = Math.max(0, Math.min(top, Math.max(0, deep - height)));
|
|
567
|
-
hits = [];
|
|
568
|
-
for (let i = top; i < Math.min(deep, top + height); i++) {
|
|
569
|
-
// Cards begin on the sixth line: blank, title, blank, headings, rule.
|
|
570
|
-
const y = 6 + (i - top);
|
|
571
|
-
for (const [n, col_] of cols.entries()) {
|
|
572
|
-
const x0 = 3 + n * (inner + 2);
|
|
573
|
-
const owns = col_.starts.reduce((found, at, idx) => (at <= i ? idx : found), -1);
|
|
574
|
-
if (owns < 0) continue;
|
|
575
|
-
const ends = col_.starts[owns + 1] ?? col_.lines.length;
|
|
576
|
-
if (i >= ends || !(col_.lines[i] ?? "").trim()) continue;
|
|
577
|
-
hits.push({ y, x0, x1: x0 + inner - 1, col: n, row: owns });
|
|
578
|
-
}
|
|
579
|
-
stdout.write(" " + cols.map((x) => pad(x.lines[i] ?? "", inner)).join(" ") + "\n");
|
|
580
|
-
}
|
|
581
|
-
const above = top > 0, below = top + height < deep;
|
|
582
|
-
if (above || below) {
|
|
583
|
-
stdout.write(` ${c.dim(`${above ? "↑" : " "} ${below ? "↓ more" : ""}`)}\n`);
|
|
584
|
-
}
|
|
585
|
-
stdout.write("\n" + footer(current()) + legend());
|
|
586
|
-
};
|
|
587
|
-
|
|
588
|
-
// Keys grouped by what they are for -- moving, acting, leaving -- separated by
|
|
589
|
-
// space rather than by middots, which made one long undifferentiated line.
|
|
590
|
-
const footer = (k?: Card) => {
|
|
591
|
-
const acts = k?.kind === "idea"
|
|
592
|
-
? "n new e edit t note x reject d delete r run"
|
|
593
|
-
: k ? "n new idea t note x abandon this run" : "n new idea";
|
|
594
|
-
const scope = allProjects ? "a this project" : "a all projects";
|
|
595
|
-
return ` ${c.dim("↑↓←→ move")} ${c.dim(acts)} ${c.dim(scope)} ${c.dim("q back")}\n`;
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
// Only the states actually on the board: a legend for dots nothing is using is
|
|
599
|
-
// six items of noise under every screen.
|
|
600
|
-
const legend = () => {
|
|
601
|
-
const present = new Set(cards.map((k) => k.status));
|
|
602
|
-
const all: [string, string][] = [
|
|
603
|
-
["running", `${c.cyan("●")} ${c.dim("running")}`],
|
|
604
|
-
["stalled", c.dim("◐ stalled")],
|
|
605
|
-
["starting", c.dim("◌ starting")],
|
|
606
|
-
["waiting", `${c.amber("●")} ${c.red("waiting on you")}`],
|
|
607
|
-
["done", c.dim("○ done")],
|
|
608
|
-
["abandoned", c.dim("○ abandoned")],
|
|
609
|
-
["idea", c.dim("· idea")],
|
|
610
|
-
];
|
|
611
|
-
const parts = all.filter(([key]) => present.has(key)).map(([, text]) => text);
|
|
612
|
-
return parts.length ? "\n " + parts.join(" ") + "\n" : "\n";
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
// Under 100 columns the board stacks: same cards, same keys, one list.
|
|
616
|
-
const drawNarrow = () => {
|
|
617
|
-
stdout.write("\x1b[H\x1b[2J");
|
|
618
|
-
stdout.write(`\n ${c.bold("Board")} ${c.dim("narrow terminal — stacked")}\n\n`);
|
|
619
|
-
hits = [];
|
|
620
|
-
// The header writes three newlines, so the first heading lands on line 4.
|
|
621
|
-
let y = 4;
|
|
622
|
-
for (const [n, meta] of COLUMNS.entries()) {
|
|
623
|
-
const items = inColumn(n);
|
|
624
|
-
if (!items.length) continue;
|
|
625
|
-
stdout.write(` ${n === col ? c.cyan(meta.title) : c.dim(meta.title)}\n`);
|
|
626
|
-
y++;
|
|
627
|
-
for (const [idx, k] of items.entries()) {
|
|
628
|
-
const chosen = n === col && items[row] === k;
|
|
629
|
-
const text = k.awaiting ? c.red(k.text) : k.dim ? c.dim(k.text) : k.text;
|
|
630
|
-
stdout.write(` ${chosen ? c.cyan("›") : " "} ${k.dot} ${cut(text, (stdout.columns ?? 80) - 8)}\n`);
|
|
631
|
-
hits.push({ y, x0: 1, x1: stdout.columns ?? 80, col: n, row: idx });
|
|
632
|
-
y++;
|
|
633
|
-
}
|
|
634
|
-
stdout.write("\n");
|
|
635
|
-
y++;
|
|
636
|
-
}
|
|
637
|
-
stdout.write("\n" + footer(current()) + legend());
|
|
638
|
-
};
|
|
639
|
-
|
|
640
|
-
const cardAt = (x: number, y: number) =>
|
|
641
|
-
hits.find((h) => h.y === y && x >= h.x0 && x <= h.x1) ?? null;
|
|
642
|
-
|
|
643
|
-
const clamp = () => {
|
|
644
|
-
col = Math.min(COLUMNS.length - 1, Math.max(0, col));
|
|
645
|
-
row = Math.min(Math.max(0, inColumn(col).length - 1), Math.max(0, row));
|
|
646
|
-
};
|
|
647
|
-
|
|
648
|
-
refresh();
|
|
649
|
-
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
650
|
-
stdin.setRawMode(true);
|
|
651
|
-
stdin.resume();
|
|
652
|
-
|
|
653
|
-
// Kaizen writes these files from other processes; watching them is what makes
|
|
654
|
-
// the board move on its own.
|
|
655
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
656
|
-
let watchers: ReturnType<typeof watch>[] = [];
|
|
657
|
-
const onChange = () => {
|
|
658
|
-
if (timer) clearTimeout(timer);
|
|
659
|
-
// A state.json being rewritten is briefly unparseable; readRuns already
|
|
660
|
-
// drops what it cannot parse, and the delay keeps that window off screen.
|
|
661
|
-
timer = setTimeout(() => { refresh(); clamp(); draw(); arm(); }, 150);
|
|
662
|
-
};
|
|
663
|
-
|
|
664
|
-
// fs.watch is not recursive here, and the file that actually changes is
|
|
665
|
-
// runs/<id>/state.json -- two levels down. Watching only the state dir and
|
|
666
|
-
// runs/ sees a new run appear and never sees an existing one move, so every
|
|
667
|
-
// run directory is watched too, and the set is rebuilt whenever anything
|
|
668
|
-
// fires in case the change was a run being created.
|
|
669
|
-
function arm() {
|
|
670
|
-
for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
|
|
671
|
-
watchers = [];
|
|
672
|
-
for (const st of states()) {
|
|
673
|
-
const runs = join(st, "runs");
|
|
674
|
-
const targets = [st, runs];
|
|
675
|
-
try { for (const id of readdirSync(runs)) targets.push(join(runs, id)); } catch { /* no runs yet */ }
|
|
676
|
-
for (const target of targets) {
|
|
677
|
-
try { if (existsSync(target)) watchers.push(watch(target, { persistent: false }, onChange)); }
|
|
678
|
-
catch { /* unwatchable path */ }
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
arm();
|
|
683
|
-
|
|
684
|
-
try {
|
|
685
|
-
draw();
|
|
686
|
-
for (;;) {
|
|
687
|
-
const key = await nextKey();
|
|
688
|
-
if (key === "quit") break;
|
|
689
|
-
if (key === "a") { allProjects = !allProjects; refresh(); clamp(); draw(); arm(); continue; }
|
|
690
|
-
if (key === "left") { col--; row = 0; clamp(); draw(); continue; }
|
|
691
|
-
if (key === "right") { col++; row = 0; clamp(); draw(); continue; }
|
|
692
|
-
if (key === "up") { row--; clamp(); draw(); continue; }
|
|
693
|
-
if (key === "down") { row++; clamp(); draw(); continue; }
|
|
694
|
-
|
|
695
|
-
const target = current();
|
|
696
|
-
if (key === "n") {
|
|
697
|
-
const text = await promptLine("New idea", "");
|
|
698
|
-
if (text) {
|
|
699
|
-
const st = target?.state ?? state!;
|
|
700
|
-
writeInbox(st, [...readInbox(st), { status: "open", text }]);
|
|
701
|
-
}
|
|
702
|
-
refresh(); col = 0; clamp(); draw(); continue;
|
|
703
|
-
}
|
|
704
|
-
if (!target) { draw(); continue; }
|
|
705
|
-
// Runs are otherwise read-only -- their stage belongs to kaizen -- but
|
|
706
|
-
// abandoning one is the user's decision to make, and /kaizen abort is the
|
|
707
|
-
// same single transition this writes.
|
|
708
|
-
if (target.kind === "run") {
|
|
709
|
-
if (key === "t" && target.id && target.column < 4) {
|
|
710
|
-
const line = await promptLine("Add a note", "");
|
|
711
|
-
if (line) appendNote(target.state, { id: target.id }, line);
|
|
712
|
-
refresh();
|
|
713
|
-
}
|
|
714
|
-
if (key === "x" && target.id) {
|
|
715
|
-
const why = await promptLine(`Abandon ${target.text}? Reason`, "");
|
|
716
|
-
if (why) {
|
|
717
|
-
const failed = abandonRun(target.state, target.id, why);
|
|
718
|
-
if (failed) await outside(() => report([c.bold("Could not abandon"), "", c.dim(failed)]));
|
|
719
|
-
}
|
|
720
|
-
refresh(); clamp();
|
|
721
|
-
}
|
|
722
|
-
draw(); continue;
|
|
723
|
-
}
|
|
724
|
-
if (key === "e") {
|
|
725
|
-
const text = await promptLine("Edit idea", target.text);
|
|
726
|
-
if (text) replaceIdea(target.state, target.text, { status: "open", text });
|
|
727
|
-
} else if (key === "t" && target.status === "idea") {
|
|
728
|
-
const line = await promptLine("Add a note", "");
|
|
729
|
-
if (line) appendNote(target.state, { text: target.text }, line);
|
|
730
|
-
} else if (key === "x") {
|
|
731
|
-
const why = await promptLine(`Reject: ${target.text}`, "");
|
|
732
|
-
// The spec requires a reason on a rejected item; no reason, no rejection.
|
|
733
|
-
if (why) replaceIdea(target.state, target.text, { status: "rejected", text: `${target.text} | ${why}` });
|
|
734
|
-
} else if (key === "d") {
|
|
735
|
-
if (await confirm(`Delete "${cut(target.text, 40)}"?`)) replaceIdea(target.state, target.text, null);
|
|
736
|
-
} else if (key === "r") {
|
|
737
|
-
const mode = await pickMode();
|
|
738
|
-
// "" is the default-mode choice, and must not leave a double space in
|
|
739
|
-
// the prompt the agent is launched with.
|
|
740
|
-
if (mode !== null) {
|
|
741
|
-
const request = `${mode} ${target.text}`.trim();
|
|
742
|
-
await outside(() => startBacklogItem({ ...parseItem(request), notes: target.notes }, target.state));
|
|
743
|
-
// Without this the idea stays open forever and the board shows the
|
|
744
|
-
// same work twice: once as an idea, once as the run it became.
|
|
745
|
-
replaceIdea(target.state, target.text, { status: "started", text: target.text });
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
refresh(); clamp(); draw();
|
|
749
|
-
}
|
|
750
|
-
} finally {
|
|
751
|
-
for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
|
|
752
|
-
watchers = [];
|
|
753
|
-
if (timer) clearTimeout(timer);
|
|
754
|
-
stdin.setRawMode(false);
|
|
755
|
-
stdin.pause();
|
|
756
|
-
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
// startBacklogItem paints its own screens, so the board steps out of the way
|
|
760
|
-
// and takes the terminal back afterwards.
|
|
761
|
-
async function outside(fn: () => Promise<void>) {
|
|
762
|
-
stdin.setRawMode(false);
|
|
763
|
-
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
764
|
-
try {
|
|
765
|
-
await fn();
|
|
766
|
-
} finally {
|
|
767
|
-
// Without this the board's own finally restores a terminal that was never
|
|
768
|
-
// put back into raw mode, and the user is left with no echo.
|
|
769
|
-
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
770
|
-
stdin.setRawMode(true);
|
|
771
|
-
stdin.resume();
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// One chunk can carry several keys -- a held arrow, a paste, fast typing --
|
|
776
|
-
// so it is walked rather than compared whole.
|
|
777
|
-
function nextKey(): Promise<string> {
|
|
778
|
-
return new Promise((resolve) => {
|
|
779
|
-
const onData = (chunk: Buffer) => {
|
|
780
|
-
const keys = chunk.toString();
|
|
781
|
-
if (keys.includes("\x03")) process.exit(130);
|
|
782
|
-
const done = (v: string) => { stdin.off("data", onData); resolve(v); };
|
|
783
|
-
for (let i = 0; i < keys.length; i++) {
|
|
784
|
-
const rest = keys.slice(i);
|
|
785
|
-
const mouse = readMouse(rest);
|
|
786
|
-
if (mouse) {
|
|
787
|
-
i += mouse.len - 1;
|
|
788
|
-
const { ev } = mouse;
|
|
789
|
-
if (ev.button === WHEEL_UP) return done("up");
|
|
790
|
-
if (ev.button === WHEEL_DOWN) return done("down");
|
|
791
|
-
if (!isClick(ev)) continue;
|
|
792
|
-
const target = cardAt(ev.x, ev.y);
|
|
793
|
-
if (!target) continue;
|
|
794
|
-
// Click to select; click the selected card to act, which on an idea
|
|
795
|
-
// means the same thing r does.
|
|
796
|
-
if (target.col === col && target.row === row) return done("r");
|
|
797
|
-
col = target.col; row = target.row;
|
|
798
|
-
draw();
|
|
799
|
-
continue;
|
|
800
|
-
}
|
|
801
|
-
if (rest.startsWith("\x1b[D")) return done("left");
|
|
802
|
-
if (rest.startsWith("\x1b[C")) return done("right");
|
|
803
|
-
if (rest.startsWith("\x1b[A")) return done("up");
|
|
804
|
-
if (rest.startsWith("\x1b[B")) return done("down");
|
|
805
|
-
const ch = keys[i]!;
|
|
806
|
-
if (ch === "h") return done("left");
|
|
807
|
-
if (ch === "l") return done("right");
|
|
808
|
-
if (ch === "k") return done("up");
|
|
809
|
-
if (ch === "j") return done("down");
|
|
810
|
-
if (ch === "q" || ch === "\x1b" || ch === "\x7f" || ch === "\b") return done("quit");
|
|
811
|
-
if ("nexdra".includes(ch)) return done(ch);
|
|
812
|
-
}
|
|
813
|
-
};
|
|
814
|
-
stdin.on("data", onData);
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
async function promptLine(title: string, initial: string): Promise<string | null> {
|
|
819
|
-
let buf = initial;
|
|
820
|
-
const paint = () => {
|
|
821
|
-
stdout.write("\x1b[H\x1b[2J");
|
|
822
|
-
stdout.write(`\n ${c.bold(title)}\n\n ${buf}${c.cyan("█")}\n\n ${c.dim("enter save · esc cancel")}\n`);
|
|
823
|
-
};
|
|
824
|
-
paint();
|
|
825
|
-
return new Promise((resolve) => {
|
|
826
|
-
const onData = (chunk: Buffer) => {
|
|
827
|
-
const keys = chunk.toString();
|
|
828
|
-
if (keys.includes("\x03")) process.exit(130);
|
|
829
|
-
const done = (v: string | null) => { stdin.off("data", onData); resolve(v); };
|
|
830
|
-
// Character by character: a held backspace or a paste arrives as one
|
|
831
|
-
// chunk, and comparing the whole chunk to "\x7f" writes the raw bytes
|
|
832
|
-
// into the file instead of deleting anything.
|
|
833
|
-
for (let i = 0; i < keys.length; i++) {
|
|
834
|
-
// Skipped whole, or its digits and semicolons land in the text.
|
|
835
|
-
const mouse = readMouse(keys.slice(i));
|
|
836
|
-
if (mouse) { i += mouse.len - 1; continue; }
|
|
837
|
-
const ch = keys[i]!;
|
|
838
|
-
if (ch === "\x1b") return done(null);
|
|
839
|
-
if (ch === "\r" || ch === "\n") return done(buf.trim() || null);
|
|
840
|
-
if (ch === "\x7f" || ch === "\b") { buf = buf.slice(0, -1); continue; }
|
|
841
|
-
// Anything else unprintable is dropped rather than stored: control
|
|
842
|
-
// bytes in an inbox line survive every later read of that file.
|
|
843
|
-
if (ch >= " " && ch !== "\x7f") buf += ch;
|
|
844
|
-
}
|
|
845
|
-
paint();
|
|
846
|
-
};
|
|
847
|
-
stdin.on("data", onData);
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
async function confirm(question: string): Promise<boolean> {
|
|
852
|
-
stdout.write("\x1b[H\x1b[2J");
|
|
853
|
-
stdout.write(`\n ${c.bold(question)}\n\n ${c.dim("y delete · any other key keep")}\n`);
|
|
854
|
-
const key = await nextRaw();
|
|
855
|
-
return key === "y" || key === "Y";
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// full or lite, asked at the moment a run starts rather than fixed in config.
|
|
859
|
-
async function pickMode(): Promise<string | null> {
|
|
860
|
-
stdout.write("\x1b[H\x1b[2J");
|
|
861
|
-
stdout.write(`\n ${c.bold("Start this run how?")}\n\n`);
|
|
862
|
-
stdout.write(` ${c.cyan("f")} full ${c.dim("every stage its own cold agent — the reviewer starts blind")}\n`);
|
|
863
|
-
stdout.write(` ${c.cyan("l")} lite ${c.dim("one session runs every stage — cheaper, review has seen the work")}\n`);
|
|
864
|
-
stdout.write(` ${c.cyan("d")} default${c.dim(" whatever config.yml says")}\n\n ${c.dim("click a line, or any other key to cancel")}\n`);
|
|
865
|
-
// Options start on the fourth line: blank, title, blank, then f, l, d.
|
|
866
|
-
const key = await nextRaw((y) => ["f", "l", "d"][y - 4] ?? null);
|
|
867
|
-
return key === "f" ? "full" : key === "l" ? "lite" : key === "d" ? "" : null;
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// A click arrives as two reports, press and release. The press is what opened
|
|
871
|
-
// this screen, so the release must not be read as its answer -- and a click on
|
|
872
|
-
// one of the offered lines answers it properly, via `clickable`.
|
|
873
|
-
function nextRaw(clickable?: (y: number) => string | null): Promise<string> {
|
|
874
|
-
return new Promise((resolve) => {
|
|
875
|
-
const onData = (chunk: Buffer) => {
|
|
876
|
-
const keys = chunk.toString();
|
|
877
|
-
if (keys.includes("\x03")) process.exit(130);
|
|
878
|
-
const done = (v: string) => { stdin.off("data", onData); resolve(v); };
|
|
879
|
-
for (let i = 0; i < keys.length; i++) {
|
|
880
|
-
const mouse = readMouse(keys.slice(i));
|
|
881
|
-
if (mouse) {
|
|
882
|
-
i += mouse.len - 1;
|
|
883
|
-
if (!clickable || !isClick(mouse.ev)) continue;
|
|
884
|
-
const answer = clickable(mouse.ev.y);
|
|
885
|
-
if (answer) return done(answer);
|
|
886
|
-
continue;
|
|
887
|
-
}
|
|
888
|
-
return done(keys.slice(i));
|
|
889
|
-
}
|
|
890
|
-
};
|
|
891
|
-
stdin.on("data", onData);
|
|
892
|
-
});
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
async function startBacklogItem(it: Item, from: string) {
|
|
897
|
-
const r = launchRun(it, from);
|
|
898
|
-
if (!r.ok) {
|
|
899
|
-
await report([
|
|
900
|
-
c.bold(r.why.startsWith("No supported") || r.why.startsWith("Terminal.app") ? "Could not open terminal" : "Failed to spawn terminal"),
|
|
901
|
-
"",
|
|
902
|
-
c.dim(r.why),
|
|
903
|
-
"",
|
|
904
|
-
"Run manually:",
|
|
905
|
-
` ${c.cyan(r.manual)}`,
|
|
906
|
-
]);
|
|
907
|
-
return;
|
|
908
|
-
}
|
|
909
|
-
await report([
|
|
910
|
-
c.bold("Started in new terminal"),
|
|
911
|
-
"",
|
|
912
|
-
` ${c.cyan("Agent")} ${r.agent.name} (${c.bold(r.agent.cmd)})`,
|
|
913
|
-
` ${c.cyan("Project")} ${tilde(r.projectDir)}`,
|
|
914
|
-
` ${c.cyan("Task")} ${it.text}`,
|
|
915
|
-
...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
|
|
916
|
-
...(it.severity ? [` ${c.cyan("Severity")} ${it.severity}`] : []),
|
|
917
|
-
"",
|
|
918
|
-
c.dim(`Opened in new window running: ${r.agent.cmd} "${r.prompt}"`),
|
|
919
|
-
]);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
function wrap(text: string, indent = 0) {
|
|
923
|
-
const room = (stdout.columns ?? 80) - 6 - indent;
|
|
924
|
-
const out: string[] = [];
|
|
925
|
-
let line = "";
|
|
926
|
-
for (const word of text.split(/\s+/)) {
|
|
927
|
-
if (line && line.length + word.length + 1 > room) { out.push(" ".repeat(indent) + line); line = word; }
|
|
928
|
-
else line = line ? `${line} ${word}` : word;
|
|
929
|
-
}
|
|
930
|
-
if (line) out.push(" ".repeat(indent) + line);
|
|
931
|
-
return out;
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
async function menu() {
|
|
935
|
-
let actsTop = 0; // screen line of the first action row
|
|
936
|
-
let printed = 0; // lines this draw has written
|
|
937
|
-
const w = (text: string) => { printed += (text.match(/\n/g) ?? []).length; stdout.write(text); };
|
|
938
|
-
const draw = () => {
|
|
939
|
-
printed = 0;
|
|
940
|
-
const acts = actions();
|
|
941
|
-
w("\x1b[H\x1b[2J");
|
|
942
|
-
|
|
943
|
-
// The mascot sits beside the header rather than above it; stacked, it pushes
|
|
944
|
-
// the runs -- the thing you opened this for -- below the fold on a short window.
|
|
945
|
-
const cols = stdout.columns ?? 80, rows = stdout.rows ?? 24;
|
|
946
|
-
const wide = cols >= 64;
|
|
947
|
-
const art = TANUKI;
|
|
948
|
-
const at = 2; // where the text sits against it
|
|
949
|
-
|
|
950
|
-
// Every width below is measured, not guessed: the mascot's own width plus the
|
|
951
|
-
// gutter plus the wordmark's columns is exactly what the wide header occupies,
|
|
952
|
-
// and 2 more is the left margin every line is written with.
|
|
953
|
-
const gutter = Math.max(...art.map((l) => l.length)) + 4;
|
|
954
|
-
const setsWordmark = cols >= gutter + WORDMARK[0]!.length + 2;
|
|
955
|
-
|
|
956
|
-
const beside = Array(art.length).fill("");
|
|
957
|
-
if (setsWordmark) {
|
|
958
|
-
// Against the mascot's middle, with what kaizen is under it and where you are
|
|
959
|
-
// under that: the header says what the tool does before it says where it is.
|
|
960
|
-
const top = 2;
|
|
961
|
-
for (const [i, line] of WORDMARK.entries()) {
|
|
962
|
-
const t = i / (WORDMARK.length - 1);
|
|
963
|
-
beside[top + i] = rgb(
|
|
964
|
-
Math.round(222 - t * 120), Math.round(238 - t * 100), Math.round(255 - t * 30), line);
|
|
965
|
-
}
|
|
966
|
-
// The description is chosen from compact phrasings to fit cleanly beside the mascot.
|
|
967
|
-
const room = cols - gutter - 2 - (version(repo).length + 3);
|
|
968
|
-
const line = ["plan · approve · build · review",
|
|
969
|
-
"plan · approve · build",
|
|
970
|
-
"plan · build · review"].find((d) => d.length <= room);
|
|
971
|
-
beside[top + WORDMARK.length + 1] =
|
|
972
|
-
`${c.dim(version(repo))}${line ? " " + c.dim(line) : ""}`;
|
|
973
|
-
beside[top + WORDMARK.length + 2] = c.dim(state ? tilde(dirname(state)) : "no project here");
|
|
974
|
-
beside[top + WORDMARK.length + 3] = c.dim(agents.join(", "));
|
|
975
|
-
} else {
|
|
976
|
-
beside[at] = `${c.bold("kaizen")} ${c.dim(version(repo))}`;
|
|
977
|
-
beside[at + 1] = c.dim(state ? tilde(dirname(state)) : "no project here");
|
|
978
|
-
beside[at + 3] = c.dim(agents.join(", "));
|
|
979
|
-
}
|
|
980
|
-
w("\n");
|
|
981
|
-
if (wide) {
|
|
982
|
-
// Cut what sits beside the mascot to the room left over. The agent list is
|
|
983
|
-
// long and grows with every agent installed, and it wrapped here before the
|
|
984
|
-
// wordmark existed -- a wrapped line pushes the whole header down a row.
|
|
985
|
-
for (const [i, line] of art.entries()) {
|
|
986
|
-
const t = i / (art.length - 1);
|
|
987
|
-
const r = Math.round(235 - t * 135);
|
|
988
|
-
const g = Math.round(245 - t * 110);
|
|
989
|
-
const b = Math.round(255 - t * 20);
|
|
990
|
-
w(" " + rgb(r, g, b, line.padEnd(gutter)) + cut(beside[i] ?? "", cols - gutter - 2) + "\n");
|
|
991
|
-
}
|
|
992
|
-
} else {
|
|
993
|
-
w(` ${c.bold("kaizen")} ${c.dim(version(repo))} ${c.dim(state ? tilde(dirname(state)) : "no project here")}\n`);
|
|
994
|
-
w(` ${c.dim(agents.join(", "))}\n`);
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
if (state) {
|
|
998
|
-
// Across every project, not only this one. Where more than one is known each
|
|
999
|
-
// row says which, since "waiting on approvals.plan" means nothing on its own.
|
|
1000
|
-
const many = new Set(seen.runs.map((r) => r.where)).size > 1;
|
|
1001
|
-
const from = (w: string) => (many ? c.dim(w + " ") : "");
|
|
1002
|
-
const wait = seen.runs.filter((r) => r.awaiting && r.stage !== "abandoned");
|
|
1003
|
-
const fly = seen.runs.filter((r) => !r.awaiting && !["done", "abandoned"].includes(r.stage));
|
|
1004
|
-
const fin = seen.runs.filter((r) => r.stage === "done");
|
|
1005
|
-
|
|
1006
|
-
const runLines = [
|
|
1007
|
-
...wait.map((r) => `${c.amber("●")} ${from(r.where)}${short(r.id)} ${c.dim(r.awaiting ?? "")}`),
|
|
1008
|
-
...fly.map((r) => `${c.cyan("●")} ${from(r.where)}${short(r.id)} ${c.dim(r.stage)}`),
|
|
1009
|
-
...(fin.length ? [c.dim(`● ${fin.length} done`)] : []),
|
|
1010
|
-
];
|
|
1011
|
-
if (!runLines.length) runLines.push(c.dim("none yet"));
|
|
1012
|
-
|
|
1013
|
-
// parseItem's `where` is the file the finding is in; the project is a separate
|
|
1014
|
-
// thing and both belong on the row.
|
|
1015
|
-
const items = seen.items.map((i) => ({ ...parseItem(i.text), project: i.where }));
|
|
1016
|
-
const backLines = items.slice(0, 6).map((it) => {
|
|
1017
|
-
const sev = it.severity ? (it.severity === "critical" || it.severity === "high"
|
|
1018
|
-
? c.amber(it.severity) : c.dim(it.severity)) + " " : "";
|
|
1019
|
-
return from(it.project) + sev + (it.where ? c.dim(it.where) + " " : "") + it.text;
|
|
1020
|
-
});
|
|
1021
|
-
if (!backLines.length) backLines.push(c.dim("nothing open"));
|
|
1022
|
-
else if (items.length > 6) backLines.push(c.dim(`… ${items.length - 6} more`));
|
|
1023
|
-
|
|
1024
|
-
// Side by side while there is room for two readable columns; stacked below
|
|
1025
|
-
// that, since a panel squeezed under forty columns shows nothing useful.
|
|
1026
|
-
const inner = Math.floor((cols - 9) / 2);
|
|
1027
|
-
if (cols >= 96) {
|
|
1028
|
-
// Both panels get the same body height so their bottom edges meet.
|
|
1029
|
-
const tall = Math.max(runLines.length, backLines.length);
|
|
1030
|
-
while (runLines.length < tall) runLines.push("");
|
|
1031
|
-
while (backLines.length < tall) backLines.push("");
|
|
1032
|
-
const left = panel("Runs", runLines, inner);
|
|
1033
|
-
const right = panel(`Backlog — ${items.length} open`, backLines, inner);
|
|
1034
|
-
w("\n");
|
|
1035
|
-
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
1036
|
-
w(" " + (left[i] ?? " ".repeat(inner + 2)) + " " + (right[i] ?? "") + "\n");
|
|
1037
|
-
}
|
|
1038
|
-
} else {
|
|
1039
|
-
w("\n");
|
|
1040
|
-
for (const line of panel("Runs", runLines, cols - 8)) w(" " + line + "\n");
|
|
1041
|
-
for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8)) w(" " + line + "\n");
|
|
1042
|
-
}
|
|
1043
|
-
} else {
|
|
1044
|
-
w(`\n ${c.dim("This folder has no .kaizen/. Set it up, or just ask your agent for something —")}\n`);
|
|
1045
|
-
w(` ${c.dim("the first run creates it.")}\n`);
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
w("\n");
|
|
1049
|
-
// Counted, not derived: the panels above change height with the terminal and
|
|
1050
|
-
// with what exists, so nothing else can know where the actions start.
|
|
1051
|
-
actsTop = printed + 1;
|
|
1052
|
-
for (const [i, a] of acts.entries()) {
|
|
1053
|
-
const on = i === active;
|
|
1054
|
-
w(on
|
|
1055
|
-
? ` ${c.cyan("›")} ${c.bold(a.label.padEnd(20))}${c.dim(a.hint)}\n`
|
|
1056
|
-
: ` ${c.dim(a.label)}\n`);
|
|
1057
|
-
}
|
|
1058
|
-
w(`\n ${c.dim("↑↓ move · enter choose · q quit")} ${c.dim("⭐ github.com/hfadhlullah/kaizen")}\n`);
|
|
1059
|
-
};
|
|
1060
|
-
|
|
1061
|
-
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
1062
|
-
stdin.setRawMode(true);
|
|
1063
|
-
stdin.resume();
|
|
1064
|
-
draw();
|
|
1065
|
-
|
|
1066
|
-
let chosen = "quit";
|
|
1067
|
-
await new Promise<void>((resolve) => {
|
|
1068
|
-
const onData = (chunk: Buffer) => {
|
|
1069
|
-
const acts = actions();
|
|
1070
|
-
const keys = chunk.toString();
|
|
1071
|
-
for (let i = 0; i < keys.length; i++) {
|
|
1072
|
-
const rest = keys.slice(i);
|
|
1073
|
-
if (rest.startsWith("\x03") || rest.startsWith("q") || rest === "\x1b"
|
|
1074
|
-
|| rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
1075
|
-
stdin.off("data", onData); return resolve();
|
|
1076
|
-
}
|
|
1077
|
-
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
1078
|
-
chosen = acts[active]!.key;
|
|
1079
|
-
stdin.off("data", onData); return resolve();
|
|
1080
|
-
}
|
|
1081
|
-
const mouse = readMouse(rest);
|
|
1082
|
-
if (mouse) {
|
|
1083
|
-
i += mouse.len - 1;
|
|
1084
|
-
const { ev } = mouse;
|
|
1085
|
-
if (ev.button === WHEEL_UP) active = (active - 1 + acts.length) % acts.length;
|
|
1086
|
-
else if (ev.button === WHEEL_DOWN) active = (active + 1) % acts.length;
|
|
1087
|
-
else if (isClick(ev)) {
|
|
1088
|
-
const row = ev.y - actsTop;
|
|
1089
|
-
if (row < 0 || row >= acts.length) continue;
|
|
1090
|
-
if (row === active) { chosen = acts[active]!.key; stdin.off("data", onData); return resolve(); }
|
|
1091
|
-
active = row;
|
|
1092
|
-
} else continue;
|
|
1093
|
-
draw();
|
|
1094
|
-
continue;
|
|
1095
|
-
}
|
|
1096
|
-
if (rest.startsWith("\x1b[A")) { active = (active - 1 + acts.length) % acts.length; i += 2; }
|
|
1097
|
-
else if (rest.startsWith("\x1b[B")) { active = (active + 1) % acts.length; i += 2; }
|
|
1098
|
-
else if (rest.startsWith("k")) active = (active - 1 + acts.length) % acts.length;
|
|
1099
|
-
else if (rest.startsWith("j")) active = (active + 1) % acts.length;
|
|
1100
|
-
else continue;
|
|
1101
|
-
draw();
|
|
1102
|
-
}
|
|
1103
|
-
};
|
|
1104
|
-
stdin.on("data", onData);
|
|
1105
|
-
});
|
|
1106
|
-
|
|
1107
|
-
stdin.setRawMode(false);
|
|
1108
|
-
stdin.pause();
|
|
1109
|
-
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
1110
|
-
return chosen;
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
const COLUMNS = [
|
|
1115
|
-
{ key: "idea", title: "IDEA" },
|
|
1116
|
-
{ key: "plan", title: "PLANNING" },
|
|
1117
|
-
{ key: "build", title: "BUILDING" },
|
|
1118
|
-
{ key: "review", title: "REVIEW" },
|
|
1119
|
-
{ key: "done", title: "DONE" },
|
|
1120
|
-
];
|
|
1121
|
-
|
|
1122
|
-
// wrap() above fills the terminal; a board column has its own width.
|
|
1123
|
-
function wrapTo(text: string, room: number) {
|
|
1124
|
-
const out: string[] = [];
|
|
1125
|
-
let line = "";
|
|
1126
|
-
// A run id is a single word to any whitespace split, so without breaking on
|
|
1127
|
-
// hyphens too it can never wrap and is always cut. Kept in the same order the
|
|
1128
|
-
// text arrived, hyphen retained on the line it ends.
|
|
1129
|
-
const words = text.split(/\s+/).flatMap((w) =>
|
|
1130
|
-
w.length <= room ? [w] : w.split(/(?<=-)/));
|
|
1131
|
-
for (const word of words) {
|
|
1132
|
-
if (line && line.length + word.length + (line.endsWith("-") ? 0 : 1) > room) { out.push(line); line = word; }
|
|
1133
|
-
else line = line ? (line.endsWith("-") ? line + word : `${line} ${word}`) : word;
|
|
1134
|
-
}
|
|
1135
|
-
if (line) out.push(line);
|
|
1136
|
-
return out.length ? out.map((l) => cut(l, room)) : [""];
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
function pad(s: string, room: number) {
|
|
1140
|
-
const body = cut(s, room);
|
|
1141
|
-
return body + " ".repeat(Math.max(0, room - vis(body)));
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Printable width: a row is mostly colour codes by the time it is drawn, and they
|
|
1145
|
-
// occupy no columns.
|
|
1146
|
-
function vis(s: string) { return s.replace(/\x1b\[[0-9;]*m/g, "").length; }
|
|
1147
|
-
|
|
1148
|
-
function cut(s: string, room: number) {
|
|
1149
|
-
if (vis(s) <= room) return s;
|
|
1150
|
-
let out = "", seen = 0, i = 0;
|
|
1151
|
-
while (i < s.length && seen < room - 1) {
|
|
1152
|
-
const esc = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
1153
|
-
if (esc) { out += esc[0]; i += esc[0].length; continue; }
|
|
1154
|
-
out += s[i]; i++; seen++;
|
|
1155
|
-
}
|
|
1156
|
-
return out + "…\x1b[0m";
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
const DIM = "\x1b[2m", OFF = "\x1b[0m";
|
|
1160
|
-
|
|
1161
|
-
// How each status is drawn here. The status itself comes from state.ts.
|
|
1162
|
-
const DOTS: Record<Status | "idea" | "starting", () => string> = {
|
|
1163
|
-
abandoned: () => DIM + "○" + OFF,
|
|
1164
|
-
waiting: () => c.amber("●"),
|
|
1165
|
-
done: () => c.dim("○"),
|
|
1166
|
-
running: () => c.cyan("●"),
|
|
1167
|
-
stalled: () => c.dim("◐"),
|
|
1168
|
-
idea: () => c.dim("·"),
|
|
1169
|
-
starting: () => c.dim("◌"),
|
|
1170
|
-
};
|
|
1171
|
-
function dotFor(r: Run, now: number): { dot: string; status: string } {
|
|
1172
|
-
const status = statusOf(r, now);
|
|
1173
|
-
return { dot: DOTS[status](), status };
|
|
1174
|
-
}
|
|
1175
|
-
type Drawn = Card & { dot: string };
|
|
1176
|
-
|
|
1177
|
-
function panel(title: string, lines: string[], inner: number) {
|
|
1178
|
-
const bar = "─".repeat(Math.max(0, inner - vis(title) - 3));
|
|
1179
|
-
const out = [`${DIM}╭─${OFF} ${title} ${DIM}${bar}╮${OFF}`];
|
|
1180
|
-
for (const line of lines) {
|
|
1181
|
-
const body = cut(line, inner - 2);
|
|
1182
|
-
out.push(`${DIM}│${OFF} ${body}${" ".repeat(Math.max(0, inner - 2 - vis(body)))} ${DIM}│${OFF}`);
|
|
1183
|
-
}
|
|
1184
|
-
out.push(`${DIM}╰${"─".repeat(Math.max(0, inner))}╯${OFF}`);
|
|
1185
|
-
return out;
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1
|
+
// What `kaizen` shows once it is installed: the state of this project's runs, and
|
|
2
|
+
// the few things you would have opened a terminal to do.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
|
|
4
|
+
import { join, dirname, basename } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
home, type Run, type Item, type Card, type InboxLine, type Status,
|
|
7
|
+
KNOWN_AGENTS, detectDefaultAgent, findTerminal,
|
|
8
|
+
searchRoots, knownProjects, remember, findProjects, locate,
|
|
9
|
+
readRuns, backlog, version, label, tilde, section, readFindings, parseItem, tidy, readBacklog, allBacklog,
|
|
10
|
+
startedRuns, normalise, statusOf, columnOf, readInbox, writeInbox, notify, short,
|
|
11
|
+
abandonRun, replaceIdea, boardCards, launchRun, appendNote, parseNotes,
|
|
12
|
+
} from "./state.ts";
|
|
13
|
+
const rgb = (r: number, g: number, b: number, s: string) =>
|
|
14
|
+
`\x1b[38;2;${r};${g};${b}m${s}\x1b[0m`;
|
|
15
|
+
|
|
16
|
+
const c = {
|
|
17
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
18
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
19
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
20
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
21
|
+
amber: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
22
|
+
// Red is spent on one thing only: a run that cannot move until the user acts.
|
|
23
|
+
red: (s: string) => rgb(232, 80, 80, s),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// The mascot, rendered from assets/kaizen.jpg. Braille packs 2x4 dots into one
|
|
27
|
+
// character, which is the only way outline art survives a downsample this far.
|
|
28
|
+
const TANUKI = [
|
|
29
|
+
"⠀⠀⠀⣰⠖⠾⣟⣛⠋⢉⣩⠽⢛⡽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
30
|
+
"⠀⠀⢰⢻⠀⠀⢀⡬⠟⠉⢀⠴⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
|
|
31
|
+
"⠀⢠⠇⣾⡠⠞⢩⠤⢄⡚⣁⣀⣀⣀⢀⣠⠤⡄⠀⠀⠀⠀⠀⠀",
|
|
32
|
+
"⢠⣏⠔⣫⣀⡤⢸⠀⠀⠉⠁⠀⠀⠈⠉⠀⠀⡇⠀⠀⠀⠀⠀⠀",
|
|
33
|
+
"⢾⠥⢾⡅⢧⠀⢸⠃⡤⠖⠢⡀⢀⠔⠲⢤⠘⡇⠀⠀⣀⣀⠀⠀",
|
|
34
|
+
"⠀⠀⠈⣇⠈⢠⣫⠞⠀⠘⢱⢣⡜⡎⠃⠀⠳⣝⡄⢠⠇⢻⢉⣷",
|
|
35
|
+
"⠀⠀⠀⠘⢆⠀⠑⢦⣀⡀⠈⠓⠚⠁⢀⣀⣤⣊⣠⢾⡀⢠⣿⠃",
|
|
36
|
+
"⠀⠀⠀⠀⠈⠓⢄⠀⠀⠉⣉⣭⣭⣉⠉⠀⠈⢀⣠⢔⡩⠥⢥⡀",
|
|
37
|
+
"⠀⠀⠀⠀⠀⠀⢠⡇⠀⡞⠁⠀⠀⠈⢳⠀⢰⣍⣠⠚⠧⡀⠀⡷",
|
|
38
|
+
"⠀⠀⠀⠀⠀⠀⠸⡇⠀⣇⠀⠀⠀⠀⣸⠀⢠⡏⠀⠀⢀⣳⠞⠁",
|
|
39
|
+
"⠀⠀⠀⠀⠀⠀⠀⠳⣀⣨⠥⠤⠶⠾⣅⣀⠞⠉⠉⠉⠉⠀⠀⠀",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Compact block wordmark (28 columns x 3 rows).
|
|
43
|
+
const WORDMARK = [
|
|
44
|
+
"█ █ ▄▀█ █ ▀▀█ █▀▀ █▄ █",
|
|
45
|
+
"█▀▄ █▀█ █ ▄▀ ██▄ █ ▀█",
|
|
46
|
+
"█ █ █ █ █ █▄▄ █▄▄ █ █",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- mouse
|
|
50
|
+
|
|
51
|
+
// SGR reporting (1006), not X10: X10 encodes coordinates as single bytes and gives
|
|
52
|
+
// up past column 223, which a five-column board reaches on any wide terminal.
|
|
53
|
+
type MouseEv = { button: number; x: number; y: number; press: boolean };
|
|
54
|
+
|
|
55
|
+
let mouseWanted = false; // what config.yml asked for
|
|
56
|
+
let mouseArmed = false; // what the terminal is actually in
|
|
57
|
+
|
|
58
|
+
function readMouse(rest: string): { ev: MouseEv; len: number } | null {
|
|
59
|
+
const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
|
|
60
|
+
if (!m) return null;
|
|
61
|
+
return {
|
|
62
|
+
ev: { button: +m[1]!, x: +m[2]!, y: +m[3]!, press: m[4] === "M" },
|
|
63
|
+
len: m[0].length,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const WHEEL_UP = 64, WHEEL_DOWN = 65;
|
|
68
|
+
const isClick = (e: MouseEv) => e.press && e.button === 0;
|
|
69
|
+
|
|
70
|
+
// Re-sent even when this module thinks it is already armed: settings runs as an
|
|
71
|
+
// action, tracks arming with its own flag, and turns reporting off in the terminal
|
|
72
|
+
// on the way out. Enabling twice costs nothing; trusting the stale flag costs the
|
|
73
|
+
// mouse for the rest of the session.
|
|
74
|
+
function mouseOn() {
|
|
75
|
+
if (!mouseWanted) return;
|
|
76
|
+
process.stdout.write("\x1b[?1000h\x1b[?1006h");
|
|
77
|
+
mouseArmed = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mouseOff() {
|
|
81
|
+
if (!mouseArmed) return;
|
|
82
|
+
process.stdout.write("\x1b[?1006l\x1b[?1000l");
|
|
83
|
+
mouseArmed = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Ctrl-C calls process.exit, which unwinds no finally block anywhere. A terminal
|
|
87
|
+
// left reporting spits escape junk at every click until `reset`, so this guard has
|
|
88
|
+
// to survive an abrupt exit.
|
|
89
|
+
process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
|
|
90
|
+
|
|
91
|
+
// Off unless the config says otherwise: while reporting is on, the terminal cannot
|
|
92
|
+
// select text with the mouse.
|
|
93
|
+
function mouseFromConfig(state: string | null) {
|
|
94
|
+
for (const f of [state ? join(state, "config.yml") : null, join(home, ".kaizen", "config.yml")]) {
|
|
95
|
+
if (!f || !existsSync(f)) continue;
|
|
96
|
+
const m = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(f, "utf8"));
|
|
97
|
+
if (m) return m[1] === "true";
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function dashboard(
|
|
103
|
+
repo: string,
|
|
104
|
+
agents: string[],
|
|
105
|
+
run: (a: string) => Promise<string[] | void>,
|
|
106
|
+
) {
|
|
107
|
+
const { stdin, stdout } = process;
|
|
108
|
+
const state = locate();
|
|
109
|
+
mouseWanted = mouseFromConfig(state);
|
|
110
|
+
// Opening a project is what makes it known; nothing else asks the user to register.
|
|
111
|
+
if (state && dirname(state) !== home) remember(dirname(state));
|
|
112
|
+
|
|
113
|
+
// Every known project's runs and open items, tagged with where they came from.
|
|
114
|
+
// draw() runs on every keypress, so this is gathered when the dashboard opens and
|
|
115
|
+
// after an action returns -- never inside draw().
|
|
116
|
+
type Seen = { runs: (Run & { where: string })[]; items: { text: string; where: string }[] };
|
|
117
|
+
let seen: Seen = { runs: [], items: [] };
|
|
118
|
+
|
|
119
|
+
const survey = () => {
|
|
120
|
+
const dirs = [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
121
|
+
.filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
|
|
122
|
+
const runs: Seen["runs"] = [], items: Seen["items"] = [];
|
|
123
|
+
for (const dir of dirs) {
|
|
124
|
+
const where = label(dir);
|
|
125
|
+
for (const r of readRuns(dir)) runs.push({ ...r, where });
|
|
126
|
+
for (const g of allBacklog(dir)) for (const t of g.items) items.push({ text: t, where });
|
|
127
|
+
}
|
|
128
|
+
seen = { runs, items };
|
|
129
|
+
};
|
|
130
|
+
survey();
|
|
131
|
+
|
|
132
|
+
const actions = () => [
|
|
133
|
+
...(knownProjects().length || !state ? [
|
|
134
|
+
{ key: "projects", label: "All projects", hint: "every project kaizen knows about" },
|
|
135
|
+
] : []),
|
|
136
|
+
...(state ? [
|
|
137
|
+
{ key: "board", label: "Board", hint: "ideas and runs, by stage" },
|
|
138
|
+
{ key: "runs", label: "Runs", hint: "every run, and what each is waiting on" },
|
|
139
|
+
{ key: "backlog", label: "Backlog", hint: "things to do later" },
|
|
140
|
+
] : []),
|
|
141
|
+
{ key: "settings", label: "Settings", hint: "change how kaizen works" },
|
|
142
|
+
// ~/.kaizen is the global state, not a project: there is nothing to set up
|
|
143
|
+
// there, and it never holds the spec files a project copy does.
|
|
144
|
+
...(state && state !== join(home, ".kaizen") && !existsSync(join(state, "spec.md"))
|
|
145
|
+
? [{ key: "init", label: "Set up this project", hint: "add kaizen to this folder" }] : []),
|
|
146
|
+
...(!state ? [{ key: "init", label: "Set up this project", hint: "add kaizen to this folder" }] : []),
|
|
147
|
+
{ key: "upgrade", label: "Upgrade", hint: "get the latest version" },
|
|
148
|
+
{ key: "quit", label: "Quit", hint: "" },
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
let active = 0;
|
|
152
|
+
mouseOn();
|
|
153
|
+
try {
|
|
154
|
+
for (;;) {
|
|
155
|
+
const chosen = await menu();
|
|
156
|
+
if (chosen === "quit") return;
|
|
157
|
+
if (chosen === "projects") { await projectsView(); continue; }
|
|
158
|
+
if (chosen === "board") { await boardView(); continue; }
|
|
159
|
+
if (chosen === "runs") { await runsView(); continue; }
|
|
160
|
+
if (chosen === "backlog") { await backlogView(); continue; }
|
|
161
|
+
const lines = await run(chosen);
|
|
162
|
+
if (lines?.length) await report(lines);
|
|
163
|
+
survey(); // an action may have changed what exists
|
|
164
|
+
// Settings is one of those actions, and ui.mouse is one of its keys: without
|
|
165
|
+
// this the toggle does nothing until the next launch.
|
|
166
|
+
mouseWanted = mouseFromConfig(state);
|
|
167
|
+
if (mouseWanted) mouseOn(); else mouseOff();
|
|
168
|
+
}
|
|
169
|
+
} finally {
|
|
170
|
+
mouseOff();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// An action that has something to say says it here, on its own screen, rather
|
|
174
|
+
// than printing to a terminal the dashboard is about to paint over.
|
|
175
|
+
async function report(lines: string[], footer = "any key to go back"): Promise<string> {
|
|
176
|
+
stdout.write("\x1b[?1049h\x1b[?25l\x1b[H\x1b[2J");
|
|
177
|
+
stdout.write("\n");
|
|
178
|
+
for (const line of lines) stdout.write(` ${line}\n`);
|
|
179
|
+
stdout.write(`\n ${c.dim(footer)}\n`);
|
|
180
|
+
stdin.setRawMode(true);
|
|
181
|
+
stdin.resume();
|
|
182
|
+
let key = "";
|
|
183
|
+
await new Promise<void>((resolve) => {
|
|
184
|
+
const once = (chunk: Buffer) => {
|
|
185
|
+
const s = chunk.toString();
|
|
186
|
+
if (s.includes("\x03")) process.exit(130);
|
|
187
|
+
const mouse = readMouse(s);
|
|
188
|
+
// A click is a keypress on an any-key screen; the release that follows it
|
|
189
|
+
// must not dismiss the next screen too.
|
|
190
|
+
if (mouse && !isClick(mouse.ev)) return;
|
|
191
|
+
key = mouse ? "" : s;
|
|
192
|
+
stdin.off("data", once);
|
|
193
|
+
resolve();
|
|
194
|
+
};
|
|
195
|
+
stdin.on("data", once);
|
|
196
|
+
});
|
|
197
|
+
stdin.setRawMode(false);
|
|
198
|
+
stdin.pause();
|
|
199
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
200
|
+
return key;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------- views
|
|
204
|
+
|
|
205
|
+
// One scrolling list, used by every view. Returns the chosen index, or null
|
|
206
|
+
// when the user backs out.
|
|
207
|
+
async function pick(
|
|
208
|
+
title: string,
|
|
209
|
+
rows: string[],
|
|
210
|
+
footer = "enter open · backspace back",
|
|
211
|
+
skip: (i: number) => boolean = () => false,
|
|
212
|
+
extra = "", // keys the caller wants to hear about
|
|
213
|
+
): Promise<{ index: number | null; key?: string }> {
|
|
214
|
+
if (!rows.length) { await report([c.bold(title), "", c.dim("nothing here yet")]); return { index: null }; }
|
|
215
|
+
|
|
216
|
+
// Headings are rows too, but they are not items: they are not counted, and
|
|
217
|
+
// moving passes over them rather than landing on them.
|
|
218
|
+
const pickable = rows.map((_, i) => i).filter((i) => !skip(i));
|
|
219
|
+
if (!pickable.length) { await report([c.bold(title), "", c.dim("nothing here yet")]); return { index: null }; }
|
|
220
|
+
let cursor = 0; // index into pickable
|
|
221
|
+
const move = (d: number) => { cursor = Math.min(pickable.length - 1, Math.max(0, cursor + d)); };
|
|
222
|
+
let top = 0;
|
|
223
|
+
const height = () => Math.max(5, (stdout.rows ?? 24) - 8);
|
|
224
|
+
|
|
225
|
+
const draw = () => {
|
|
226
|
+
const h = height();
|
|
227
|
+
const at = pickable[cursor]!;
|
|
228
|
+
if (at < top) top = at;
|
|
229
|
+
if (at >= top + h) top = at - h + 1;
|
|
230
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
231
|
+
stdout.write(`\n ${c.bold(title)} ${c.dim(`${cursor + 1}/${pickable.length}`)}\n\n`);
|
|
232
|
+
for (const [i, row] of rows.slice(top, top + h).entries()) {
|
|
233
|
+
const real = top + i;
|
|
234
|
+
stdout.write(real === at ? ` ${c.cyan("›")} ${fit(row)}\n` : ` ${fit(row)}\n`);
|
|
235
|
+
}
|
|
236
|
+
if (rows.length > h) stdout.write(`\n ${c.dim(top + h < rows.length ? "↓ more" : "")}`);
|
|
237
|
+
stdout.write(`\n\n ${c.dim("↑↓ move · " + footer)}\n`);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
241
|
+
stdin.setRawMode(true);
|
|
242
|
+
stdin.resume();
|
|
243
|
+
draw();
|
|
244
|
+
let chosen: number | null = null, hit: string | undefined;
|
|
245
|
+
await new Promise<void>((resolve) => {
|
|
246
|
+
const onData = (chunk: Buffer) => {
|
|
247
|
+
const keys = chunk.toString();
|
|
248
|
+
for (let i = 0; i < keys.length; i++) {
|
|
249
|
+
const rest = keys.slice(i);
|
|
250
|
+
if (rest.startsWith("\x03")) process.exit(130);
|
|
251
|
+
// Mouse reports are consumed here whether or not they mean anything, so
|
|
252
|
+
// an unexpected one is never read as a burst of keystrokes.
|
|
253
|
+
const mouse = readMouse(rest);
|
|
254
|
+
if (mouse) {
|
|
255
|
+
i += mouse.len - 1;
|
|
256
|
+
const { ev } = mouse;
|
|
257
|
+
if (ev.button === WHEEL_UP) move(-3);
|
|
258
|
+
else if (ev.button === WHEEL_DOWN) move(3);
|
|
259
|
+
else if (isClick(ev)) {
|
|
260
|
+
// Rows begin on the fourth line: blank, title, blank, then the list.
|
|
261
|
+
const real = top + (ev.y - 4);
|
|
262
|
+
const at = pickable.indexOf(real);
|
|
263
|
+
if (at < 0) continue; // a heading, or empty space
|
|
264
|
+
// Click to select; click the selected one to act.
|
|
265
|
+
if (at === cursor) { chosen = real; stdin.off("data", onData); return resolve(); }
|
|
266
|
+
cursor = at;
|
|
267
|
+
} else continue;
|
|
268
|
+
draw();
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (rest.startsWith("q") || rest === "\x1b" || rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
272
|
+
stdin.off("data", onData); return resolve();
|
|
273
|
+
}
|
|
274
|
+
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
275
|
+
chosen = pickable[cursor]!; stdin.off("data", onData); return resolve();
|
|
276
|
+
}
|
|
277
|
+
const k = [...extra].find((x) => rest.startsWith(x));
|
|
278
|
+
if (k) { hit = k; stdin.off("data", onData); return resolve(); }
|
|
279
|
+
if (rest.startsWith("\x1b[A")) { move(-1); i += 2; }
|
|
280
|
+
else if (rest.startsWith("\x1b[B")) { move(1); i += 2; }
|
|
281
|
+
else if (rest.startsWith("k")) move(-1);
|
|
282
|
+
else if (rest.startsWith("j")) move(1);
|
|
283
|
+
else continue;
|
|
284
|
+
draw();
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
stdin.on("data", onData);
|
|
288
|
+
});
|
|
289
|
+
stdin.setRawMode(false);
|
|
290
|
+
stdin.pause();
|
|
291
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
292
|
+
return { index: hit ? pickable[cursor]! : chosen, key: hit };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Every project kaizen knows about, with what each is waiting on. The global
|
|
296
|
+
// ~/.kaizen is one of them: it holds runs made outside any project.
|
|
297
|
+
async function projectsView() {
|
|
298
|
+
for (;;) {
|
|
299
|
+
const dirs = [...knownProjects(), join(home, ".kaizen")].filter(
|
|
300
|
+
(d, i, all) => all.indexOf(d) === i);
|
|
301
|
+
|
|
302
|
+
const rows: string[] = [];
|
|
303
|
+
const open: (string | null)[] = [];
|
|
304
|
+
const width = Math.max(...dirs.map((d) => tilde(d).length));
|
|
305
|
+
|
|
306
|
+
for (const dir of dirs) {
|
|
307
|
+
const st = dir.endsWith(".kaizen") ? dir : join(dir, ".kaizen");
|
|
308
|
+
if (!existsSync(st)) {
|
|
309
|
+
// Listed, not dropped: a project that moved should be visible and fixable,
|
|
310
|
+
// and the registry is a file the user can edit.
|
|
311
|
+
rows.push(`${c.dim(tilde(dir).padEnd(width))} ${c.amber("missing")}`);
|
|
312
|
+
open.push(null);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const runs = readRuns(st);
|
|
316
|
+
const waiting = runs.filter((r) => r.awaiting && r.stage !== "abandoned").length;
|
|
317
|
+
const flight = runs.filter((r) => !r.awaiting && !["done", "abandoned"].includes(r.stage)).length;
|
|
318
|
+
const done = runs.filter((r) => r.stage === "done").length;
|
|
319
|
+
const items = allBacklog(st).reduce((n, g) => n + g.items.length, 0);
|
|
320
|
+
const bits = [
|
|
321
|
+
waiting ? c.amber(`${waiting} waiting`) : "",
|
|
322
|
+
flight ? c.cyan(`${flight} running`) : "",
|
|
323
|
+
done ? c.dim(`${done} done`) : "",
|
|
324
|
+
items ? c.dim(`${items} open`) : "",
|
|
325
|
+
].filter(Boolean);
|
|
326
|
+
rows.push(`${tilde(dir).padEnd(width)} ${bits.join(c.dim(" · ")) || c.dim("nothing yet")}`);
|
|
327
|
+
open.push(st);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const { index, key } = await pick("All projects", rows,
|
|
331
|
+
"enter runs · b backlog · s find more · backspace back", () => false, "sb");
|
|
332
|
+
if (index === null) return;
|
|
333
|
+
if (key === "s") { await sync(); continue; } // the list rebuilds on the next pass
|
|
334
|
+
if (!open[index]) continue; // a project whose state has gone
|
|
335
|
+
if (key === "b") await backlogView(open[index]!);
|
|
336
|
+
else await runsView(open[index]!);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function sync() {
|
|
341
|
+
const before = knownProjects().length;
|
|
342
|
+
const started = Date.now();
|
|
343
|
+
// Only searching under $HOME misses every project on another drive or mount,
|
|
344
|
+
// which on Windows is most of them. The folder the dashboard was opened in is
|
|
345
|
+
// searched too, and it is where the projects being worked on actually are.
|
|
346
|
+
const roots = searchRoots([dirname(state ?? process.cwd()), process.cwd()]);
|
|
347
|
+
const found = [...new Set(roots.flatMap((r) => findProjects(r)))];
|
|
348
|
+
for (const dir of found) remember(dir);
|
|
349
|
+
const added = knownProjects().length - before;
|
|
350
|
+
await report([
|
|
351
|
+
c.bold(added ? `Found ${added} project${added === 1 ? "" : "s"}` : "Nothing new"),
|
|
352
|
+
"",
|
|
353
|
+
...found.map((d) => " " + c.dim(tilde(d))),
|
|
354
|
+
"",
|
|
355
|
+
c.dim(`${found.length} found in ${((Date.now() - started) / 1000).toFixed(1)}s under ${roots.map(tilde).join(", ")}`),
|
|
356
|
+
c.dim(`the list lives in ${tilde(join(home, ".kaizen", "projects"))} and can be edited`),
|
|
357
|
+
]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function runsView(from?: string) {
|
|
361
|
+
for (;;) {
|
|
362
|
+
const here = from ?? state!;
|
|
363
|
+
const runs = readRuns(here);
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
// The dot the board already uses, a four-letter word for anyone not reading
|
|
366
|
+
// colour, and the date dropped from the id: every run made the same day
|
|
367
|
+
// repeated it, which is ten columns saying nothing.
|
|
368
|
+
const width = Math.max(0, ...runs.map((r) => short(r.id).length));
|
|
369
|
+
const rows = runs.map((r) => {
|
|
370
|
+
const { dot, status } = dotFor(r, now);
|
|
371
|
+
const word = { waiting: "wait", running: "run", stalled: "idle", done: "done", abandoned: "drop" }[status] ?? status;
|
|
372
|
+
const mark = status === "waiting" ? c.amber(word.padEnd(4)) : c.dim(word.padEnd(4));
|
|
373
|
+
// The mark already says done or dropped; repeating the stage beside it is noise.
|
|
374
|
+
const tail = r.stage === "done" || r.stage === "abandoned" ? "" : (r.awaiting ?? r.stage);
|
|
375
|
+
return `${dot} ${mark} ${short(r.id).padEnd(width)} ${c.dim(tail)}`;
|
|
376
|
+
});
|
|
377
|
+
const { index } = await pick(from ? `Runs — ${label(from)}` : "Runs", rows);
|
|
378
|
+
if (index === null) return;
|
|
379
|
+
await runDetail(runs[index]!, here);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// What a run is, read off its own files rather than summarised from memory.
|
|
384
|
+
async function runDetail(r: Run, from?: string) {
|
|
385
|
+
const dir = join(from ?? state!, "runs", r.id);
|
|
386
|
+
const lines: string[] = [c.bold(r.id), ""];
|
|
387
|
+
lines.push(` stage ${r.stage}`);
|
|
388
|
+
lines.push(` waiting ${r.awaiting ?? c.dim("nothing — it can carry on")}`);
|
|
389
|
+
|
|
390
|
+
const request = section(join(dir, "00-request.md"), 6);
|
|
391
|
+
if (request.length) lines.push("", c.bold(" Request"), ...request.map((l) => " " + l));
|
|
392
|
+
|
|
393
|
+
const findings = readFindings(join(dir, "04-review.md"));
|
|
394
|
+
if (findings.length) {
|
|
395
|
+
lines.push("", c.bold(` Findings (${findings.length})`));
|
|
396
|
+
for (const f of findings.slice(0, 8)) lines.push(" " + f);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const items = readBacklog(join(dir, "06-backlog.md"));
|
|
400
|
+
if (items.length) {
|
|
401
|
+
lines.push("", c.bold(` Backlog (${items.length} open)`));
|
|
402
|
+
for (const it of items.slice(0, 8)) lines.push(" " + it);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
lines.push("", c.dim(` files in ${tilde(dir)}`));
|
|
406
|
+
await report(lines);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Cut to the window, counting printable characters only -- a row is mostly colour
|
|
410
|
+
// codes by the time it gets here, and they take no space on screen.
|
|
411
|
+
function fit(row: string) {
|
|
412
|
+
const room = (stdout.columns ?? 80) - 8;
|
|
413
|
+
const plain = row.replace(/\x1b\[[0-9;]*m/g, "");
|
|
414
|
+
if (plain.length <= room) return row;
|
|
415
|
+
let out = "", seen = 0, i = 0;
|
|
416
|
+
while (i < row.length && seen < room - 1) {
|
|
417
|
+
const esc = /^\x1b\[[0-9;]*m/.exec(row.slice(i));
|
|
418
|
+
if (esc) { out += esc[0]; i += esc[0].length; continue; }
|
|
419
|
+
out += row[i]; i++; seen++;
|
|
420
|
+
}
|
|
421
|
+
return out + "…\x1b[0m";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function backlogView(from?: string) {
|
|
425
|
+
const groups = allBacklog(from ?? state!);
|
|
426
|
+
const parsed = groups.map((g) => ({ run: g.run, items: g.items.map(parseItem) }));
|
|
427
|
+
const sevWidth = Math.max(0, ...parsed.flatMap((g) => g.items.map((i) => i.severity?.length ?? 0)));
|
|
428
|
+
const whereWidth = Math.max(0, ...parsed.flatMap((g) => g.items.map((i) => i.where?.length ?? 0)));
|
|
429
|
+
|
|
430
|
+
const rows: string[] = [];
|
|
431
|
+
const full: (Item | null)[] = []; // null where the row is a heading
|
|
432
|
+
for (const g of parsed) {
|
|
433
|
+
rows.push(c.bold(g.run.replace(/^\d{4}-\d{2}-\d{2}-/, ""))); full.push(null);
|
|
434
|
+
for (const it of g.items) {
|
|
435
|
+
// Columns only where there is something to put in them: an item written as
|
|
436
|
+
// prose should not be pushed across the screen by other items' severities.
|
|
437
|
+
const cols: string[] = [];
|
|
438
|
+
if (sevWidth) {
|
|
439
|
+
const sev = (it.severity ?? "").padEnd(sevWidth);
|
|
440
|
+
cols.push(it.severity === "critical" || it.severity === "high" ? c.amber(sev) : c.dim(sev));
|
|
441
|
+
}
|
|
442
|
+
if (whereWidth) cols.push(c.dim((it.where ?? "").padEnd(whereWidth)));
|
|
443
|
+
rows.push(" " + [...cols, it.text].join(" "));
|
|
444
|
+
full.push(it);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (;;) {
|
|
448
|
+
const { index, key } = await pick(
|
|
449
|
+
from ? `Backlog — ${label(from)}` : "Backlog — open items",
|
|
450
|
+
rows,
|
|
451
|
+
"enter read · r run in agent · backspace back",
|
|
452
|
+
(n) => full[n] === null,
|
|
453
|
+
"rR",
|
|
454
|
+
);
|
|
455
|
+
if (index === null) return;
|
|
456
|
+
// Rows are cut to the window, so reading one means opening it.
|
|
457
|
+
const it = full[index];
|
|
458
|
+
if (!it) continue;
|
|
459
|
+
if (key === "r" || key === "R") {
|
|
460
|
+
await startBacklogItem(it, from ?? state!);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const k = await report([
|
|
464
|
+
c.bold(it.where ?? "Backlog item"),
|
|
465
|
+
it.severity ? c.dim(` ${it.severity}`) : "",
|
|
466
|
+
"",
|
|
467
|
+
...wrap(it.raw.replace(/`/g, ""), 2),
|
|
468
|
+
], "r run in agent · any other key to go back");
|
|
469
|
+
if (k === "r" || k === "R") {
|
|
470
|
+
await startBacklogItem(it, from ?? state!);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ------------------------------------------------------------------ board
|
|
476
|
+
|
|
477
|
+
// Ideas and runs on one screen, in stage columns. A run's stage belongs to
|
|
478
|
+
// kaizen and cannot be edited here -- a board that could drag one backwards
|
|
479
|
+
// would be lying about what happened. The one exception is abandoning a run,
|
|
480
|
+
// which is the user's decision either way.
|
|
481
|
+
async function boardView() {
|
|
482
|
+
let allProjects = false;
|
|
483
|
+
let cards: Drawn[] = [];
|
|
484
|
+
let col = 0, row = 0;
|
|
485
|
+
let top = 0; // first visible line, board-wide
|
|
486
|
+
// Where each card actually landed on screen, rebuilt by whichever draw ran.
|
|
487
|
+
// Recording the frame beats recomputing its geometry: the two layouts place
|
|
488
|
+
// cards differently, and a stale guess after a resize clicks the wrong card.
|
|
489
|
+
type Hit = { y: number; x0: number; x1: number; col: number; row: number };
|
|
490
|
+
let hits: Hit[] = [];
|
|
491
|
+
// What each run was awaiting last time we looked, so a redraw can tell the
|
|
492
|
+
// difference between "still blocked" and "just became blocked".
|
|
493
|
+
const wasAwaiting = new Map<string, string | null>();
|
|
494
|
+
let first = true;
|
|
495
|
+
|
|
496
|
+
const states = () => (allProjects
|
|
497
|
+
? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
|
|
498
|
+
: [state!]
|
|
499
|
+
).filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
|
|
500
|
+
|
|
501
|
+
const refresh = () => {
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
const next: Drawn[] = [];
|
|
504
|
+
for (const k of boardCards(states(), now)) {
|
|
505
|
+
if (k.archived) continue; // cleared from the board; kaizen web shows the archive
|
|
506
|
+
if (k.kind === "run") {
|
|
507
|
+
const key = `${k.state}/${k.id}`;
|
|
508
|
+
const before = wasAwaiting.get(key);
|
|
509
|
+
if (!first && before === null && k.awaiting) {
|
|
510
|
+
notify("kaizen — waiting on you", `${k.text} · ${k.awaiting}`);
|
|
511
|
+
}
|
|
512
|
+
wasAwaiting.set(key, k.awaiting);
|
|
513
|
+
}
|
|
514
|
+
next.push({ ...k, dot: DOTS[k.status as Status | "idea" | "starting"]() });
|
|
515
|
+
}
|
|
516
|
+
cards = next;
|
|
517
|
+
first = false;
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const inColumn = (n: number) => cards.filter((k) => k.column === n);
|
|
521
|
+
const current = () => inColumn(col)[row];
|
|
522
|
+
|
|
523
|
+
const draw = () => {
|
|
524
|
+
const width = stdout.columns ?? 80;
|
|
525
|
+
// Five columns need room to say anything. Below that the board is worse
|
|
526
|
+
// than the list it replaces, so it becomes one.
|
|
527
|
+
if (width < 100) { drawNarrow(); return; }
|
|
528
|
+
const inner = Math.floor((width - 4) / COLUMNS.length) - 2;
|
|
529
|
+
const cols = COLUMNS.map((meta, n) => {
|
|
530
|
+
const lines: string[] = [];
|
|
531
|
+
const starts: number[] = [];
|
|
532
|
+
for (const k of inColumn(n)) {
|
|
533
|
+
starts.push(lines.length);
|
|
534
|
+
const chosen = n === col && inColumn(n)[row] === k;
|
|
535
|
+
const body = wrapTo(k.text, inner - 4);
|
|
536
|
+
for (const [i, line] of body.entries()) {
|
|
537
|
+
const marker = i === 0 ? (chosen ? c.cyan("›") : " ") : " ";
|
|
538
|
+
const dot = i === 0 ? k.dot : " ";
|
|
539
|
+
const painted = k.awaiting ? c.red(line) : k.dim ? c.dim(line) : line;
|
|
540
|
+
lines.push(`${marker} ${dot} ${painted}`);
|
|
541
|
+
}
|
|
542
|
+
if (allProjects && k.where !== "no project") lines.push(" " + c.dim(cut(basename(k.where), inner - 4)));
|
|
543
|
+
// First note line only; the rest is for the web board's detail panel.
|
|
544
|
+
if (k.notes) { const n = parseNotes(k.notes); const l = n[n.length - 1]?.text.split("\n")[0]; if (l) lines.push(" " + c.dim(cut(l, inner - 4))); }
|
|
545
|
+
lines.push("");
|
|
546
|
+
}
|
|
547
|
+
return { meta, lines, starts };
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
551
|
+
const scope = allProjects ? "all projects" : label(state!);
|
|
552
|
+
stdout.write(`\n ${c.bold("Board")} ${c.dim(scope)}\n\n`);
|
|
553
|
+
stdout.write(" " + cols.map(({ meta }, n) =>
|
|
554
|
+
pad(n === col ? c.cyan(meta.title) : c.dim(meta.title), inner)).join(" ") + "\n");
|
|
555
|
+
// Only the column you are in gets a solid rule; the rest fade to a dotted one,
|
|
556
|
+
// which says where the cursor is without painting a block over the card.
|
|
557
|
+
stdout.write(" " + cols.map((_, n) =>
|
|
558
|
+
n === col ? c.cyan("─".repeat(inner)) : c.dim("┈".repeat(inner))).join(" ") + "\n");
|
|
559
|
+
const height = Math.max(4, (stdout.rows ?? 24) - 11);
|
|
560
|
+
const deep = Math.max(0, ...cols.map((x) => x.lines.length));
|
|
561
|
+
// The selected card drags the viewport with it; a cursor that can leave the
|
|
562
|
+
// screen is a cursor that deletes cards the user cannot see.
|
|
563
|
+
const sel = cols[col]!.starts[row] ?? 0;
|
|
564
|
+
if (sel < top) top = sel;
|
|
565
|
+
if (sel >= top + height) top = sel - height + 1;
|
|
566
|
+
top = Math.max(0, Math.min(top, Math.max(0, deep - height)));
|
|
567
|
+
hits = [];
|
|
568
|
+
for (let i = top; i < Math.min(deep, top + height); i++) {
|
|
569
|
+
// Cards begin on the sixth line: blank, title, blank, headings, rule.
|
|
570
|
+
const y = 6 + (i - top);
|
|
571
|
+
for (const [n, col_] of cols.entries()) {
|
|
572
|
+
const x0 = 3 + n * (inner + 2);
|
|
573
|
+
const owns = col_.starts.reduce((found, at, idx) => (at <= i ? idx : found), -1);
|
|
574
|
+
if (owns < 0) continue;
|
|
575
|
+
const ends = col_.starts[owns + 1] ?? col_.lines.length;
|
|
576
|
+
if (i >= ends || !(col_.lines[i] ?? "").trim()) continue;
|
|
577
|
+
hits.push({ y, x0, x1: x0 + inner - 1, col: n, row: owns });
|
|
578
|
+
}
|
|
579
|
+
stdout.write(" " + cols.map((x) => pad(x.lines[i] ?? "", inner)).join(" ") + "\n");
|
|
580
|
+
}
|
|
581
|
+
const above = top > 0, below = top + height < deep;
|
|
582
|
+
if (above || below) {
|
|
583
|
+
stdout.write(` ${c.dim(`${above ? "↑" : " "} ${below ? "↓ more" : ""}`)}\n`);
|
|
584
|
+
}
|
|
585
|
+
stdout.write("\n" + footer(current()) + legend());
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
// Keys grouped by what they are for -- moving, acting, leaving -- separated by
|
|
589
|
+
// space rather than by middots, which made one long undifferentiated line.
|
|
590
|
+
const footer = (k?: Card) => {
|
|
591
|
+
const acts = k?.kind === "idea"
|
|
592
|
+
? "n new e edit t note x reject d delete r run"
|
|
593
|
+
: k ? "n new idea t note x abandon this run" : "n new idea";
|
|
594
|
+
const scope = allProjects ? "a this project" : "a all projects";
|
|
595
|
+
return ` ${c.dim("↑↓←→ move")} ${c.dim(acts)} ${c.dim(scope)} ${c.dim("q back")}\n`;
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// Only the states actually on the board: a legend for dots nothing is using is
|
|
599
|
+
// six items of noise under every screen.
|
|
600
|
+
const legend = () => {
|
|
601
|
+
const present = new Set(cards.map((k) => k.status));
|
|
602
|
+
const all: [string, string][] = [
|
|
603
|
+
["running", `${c.cyan("●")} ${c.dim("running")}`],
|
|
604
|
+
["stalled", c.dim("◐ stalled")],
|
|
605
|
+
["starting", c.dim("◌ starting")],
|
|
606
|
+
["waiting", `${c.amber("●")} ${c.red("waiting on you")}`],
|
|
607
|
+
["done", c.dim("○ done")],
|
|
608
|
+
["abandoned", c.dim("○ abandoned")],
|
|
609
|
+
["idea", c.dim("· idea")],
|
|
610
|
+
];
|
|
611
|
+
const parts = all.filter(([key]) => present.has(key)).map(([, text]) => text);
|
|
612
|
+
return parts.length ? "\n " + parts.join(" ") + "\n" : "\n";
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Under 100 columns the board stacks: same cards, same keys, one list.
|
|
616
|
+
const drawNarrow = () => {
|
|
617
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
618
|
+
stdout.write(`\n ${c.bold("Board")} ${c.dim("narrow terminal — stacked")}\n\n`);
|
|
619
|
+
hits = [];
|
|
620
|
+
// The header writes three newlines, so the first heading lands on line 4.
|
|
621
|
+
let y = 4;
|
|
622
|
+
for (const [n, meta] of COLUMNS.entries()) {
|
|
623
|
+
const items = inColumn(n);
|
|
624
|
+
if (!items.length) continue;
|
|
625
|
+
stdout.write(` ${n === col ? c.cyan(meta.title) : c.dim(meta.title)}\n`);
|
|
626
|
+
y++;
|
|
627
|
+
for (const [idx, k] of items.entries()) {
|
|
628
|
+
const chosen = n === col && items[row] === k;
|
|
629
|
+
const text = k.awaiting ? c.red(k.text) : k.dim ? c.dim(k.text) : k.text;
|
|
630
|
+
stdout.write(` ${chosen ? c.cyan("›") : " "} ${k.dot} ${cut(text, (stdout.columns ?? 80) - 8)}\n`);
|
|
631
|
+
hits.push({ y, x0: 1, x1: stdout.columns ?? 80, col: n, row: idx });
|
|
632
|
+
y++;
|
|
633
|
+
}
|
|
634
|
+
stdout.write("\n");
|
|
635
|
+
y++;
|
|
636
|
+
}
|
|
637
|
+
stdout.write("\n" + footer(current()) + legend());
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const cardAt = (x: number, y: number) =>
|
|
641
|
+
hits.find((h) => h.y === y && x >= h.x0 && x <= h.x1) ?? null;
|
|
642
|
+
|
|
643
|
+
const clamp = () => {
|
|
644
|
+
col = Math.min(COLUMNS.length - 1, Math.max(0, col));
|
|
645
|
+
row = Math.min(Math.max(0, inColumn(col).length - 1), Math.max(0, row));
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
refresh();
|
|
649
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
650
|
+
stdin.setRawMode(true);
|
|
651
|
+
stdin.resume();
|
|
652
|
+
|
|
653
|
+
// Kaizen writes these files from other processes; watching them is what makes
|
|
654
|
+
// the board move on its own.
|
|
655
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
656
|
+
let watchers: ReturnType<typeof watch>[] = [];
|
|
657
|
+
const onChange = () => {
|
|
658
|
+
if (timer) clearTimeout(timer);
|
|
659
|
+
// A state.json being rewritten is briefly unparseable; readRuns already
|
|
660
|
+
// drops what it cannot parse, and the delay keeps that window off screen.
|
|
661
|
+
timer = setTimeout(() => { refresh(); clamp(); draw(); arm(); }, 150);
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// fs.watch is not recursive here, and the file that actually changes is
|
|
665
|
+
// runs/<id>/state.json -- two levels down. Watching only the state dir and
|
|
666
|
+
// runs/ sees a new run appear and never sees an existing one move, so every
|
|
667
|
+
// run directory is watched too, and the set is rebuilt whenever anything
|
|
668
|
+
// fires in case the change was a run being created.
|
|
669
|
+
function arm() {
|
|
670
|
+
for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
|
|
671
|
+
watchers = [];
|
|
672
|
+
for (const st of states()) {
|
|
673
|
+
const runs = join(st, "runs");
|
|
674
|
+
const targets = [st, runs];
|
|
675
|
+
try { for (const id of readdirSync(runs)) targets.push(join(runs, id)); } catch { /* no runs yet */ }
|
|
676
|
+
for (const target of targets) {
|
|
677
|
+
try { if (existsSync(target)) watchers.push(watch(target, { persistent: false }, onChange)); }
|
|
678
|
+
catch { /* unwatchable path */ }
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
arm();
|
|
683
|
+
|
|
684
|
+
try {
|
|
685
|
+
draw();
|
|
686
|
+
for (;;) {
|
|
687
|
+
const key = await nextKey();
|
|
688
|
+
if (key === "quit") break;
|
|
689
|
+
if (key === "a") { allProjects = !allProjects; refresh(); clamp(); draw(); arm(); continue; }
|
|
690
|
+
if (key === "left") { col--; row = 0; clamp(); draw(); continue; }
|
|
691
|
+
if (key === "right") { col++; row = 0; clamp(); draw(); continue; }
|
|
692
|
+
if (key === "up") { row--; clamp(); draw(); continue; }
|
|
693
|
+
if (key === "down") { row++; clamp(); draw(); continue; }
|
|
694
|
+
|
|
695
|
+
const target = current();
|
|
696
|
+
if (key === "n") {
|
|
697
|
+
const text = await promptLine("New idea", "");
|
|
698
|
+
if (text) {
|
|
699
|
+
const st = target?.state ?? state!;
|
|
700
|
+
writeInbox(st, [...readInbox(st), { status: "open", text }]);
|
|
701
|
+
}
|
|
702
|
+
refresh(); col = 0; clamp(); draw(); continue;
|
|
703
|
+
}
|
|
704
|
+
if (!target) { draw(); continue; }
|
|
705
|
+
// Runs are otherwise read-only -- their stage belongs to kaizen -- but
|
|
706
|
+
// abandoning one is the user's decision to make, and /kaizen abort is the
|
|
707
|
+
// same single transition this writes.
|
|
708
|
+
if (target.kind === "run") {
|
|
709
|
+
if (key === "t" && target.id && target.column < 4) {
|
|
710
|
+
const line = await promptLine("Add a note", "");
|
|
711
|
+
if (line) appendNote(target.state, { id: target.id }, line);
|
|
712
|
+
refresh();
|
|
713
|
+
}
|
|
714
|
+
if (key === "x" && target.id) {
|
|
715
|
+
const why = await promptLine(`Abandon ${target.text}? Reason`, "");
|
|
716
|
+
if (why) {
|
|
717
|
+
const failed = abandonRun(target.state, target.id, why);
|
|
718
|
+
if (failed) await outside(() => report([c.bold("Could not abandon"), "", c.dim(failed)]));
|
|
719
|
+
}
|
|
720
|
+
refresh(); clamp();
|
|
721
|
+
}
|
|
722
|
+
draw(); continue;
|
|
723
|
+
}
|
|
724
|
+
if (key === "e") {
|
|
725
|
+
const text = await promptLine("Edit idea", target.text);
|
|
726
|
+
if (text) replaceIdea(target.state, target.text, { status: "open", text });
|
|
727
|
+
} else if (key === "t" && target.status === "idea") {
|
|
728
|
+
const line = await promptLine("Add a note", "");
|
|
729
|
+
if (line) appendNote(target.state, { text: target.text }, line);
|
|
730
|
+
} else if (key === "x") {
|
|
731
|
+
const why = await promptLine(`Reject: ${target.text}`, "");
|
|
732
|
+
// The spec requires a reason on a rejected item; no reason, no rejection.
|
|
733
|
+
if (why) replaceIdea(target.state, target.text, { status: "rejected", text: `${target.text} | ${why}` });
|
|
734
|
+
} else if (key === "d") {
|
|
735
|
+
if (await confirm(`Delete "${cut(target.text, 40)}"?`)) replaceIdea(target.state, target.text, null);
|
|
736
|
+
} else if (key === "r") {
|
|
737
|
+
const mode = await pickMode();
|
|
738
|
+
// "" is the default-mode choice, and must not leave a double space in
|
|
739
|
+
// the prompt the agent is launched with.
|
|
740
|
+
if (mode !== null) {
|
|
741
|
+
const request = `${mode} ${target.text}`.trim();
|
|
742
|
+
await outside(() => startBacklogItem({ ...parseItem(request), notes: target.notes }, target.state));
|
|
743
|
+
// Without this the idea stays open forever and the board shows the
|
|
744
|
+
// same work twice: once as an idea, once as the run it became.
|
|
745
|
+
replaceIdea(target.state, target.text, { status: "started", text: target.text });
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
refresh(); clamp(); draw();
|
|
749
|
+
}
|
|
750
|
+
} finally {
|
|
751
|
+
for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
|
|
752
|
+
watchers = [];
|
|
753
|
+
if (timer) clearTimeout(timer);
|
|
754
|
+
stdin.setRawMode(false);
|
|
755
|
+
stdin.pause();
|
|
756
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// startBacklogItem paints its own screens, so the board steps out of the way
|
|
760
|
+
// and takes the terminal back afterwards.
|
|
761
|
+
async function outside(fn: () => Promise<void>) {
|
|
762
|
+
stdin.setRawMode(false);
|
|
763
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
764
|
+
try {
|
|
765
|
+
await fn();
|
|
766
|
+
} finally {
|
|
767
|
+
// Without this the board's own finally restores a terminal that was never
|
|
768
|
+
// put back into raw mode, and the user is left with no echo.
|
|
769
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
770
|
+
stdin.setRawMode(true);
|
|
771
|
+
stdin.resume();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// One chunk can carry several keys -- a held arrow, a paste, fast typing --
|
|
776
|
+
// so it is walked rather than compared whole.
|
|
777
|
+
function nextKey(): Promise<string> {
|
|
778
|
+
return new Promise((resolve) => {
|
|
779
|
+
const onData = (chunk: Buffer) => {
|
|
780
|
+
const keys = chunk.toString();
|
|
781
|
+
if (keys.includes("\x03")) process.exit(130);
|
|
782
|
+
const done = (v: string) => { stdin.off("data", onData); resolve(v); };
|
|
783
|
+
for (let i = 0; i < keys.length; i++) {
|
|
784
|
+
const rest = keys.slice(i);
|
|
785
|
+
const mouse = readMouse(rest);
|
|
786
|
+
if (mouse) {
|
|
787
|
+
i += mouse.len - 1;
|
|
788
|
+
const { ev } = mouse;
|
|
789
|
+
if (ev.button === WHEEL_UP) return done("up");
|
|
790
|
+
if (ev.button === WHEEL_DOWN) return done("down");
|
|
791
|
+
if (!isClick(ev)) continue;
|
|
792
|
+
const target = cardAt(ev.x, ev.y);
|
|
793
|
+
if (!target) continue;
|
|
794
|
+
// Click to select; click the selected card to act, which on an idea
|
|
795
|
+
// means the same thing r does.
|
|
796
|
+
if (target.col === col && target.row === row) return done("r");
|
|
797
|
+
col = target.col; row = target.row;
|
|
798
|
+
draw();
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (rest.startsWith("\x1b[D")) return done("left");
|
|
802
|
+
if (rest.startsWith("\x1b[C")) return done("right");
|
|
803
|
+
if (rest.startsWith("\x1b[A")) return done("up");
|
|
804
|
+
if (rest.startsWith("\x1b[B")) return done("down");
|
|
805
|
+
const ch = keys[i]!;
|
|
806
|
+
if (ch === "h") return done("left");
|
|
807
|
+
if (ch === "l") return done("right");
|
|
808
|
+
if (ch === "k") return done("up");
|
|
809
|
+
if (ch === "j") return done("down");
|
|
810
|
+
if (ch === "q" || ch === "\x1b" || ch === "\x7f" || ch === "\b") return done("quit");
|
|
811
|
+
if ("nexdra".includes(ch)) return done(ch);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
stdin.on("data", onData);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function promptLine(title: string, initial: string): Promise<string | null> {
|
|
819
|
+
let buf = initial;
|
|
820
|
+
const paint = () => {
|
|
821
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
822
|
+
stdout.write(`\n ${c.bold(title)}\n\n ${buf}${c.cyan("█")}\n\n ${c.dim("enter save · esc cancel")}\n`);
|
|
823
|
+
};
|
|
824
|
+
paint();
|
|
825
|
+
return new Promise((resolve) => {
|
|
826
|
+
const onData = (chunk: Buffer) => {
|
|
827
|
+
const keys = chunk.toString();
|
|
828
|
+
if (keys.includes("\x03")) process.exit(130);
|
|
829
|
+
const done = (v: string | null) => { stdin.off("data", onData); resolve(v); };
|
|
830
|
+
// Character by character: a held backspace or a paste arrives as one
|
|
831
|
+
// chunk, and comparing the whole chunk to "\x7f" writes the raw bytes
|
|
832
|
+
// into the file instead of deleting anything.
|
|
833
|
+
for (let i = 0; i < keys.length; i++) {
|
|
834
|
+
// Skipped whole, or its digits and semicolons land in the text.
|
|
835
|
+
const mouse = readMouse(keys.slice(i));
|
|
836
|
+
if (mouse) { i += mouse.len - 1; continue; }
|
|
837
|
+
const ch = keys[i]!;
|
|
838
|
+
if (ch === "\x1b") return done(null);
|
|
839
|
+
if (ch === "\r" || ch === "\n") return done(buf.trim() || null);
|
|
840
|
+
if (ch === "\x7f" || ch === "\b") { buf = buf.slice(0, -1); continue; }
|
|
841
|
+
// Anything else unprintable is dropped rather than stored: control
|
|
842
|
+
// bytes in an inbox line survive every later read of that file.
|
|
843
|
+
if (ch >= " " && ch !== "\x7f") buf += ch;
|
|
844
|
+
}
|
|
845
|
+
paint();
|
|
846
|
+
};
|
|
847
|
+
stdin.on("data", onData);
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function confirm(question: string): Promise<boolean> {
|
|
852
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
853
|
+
stdout.write(`\n ${c.bold(question)}\n\n ${c.dim("y delete · any other key keep")}\n`);
|
|
854
|
+
const key = await nextRaw();
|
|
855
|
+
return key === "y" || key === "Y";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// full or lite, asked at the moment a run starts rather than fixed in config.
|
|
859
|
+
async function pickMode(): Promise<string | null> {
|
|
860
|
+
stdout.write("\x1b[H\x1b[2J");
|
|
861
|
+
stdout.write(`\n ${c.bold("Start this run how?")}\n\n`);
|
|
862
|
+
stdout.write(` ${c.cyan("f")} full ${c.dim("every stage its own cold agent — the reviewer starts blind")}\n`);
|
|
863
|
+
stdout.write(` ${c.cyan("l")} lite ${c.dim("one session runs every stage — cheaper, review has seen the work")}\n`);
|
|
864
|
+
stdout.write(` ${c.cyan("d")} default${c.dim(" whatever config.yml says")}\n\n ${c.dim("click a line, or any other key to cancel")}\n`);
|
|
865
|
+
// Options start on the fourth line: blank, title, blank, then f, l, d.
|
|
866
|
+
const key = await nextRaw((y) => ["f", "l", "d"][y - 4] ?? null);
|
|
867
|
+
return key === "f" ? "full" : key === "l" ? "lite" : key === "d" ? "" : null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// A click arrives as two reports, press and release. The press is what opened
|
|
871
|
+
// this screen, so the release must not be read as its answer -- and a click on
|
|
872
|
+
// one of the offered lines answers it properly, via `clickable`.
|
|
873
|
+
function nextRaw(clickable?: (y: number) => string | null): Promise<string> {
|
|
874
|
+
return new Promise((resolve) => {
|
|
875
|
+
const onData = (chunk: Buffer) => {
|
|
876
|
+
const keys = chunk.toString();
|
|
877
|
+
if (keys.includes("\x03")) process.exit(130);
|
|
878
|
+
const done = (v: string) => { stdin.off("data", onData); resolve(v); };
|
|
879
|
+
for (let i = 0; i < keys.length; i++) {
|
|
880
|
+
const mouse = readMouse(keys.slice(i));
|
|
881
|
+
if (mouse) {
|
|
882
|
+
i += mouse.len - 1;
|
|
883
|
+
if (!clickable || !isClick(mouse.ev)) continue;
|
|
884
|
+
const answer = clickable(mouse.ev.y);
|
|
885
|
+
if (answer) return done(answer);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
return done(keys.slice(i));
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
stdin.on("data", onData);
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function startBacklogItem(it: Item, from: string) {
|
|
897
|
+
const r = launchRun(it, from);
|
|
898
|
+
if (!r.ok) {
|
|
899
|
+
await report([
|
|
900
|
+
c.bold(r.why.startsWith("No supported") || r.why.startsWith("Terminal.app") ? "Could not open terminal" : "Failed to spawn terminal"),
|
|
901
|
+
"",
|
|
902
|
+
c.dim(r.why),
|
|
903
|
+
"",
|
|
904
|
+
"Run manually:",
|
|
905
|
+
` ${c.cyan(r.manual)}`,
|
|
906
|
+
]);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
await report([
|
|
910
|
+
c.bold("Started in new terminal"),
|
|
911
|
+
"",
|
|
912
|
+
` ${c.cyan("Agent")} ${r.agent.name} (${c.bold(r.agent.cmd)})`,
|
|
913
|
+
` ${c.cyan("Project")} ${tilde(r.projectDir)}`,
|
|
914
|
+
` ${c.cyan("Task")} ${it.text}`,
|
|
915
|
+
...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
|
|
916
|
+
...(it.severity ? [` ${c.cyan("Severity")} ${it.severity}`] : []),
|
|
917
|
+
"",
|
|
918
|
+
c.dim(`Opened in new window running: ${r.agent.cmd} "${r.prompt}"`),
|
|
919
|
+
]);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function wrap(text: string, indent = 0) {
|
|
923
|
+
const room = (stdout.columns ?? 80) - 6 - indent;
|
|
924
|
+
const out: string[] = [];
|
|
925
|
+
let line = "";
|
|
926
|
+
for (const word of text.split(/\s+/)) {
|
|
927
|
+
if (line && line.length + word.length + 1 > room) { out.push(" ".repeat(indent) + line); line = word; }
|
|
928
|
+
else line = line ? `${line} ${word}` : word;
|
|
929
|
+
}
|
|
930
|
+
if (line) out.push(" ".repeat(indent) + line);
|
|
931
|
+
return out;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
async function menu() {
|
|
935
|
+
let actsTop = 0; // screen line of the first action row
|
|
936
|
+
let printed = 0; // lines this draw has written
|
|
937
|
+
const w = (text: string) => { printed += (text.match(/\n/g) ?? []).length; stdout.write(text); };
|
|
938
|
+
const draw = () => {
|
|
939
|
+
printed = 0;
|
|
940
|
+
const acts = actions();
|
|
941
|
+
w("\x1b[H\x1b[2J");
|
|
942
|
+
|
|
943
|
+
// The mascot sits beside the header rather than above it; stacked, it pushes
|
|
944
|
+
// the runs -- the thing you opened this for -- below the fold on a short window.
|
|
945
|
+
const cols = stdout.columns ?? 80, rows = stdout.rows ?? 24;
|
|
946
|
+
const wide = cols >= 64;
|
|
947
|
+
const art = TANUKI;
|
|
948
|
+
const at = 2; // where the text sits against it
|
|
949
|
+
|
|
950
|
+
// Every width below is measured, not guessed: the mascot's own width plus the
|
|
951
|
+
// gutter plus the wordmark's columns is exactly what the wide header occupies,
|
|
952
|
+
// and 2 more is the left margin every line is written with.
|
|
953
|
+
const gutter = Math.max(...art.map((l) => l.length)) + 4;
|
|
954
|
+
const setsWordmark = cols >= gutter + WORDMARK[0]!.length + 2;
|
|
955
|
+
|
|
956
|
+
const beside = Array(art.length).fill("");
|
|
957
|
+
if (setsWordmark) {
|
|
958
|
+
// Against the mascot's middle, with what kaizen is under it and where you are
|
|
959
|
+
// under that: the header says what the tool does before it says where it is.
|
|
960
|
+
const top = 2;
|
|
961
|
+
for (const [i, line] of WORDMARK.entries()) {
|
|
962
|
+
const t = i / (WORDMARK.length - 1);
|
|
963
|
+
beside[top + i] = rgb(
|
|
964
|
+
Math.round(222 - t * 120), Math.round(238 - t * 100), Math.round(255 - t * 30), line);
|
|
965
|
+
}
|
|
966
|
+
// The description is chosen from compact phrasings to fit cleanly beside the mascot.
|
|
967
|
+
const room = cols - gutter - 2 - (version(repo).length + 3);
|
|
968
|
+
const line = ["plan · approve · build · review",
|
|
969
|
+
"plan · approve · build",
|
|
970
|
+
"plan · build · review"].find((d) => d.length <= room);
|
|
971
|
+
beside[top + WORDMARK.length + 1] =
|
|
972
|
+
`${c.dim(version(repo))}${line ? " " + c.dim(line) : ""}`;
|
|
973
|
+
beside[top + WORDMARK.length + 2] = c.dim(state ? tilde(dirname(state)) : "no project here");
|
|
974
|
+
beside[top + WORDMARK.length + 3] = c.dim(agents.join(", "));
|
|
975
|
+
} else {
|
|
976
|
+
beside[at] = `${c.bold("kaizen")} ${c.dim(version(repo))}`;
|
|
977
|
+
beside[at + 1] = c.dim(state ? tilde(dirname(state)) : "no project here");
|
|
978
|
+
beside[at + 3] = c.dim(agents.join(", "));
|
|
979
|
+
}
|
|
980
|
+
w("\n");
|
|
981
|
+
if (wide) {
|
|
982
|
+
// Cut what sits beside the mascot to the room left over. The agent list is
|
|
983
|
+
// long and grows with every agent installed, and it wrapped here before the
|
|
984
|
+
// wordmark existed -- a wrapped line pushes the whole header down a row.
|
|
985
|
+
for (const [i, line] of art.entries()) {
|
|
986
|
+
const t = i / (art.length - 1);
|
|
987
|
+
const r = Math.round(235 - t * 135);
|
|
988
|
+
const g = Math.round(245 - t * 110);
|
|
989
|
+
const b = Math.round(255 - t * 20);
|
|
990
|
+
w(" " + rgb(r, g, b, line.padEnd(gutter)) + cut(beside[i] ?? "", cols - gutter - 2) + "\n");
|
|
991
|
+
}
|
|
992
|
+
} else {
|
|
993
|
+
w(` ${c.bold("kaizen")} ${c.dim(version(repo))} ${c.dim(state ? tilde(dirname(state)) : "no project here")}\n`);
|
|
994
|
+
w(` ${c.dim(agents.join(", "))}\n`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (state) {
|
|
998
|
+
// Across every project, not only this one. Where more than one is known each
|
|
999
|
+
// row says which, since "waiting on approvals.plan" means nothing on its own.
|
|
1000
|
+
const many = new Set(seen.runs.map((r) => r.where)).size > 1;
|
|
1001
|
+
const from = (w: string) => (many ? c.dim(w + " ") : "");
|
|
1002
|
+
const wait = seen.runs.filter((r) => r.awaiting && r.stage !== "abandoned");
|
|
1003
|
+
const fly = seen.runs.filter((r) => !r.awaiting && !["done", "abandoned"].includes(r.stage));
|
|
1004
|
+
const fin = seen.runs.filter((r) => r.stage === "done");
|
|
1005
|
+
|
|
1006
|
+
const runLines = [
|
|
1007
|
+
...wait.map((r) => `${c.amber("●")} ${from(r.where)}${short(r.id)} ${c.dim(r.awaiting ?? "")}`),
|
|
1008
|
+
...fly.map((r) => `${c.cyan("●")} ${from(r.where)}${short(r.id)} ${c.dim(r.stage)}`),
|
|
1009
|
+
...(fin.length ? [c.dim(`● ${fin.length} done`)] : []),
|
|
1010
|
+
];
|
|
1011
|
+
if (!runLines.length) runLines.push(c.dim("none yet"));
|
|
1012
|
+
|
|
1013
|
+
// parseItem's `where` is the file the finding is in; the project is a separate
|
|
1014
|
+
// thing and both belong on the row.
|
|
1015
|
+
const items = seen.items.map((i) => ({ ...parseItem(i.text), project: i.where }));
|
|
1016
|
+
const backLines = items.slice(0, 6).map((it) => {
|
|
1017
|
+
const sev = it.severity ? (it.severity === "critical" || it.severity === "high"
|
|
1018
|
+
? c.amber(it.severity) : c.dim(it.severity)) + " " : "";
|
|
1019
|
+
return from(it.project) + sev + (it.where ? c.dim(it.where) + " " : "") + it.text;
|
|
1020
|
+
});
|
|
1021
|
+
if (!backLines.length) backLines.push(c.dim("nothing open"));
|
|
1022
|
+
else if (items.length > 6) backLines.push(c.dim(`… ${items.length - 6} more`));
|
|
1023
|
+
|
|
1024
|
+
// Side by side while there is room for two readable columns; stacked below
|
|
1025
|
+
// that, since a panel squeezed under forty columns shows nothing useful.
|
|
1026
|
+
const inner = Math.floor((cols - 9) / 2);
|
|
1027
|
+
if (cols >= 96) {
|
|
1028
|
+
// Both panels get the same body height so their bottom edges meet.
|
|
1029
|
+
const tall = Math.max(runLines.length, backLines.length);
|
|
1030
|
+
while (runLines.length < tall) runLines.push("");
|
|
1031
|
+
while (backLines.length < tall) backLines.push("");
|
|
1032
|
+
const left = panel("Runs", runLines, inner);
|
|
1033
|
+
const right = panel(`Backlog — ${items.length} open`, backLines, inner);
|
|
1034
|
+
w("\n");
|
|
1035
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
1036
|
+
w(" " + (left[i] ?? " ".repeat(inner + 2)) + " " + (right[i] ?? "") + "\n");
|
|
1037
|
+
}
|
|
1038
|
+
} else {
|
|
1039
|
+
w("\n");
|
|
1040
|
+
for (const line of panel("Runs", runLines, cols - 8)) w(" " + line + "\n");
|
|
1041
|
+
for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8)) w(" " + line + "\n");
|
|
1042
|
+
}
|
|
1043
|
+
} else {
|
|
1044
|
+
w(`\n ${c.dim("This folder has no .kaizen/. Set it up, or just ask your agent for something —")}\n`);
|
|
1045
|
+
w(` ${c.dim("the first run creates it.")}\n`);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
w("\n");
|
|
1049
|
+
// Counted, not derived: the panels above change height with the terminal and
|
|
1050
|
+
// with what exists, so nothing else can know where the actions start.
|
|
1051
|
+
actsTop = printed + 1;
|
|
1052
|
+
for (const [i, a] of acts.entries()) {
|
|
1053
|
+
const on = i === active;
|
|
1054
|
+
w(on
|
|
1055
|
+
? ` ${c.cyan("›")} ${c.bold(a.label.padEnd(20))}${c.dim(a.hint)}\n`
|
|
1056
|
+
: ` ${c.dim(a.label)}\n`);
|
|
1057
|
+
}
|
|
1058
|
+
w(`\n ${c.dim("↑↓ move · enter choose · q quit")} ${c.dim("⭐ github.com/hfadhlullah/kaizen")}\n`);
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
stdout.write("\x1b[?1049h\x1b[?25l");
|
|
1062
|
+
stdin.setRawMode(true);
|
|
1063
|
+
stdin.resume();
|
|
1064
|
+
draw();
|
|
1065
|
+
|
|
1066
|
+
let chosen = "quit";
|
|
1067
|
+
await new Promise<void>((resolve) => {
|
|
1068
|
+
const onData = (chunk: Buffer) => {
|
|
1069
|
+
const acts = actions();
|
|
1070
|
+
const keys = chunk.toString();
|
|
1071
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1072
|
+
const rest = keys.slice(i);
|
|
1073
|
+
if (rest.startsWith("\x03") || rest.startsWith("q") || rest === "\x1b"
|
|
1074
|
+
|| rest.startsWith("\x7f") || rest.startsWith("\b")) {
|
|
1075
|
+
stdin.off("data", onData); return resolve();
|
|
1076
|
+
}
|
|
1077
|
+
if (rest.startsWith("\r") || rest.startsWith("\n")) {
|
|
1078
|
+
chosen = acts[active]!.key;
|
|
1079
|
+
stdin.off("data", onData); return resolve();
|
|
1080
|
+
}
|
|
1081
|
+
const mouse = readMouse(rest);
|
|
1082
|
+
if (mouse) {
|
|
1083
|
+
i += mouse.len - 1;
|
|
1084
|
+
const { ev } = mouse;
|
|
1085
|
+
if (ev.button === WHEEL_UP) active = (active - 1 + acts.length) % acts.length;
|
|
1086
|
+
else if (ev.button === WHEEL_DOWN) active = (active + 1) % acts.length;
|
|
1087
|
+
else if (isClick(ev)) {
|
|
1088
|
+
const row = ev.y - actsTop;
|
|
1089
|
+
if (row < 0 || row >= acts.length) continue;
|
|
1090
|
+
if (row === active) { chosen = acts[active]!.key; stdin.off("data", onData); return resolve(); }
|
|
1091
|
+
active = row;
|
|
1092
|
+
} else continue;
|
|
1093
|
+
draw();
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (rest.startsWith("\x1b[A")) { active = (active - 1 + acts.length) % acts.length; i += 2; }
|
|
1097
|
+
else if (rest.startsWith("\x1b[B")) { active = (active + 1) % acts.length; i += 2; }
|
|
1098
|
+
else if (rest.startsWith("k")) active = (active - 1 + acts.length) % acts.length;
|
|
1099
|
+
else if (rest.startsWith("j")) active = (active + 1) % acts.length;
|
|
1100
|
+
else continue;
|
|
1101
|
+
draw();
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
stdin.on("data", onData);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
stdin.setRawMode(false);
|
|
1108
|
+
stdin.pause();
|
|
1109
|
+
stdout.write("\x1b[?25h\x1b[?1049l");
|
|
1110
|
+
return chosen;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const COLUMNS = [
|
|
1115
|
+
{ key: "idea", title: "IDEA" },
|
|
1116
|
+
{ key: "plan", title: "PLANNING" },
|
|
1117
|
+
{ key: "build", title: "BUILDING" },
|
|
1118
|
+
{ key: "review", title: "REVIEW" },
|
|
1119
|
+
{ key: "done", title: "DONE" },
|
|
1120
|
+
];
|
|
1121
|
+
|
|
1122
|
+
// wrap() above fills the terminal; a board column has its own width.
|
|
1123
|
+
function wrapTo(text: string, room: number) {
|
|
1124
|
+
const out: string[] = [];
|
|
1125
|
+
let line = "";
|
|
1126
|
+
// A run id is a single word to any whitespace split, so without breaking on
|
|
1127
|
+
// hyphens too it can never wrap and is always cut. Kept in the same order the
|
|
1128
|
+
// text arrived, hyphen retained on the line it ends.
|
|
1129
|
+
const words = text.split(/\s+/).flatMap((w) =>
|
|
1130
|
+
w.length <= room ? [w] : w.split(/(?<=-)/));
|
|
1131
|
+
for (const word of words) {
|
|
1132
|
+
if (line && line.length + word.length + (line.endsWith("-") ? 0 : 1) > room) { out.push(line); line = word; }
|
|
1133
|
+
else line = line ? (line.endsWith("-") ? line + word : `${line} ${word}`) : word;
|
|
1134
|
+
}
|
|
1135
|
+
if (line) out.push(line);
|
|
1136
|
+
return out.length ? out.map((l) => cut(l, room)) : [""];
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function pad(s: string, room: number) {
|
|
1140
|
+
const body = cut(s, room);
|
|
1141
|
+
return body + " ".repeat(Math.max(0, room - vis(body)));
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Printable width: a row is mostly colour codes by the time it is drawn, and they
|
|
1145
|
+
// occupy no columns.
|
|
1146
|
+
function vis(s: string) { return s.replace(/\x1b\[[0-9;]*m/g, "").length; }
|
|
1147
|
+
|
|
1148
|
+
function cut(s: string, room: number) {
|
|
1149
|
+
if (vis(s) <= room) return s;
|
|
1150
|
+
let out = "", seen = 0, i = 0;
|
|
1151
|
+
while (i < s.length && seen < room - 1) {
|
|
1152
|
+
const esc = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
1153
|
+
if (esc) { out += esc[0]; i += esc[0].length; continue; }
|
|
1154
|
+
out += s[i]; i++; seen++;
|
|
1155
|
+
}
|
|
1156
|
+
return out + "…\x1b[0m";
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const DIM = "\x1b[2m", OFF = "\x1b[0m";
|
|
1160
|
+
|
|
1161
|
+
// How each status is drawn here. The status itself comes from state.ts.
|
|
1162
|
+
const DOTS: Record<Status | "idea" | "starting", () => string> = {
|
|
1163
|
+
abandoned: () => DIM + "○" + OFF,
|
|
1164
|
+
waiting: () => c.amber("●"),
|
|
1165
|
+
done: () => c.dim("○"),
|
|
1166
|
+
running: () => c.cyan("●"),
|
|
1167
|
+
stalled: () => c.dim("◐"),
|
|
1168
|
+
idea: () => c.dim("·"),
|
|
1169
|
+
starting: () => c.dim("◌"),
|
|
1170
|
+
};
|
|
1171
|
+
function dotFor(r: Run, now: number): { dot: string; status: string } {
|
|
1172
|
+
const status = statusOf(r, now);
|
|
1173
|
+
return { dot: DOTS[status](), status };
|
|
1174
|
+
}
|
|
1175
|
+
type Drawn = Card & { dot: string };
|
|
1176
|
+
|
|
1177
|
+
function panel(title: string, lines: string[], inner: number) {
|
|
1178
|
+
const bar = "─".repeat(Math.max(0, inner - vis(title) - 3));
|
|
1179
|
+
const out = [`${DIM}╭─${OFF} ${title} ${DIM}${bar}╮${OFF}`];
|
|
1180
|
+
for (const line of lines) {
|
|
1181
|
+
const body = cut(line, inner - 2);
|
|
1182
|
+
out.push(`${DIM}│${OFF} ${body}${" ".repeat(Math.max(0, inner - 2 - vis(body)))} ${DIM}│${OFF}`);
|
|
1183
|
+
}
|
|
1184
|
+
out.push(`${DIM}╰${"─".repeat(Math.max(0, inner))}╯${OFF}`);
|
|
1185
|
+
return out;
|
|
1186
|
+
}
|
|
1187
|
+
|