kaizen-agent 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/dashboard.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // What `kaizen` shows once it is installed: the state of this project's runs, and
2
2
  // the few things you would have opened a terminal to do.
3
- import { existsSync, readFileSync, readdirSync, appendFileSync, mkdirSync, statSync } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, watch } from "node:fs";
4
4
  import { join, dirname, basename } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
 
@@ -14,9 +14,11 @@ const c = {
14
14
  cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
15
15
  green: (s: string) => `\x1b[32m${s}\x1b[0m`,
16
16
  amber: (s: string) => `\x1b[33m${s}\x1b[0m`,
17
+ // Red is spent on one thing only: a run that cannot move until the user acts.
18
+ red: (s: string) => rgb(232, 80, 80, s),
17
19
  };
18
20
 
19
- type Run = { id: string; stage: string; awaiting: string | null };
21
+ type Run = { id: string; stage: string; awaiting: string | null; moved: number };
20
22
 
21
23
  // The mascot, rendered from assets/kaizen.jpg. Braille packs 2x4 dots into one
22
24
  // character, which is the only way outline art survives a downsample this far.
@@ -116,6 +118,56 @@ function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached
116
118
  return null;
117
119
  }
118
120
 
121
+
122
+ // ---------------------------------------------------------------- mouse
123
+
124
+ // SGR reporting (1006), not X10: X10 encodes coordinates as single bytes and gives
125
+ // up past column 223, which a five-column board reaches on any wide terminal.
126
+ type MouseEv = { button: number; x: number; y: number; press: boolean };
127
+
128
+ let mouseWanted = false; // what config.yml asked for
129
+ let mouseArmed = false; // what the terminal is actually in
130
+
131
+ function readMouse(rest: string): { ev: MouseEv; len: number } | null {
132
+ const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
133
+ if (!m) return null;
134
+ return {
135
+ ev: { button: +m[1]!, x: +m[2]!, y: +m[3]!, press: m[4] === "M" },
136
+ len: m[0].length,
137
+ };
138
+ }
139
+
140
+ const WHEEL_UP = 64, WHEEL_DOWN = 65;
141
+ const isClick = (e: MouseEv) => e.press && e.button === 0;
142
+
143
+ function mouseOn() {
144
+ if (!mouseWanted || mouseArmed) return;
145
+ process.stdout.write("\x1b[?1000h\x1b[?1006h");
146
+ mouseArmed = true;
147
+ }
148
+
149
+ function mouseOff() {
150
+ if (!mouseArmed) return;
151
+ process.stdout.write("\x1b[?1006l\x1b[?1000l");
152
+ mouseArmed = false;
153
+ }
154
+
155
+ // Ctrl-C calls process.exit, which unwinds no finally block anywhere. A terminal
156
+ // left reporting spits escape junk at every click until `reset`, so this guard has
157
+ // to survive an abrupt exit.
158
+ process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
159
+
160
+ // Off unless the config says otherwise: while reporting is on, the terminal cannot
161
+ // select text with the mouse.
162
+ function mouseFromConfig(state: string | null) {
163
+ for (const f of [state ? join(state, "config.yml") : null, join(home, ".kaizen", "config.yml")]) {
164
+ if (!f || !existsSync(f)) continue;
165
+ const m = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(f, "utf8"));
166
+ if (m) return m[1] === "true";
167
+ }
168
+ return false;
169
+ }
170
+
119
171
  export async function dashboard(
120
172
  repo: string,
121
173
  agents: string[],
@@ -123,6 +175,7 @@ export async function dashboard(
123
175
  ) {
124
176
  const { stdin, stdout } = process;
125
177
  const state = locate();
178
+ mouseWanted = mouseFromConfig(state);
126
179
  // Opening a project is what makes it known; nothing else asks the user to register.
127
180
  if (state && dirname(state) !== home) remember(dirname(state));
128
181
 
@@ -150,6 +203,7 @@ export async function dashboard(
150
203
  { key: "projects", label: "All projects", hint: "every project kaizen knows about" },
151
204
  ] : []),
152
205
  ...(state ? [
206
+ { key: "board", label: "Board", hint: "ideas and runs, by stage" },
153
207
  { key: "runs", label: "Runs", hint: "every run, and what each is waiting on" },
154
208
  { key: "backlog", label: "Backlog", hint: "what runs noticed and did not do" },
155
209
  ] : []),
@@ -162,15 +216,25 @@ export async function dashboard(
162
216
  ];
163
217
 
164
218
  let active = 0;
165
- for (;;) {
166
- const chosen = await menu();
167
- if (chosen === "quit") return;
168
- if (chosen === "projects") { await projectsView(); continue; }
169
- if (chosen === "runs") { await runsView(); continue; }
170
- if (chosen === "backlog") { await backlogView(); continue; }
171
- const lines = await run(chosen);
172
- if (lines?.length) await report(lines);
173
- survey(); // an action may have changed what exists
219
+ mouseOn();
220
+ try {
221
+ for (;;) {
222
+ const chosen = await menu();
223
+ if (chosen === "quit") return;
224
+ if (chosen === "projects") { await projectsView(); continue; }
225
+ if (chosen === "board") { await boardView(); continue; }
226
+ if (chosen === "runs") { await runsView(); continue; }
227
+ if (chosen === "backlog") { await backlogView(); continue; }
228
+ const lines = await run(chosen);
229
+ if (lines?.length) await report(lines);
230
+ survey(); // an action may have changed what exists
231
+ // Settings is one of those actions, and ui.mouse is one of its keys: without
232
+ // this the toggle does nothing until the next launch.
233
+ mouseWanted = mouseFromConfig(state);
234
+ if (mouseWanted) mouseOn(); else mouseOff();
235
+ }
236
+ } finally {
237
+ mouseOff();
174
238
  }
175
239
 
176
240
  // An action that has something to say says it here, on its own screen, rather
@@ -187,7 +251,11 @@ export async function dashboard(
187
251
  const once = (chunk: Buffer) => {
188
252
  const s = chunk.toString();
189
253
  if (s.includes("\x03")) process.exit(130);
190
- key = s;
254
+ const mouse = readMouse(s);
255
+ // A click is a keypress on an any-key screen; the release that follows it
256
+ // must not dismiss the next screen too.
257
+ if (mouse && !isClick(mouse.ev)) return;
258
+ key = mouse ? "" : s;
191
259
  stdin.off("data", once);
192
260
  resolve();
193
261
  };
@@ -247,6 +315,26 @@ export async function dashboard(
247
315
  for (let i = 0; i < keys.length; i++) {
248
316
  const rest = keys.slice(i);
249
317
  if (rest.startsWith("\x03")) process.exit(130);
318
+ // Mouse reports are consumed here whether or not they mean anything, so
319
+ // an unexpected one is never read as a burst of keystrokes.
320
+ const mouse = readMouse(rest);
321
+ if (mouse) {
322
+ i += mouse.len - 1;
323
+ const { ev } = mouse;
324
+ if (ev.button === WHEEL_UP) move(-3);
325
+ else if (ev.button === WHEEL_DOWN) move(3);
326
+ else if (isClick(ev)) {
327
+ // Rows begin on the fourth line: blank, title, blank, then the list.
328
+ const real = top + (ev.y - 4);
329
+ const at = pickable.indexOf(real);
330
+ if (at < 0) continue; // a heading, or empty space
331
+ // Click to select; click the selected one to act.
332
+ if (at === cursor) { chosen = real; stdin.off("data", onData); return resolve(); }
333
+ cursor = at;
334
+ } else continue;
335
+ draw();
336
+ continue;
337
+ }
250
338
  if (rest.startsWith("q") || rest === "\x1b" || rest.startsWith("\x7f") || rest.startsWith("\b")) {
251
339
  stdin.off("data", onData); return resolve();
252
340
  }
@@ -443,6 +531,466 @@ export async function dashboard(
443
531
  }
444
532
  }
445
533
 
534
+
535
+ // ------------------------------------------------------------------ board
536
+
537
+ // Ideas and runs on one screen, in stage columns. A run's stage belongs to
538
+ // kaizen and cannot be edited here -- a board that could drag one backwards
539
+ // would be lying about what happened. The one exception is abandoning a run,
540
+ // which is the user's decision either way.
541
+ async function boardView() {
542
+ let allProjects = false;
543
+ let cards: Card[] = [];
544
+ let col = 0, row = 0;
545
+ let top = 0; // first visible line, board-wide
546
+ // Where each card actually landed on screen, rebuilt by whichever draw ran.
547
+ // Recording the frame beats recomputing its geometry: the two layouts place
548
+ // cards differently, and a stale guess after a resize clicks the wrong card.
549
+ type Hit = { y: number; x0: number; x1: number; col: number; row: number };
550
+ let hits: Hit[] = [];
551
+ // What each run was awaiting last time we looked, so a redraw can tell the
552
+ // difference between "still blocked" and "just became blocked".
553
+ const wasAwaiting = new Map<string, string | null>();
554
+ let first = true;
555
+
556
+ const states = () => (allProjects
557
+ ? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
558
+ : [state!]
559
+ ).filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
560
+
561
+ const refresh = () => {
562
+ const now = Date.now();
563
+ const next: Card[] = [];
564
+ // Every project's requests, not just this state dir's: an idea in the global
565
+ // inbox becomes a run in whichever project the agent was started in.
566
+ const requests = [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
567
+ .filter((d, i, all) => all.indexOf(d) === i && existsSync(d))
568
+ .flatMap(startedRuns);
569
+ for (const st of states()) {
570
+ const where = label(st);
571
+ for (const it of readInbox(st)) {
572
+ if (it.status !== "open" && it.status !== "started") continue;
573
+ // Retired once any run's request contains this text -- whoever started it.
574
+ // Checking `started` items only would leave every idea acted on outside the
575
+ // board sitting in IDEA forever, and typing `/kaizen ...` in a terminal is
576
+ // the common way a run begins.
577
+ if (requests.some((r) => r.includes(normalise(it.text)))) continue;
578
+ next.push(it.status === "started"
579
+ ? { kind: "idea", dot: c.dim("◌"), status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true }
580
+ : { kind: "idea", dot: c.dim("·"), status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false });
581
+ }
582
+ for (const r of readRuns(st)) {
583
+ const shown = dotFor(r, now);
584
+ const key = `${st}/${r.id}`;
585
+ const before = wasAwaiting.get(key);
586
+ if (!first && before === null && r.awaiting) {
587
+ notify("kaizen — waiting on you", `${short(r.id)} · ${r.awaiting}`);
588
+ }
589
+ wasAwaiting.set(key, r.awaiting);
590
+ next.push({
591
+ kind: "run", id: r.id, dot: shown.dot, status: shown.status, text: short(r.id), state: st, where, column: columnOf(r.stage),
592
+ awaiting: r.stage === "abandoned" ? null : r.awaiting,
593
+ dim: r.stage === "abandoned",
594
+ });
595
+ }
596
+ }
597
+ cards = next;
598
+ first = false;
599
+ };
600
+
601
+ const inColumn = (n: number) => cards.filter((k) => k.column === n);
602
+ const current = () => inColumn(col)[row];
603
+
604
+ const draw = () => {
605
+ const width = stdout.columns ?? 80;
606
+ // Five columns need room to say anything. Below that the board is worse
607
+ // than the list it replaces, so it becomes one.
608
+ if (width < 100) { drawNarrow(); return; }
609
+ const inner = Math.floor((width - 4) / COLUMNS.length) - 2;
610
+ const cols = COLUMNS.map((meta, n) => {
611
+ const lines: string[] = [];
612
+ const starts: number[] = [];
613
+ for (const k of inColumn(n)) {
614
+ starts.push(lines.length);
615
+ const chosen = n === col && inColumn(n)[row] === k;
616
+ const body = wrapTo(k.text, inner - 4);
617
+ for (const [i, line] of body.entries()) {
618
+ const marker = i === 0 ? (chosen ? c.cyan("›") : " ") : " ";
619
+ const dot = i === 0 ? k.dot : " ";
620
+ const painted = k.awaiting ? c.red(line) : k.dim ? c.dim(line) : line;
621
+ lines.push(`${marker} ${dot} ${painted}`);
622
+ }
623
+ if (allProjects && k.where !== "global") lines.push(" " + c.dim(cut(basename(k.where), inner - 4)));
624
+ lines.push("");
625
+ }
626
+ return { meta, lines, starts };
627
+ });
628
+
629
+ stdout.write("\x1b[H\x1b[2J");
630
+ const scope = allProjects ? "all projects" : label(state!);
631
+ stdout.write(`\n ${c.bold("Board")} ${c.dim(scope)}\n\n`);
632
+ stdout.write(" " + cols.map(({ meta }, n) =>
633
+ pad(n === col ? c.cyan(meta.title) : c.dim(meta.title), inner)).join(" ") + "\n");
634
+ stdout.write(" " + cols.map(() => c.dim("─".repeat(inner))).join(" ") + "\n");
635
+ const height = Math.max(4, (stdout.rows ?? 24) - 11);
636
+ const deep = Math.max(0, ...cols.map((x) => x.lines.length));
637
+ // The selected card drags the viewport with it; a cursor that can leave the
638
+ // screen is a cursor that deletes cards the user cannot see.
639
+ const sel = cols[col]!.starts[row] ?? 0;
640
+ if (sel < top) top = sel;
641
+ if (sel >= top + height) top = sel - height + 1;
642
+ top = Math.max(0, Math.min(top, Math.max(0, deep - height)));
643
+ hits = [];
644
+ for (let i = top; i < Math.min(deep, top + height); i++) {
645
+ // Cards begin on the sixth line: blank, title, blank, headings, rule.
646
+ const y = 6 + (i - top);
647
+ for (const [n, col_] of cols.entries()) {
648
+ const x0 = 3 + n * (inner + 2);
649
+ const owns = col_.starts.reduce((found, at, idx) => (at <= i ? idx : found), -1);
650
+ if (owns < 0) continue;
651
+ const ends = col_.starts[owns + 1] ?? col_.lines.length;
652
+ if (i >= ends || !(col_.lines[i] ?? "").trim()) continue;
653
+ hits.push({ y, x0, x1: x0 + inner - 1, col: n, row: owns });
654
+ }
655
+ stdout.write(" " + cols.map((x) => pad(x.lines[i] ?? "", inner)).join(" ") + "\n");
656
+ }
657
+ const above = top > 0, below = top + height < deep;
658
+ if (above || below) {
659
+ stdout.write(` ${c.dim(`${above ? "↑" : " "} ${below ? "↓ more" : ""}`)}\n`);
660
+ }
661
+ stdout.write("\n" + footer(current()) + legend());
662
+ };
663
+
664
+ // Keys grouped by what they are for -- moving, acting, leaving -- separated by
665
+ // space rather than by middots, which made one long undifferentiated line.
666
+ const footer = (k?: Card) => {
667
+ const acts = k?.kind === "idea"
668
+ ? "n new e edit x reject d delete r run"
669
+ : k ? "n new idea x abandon this run" : "n new idea";
670
+ const scope = allProjects ? "a this project" : "a all projects";
671
+ return ` ${c.dim("↑↓←→ move")} ${c.dim(acts)} ${c.dim(scope)} ${c.dim("q back")}\n`;
672
+ };
673
+
674
+ // Only the states actually on the board: a legend for dots nothing is using is
675
+ // six items of noise under every screen.
676
+ const legend = () => {
677
+ const present = new Set(cards.map((k) => k.status));
678
+ const all: [string, string][] = [
679
+ ["running", `${c.cyan("●")} ${c.dim("running")}`],
680
+ ["stalled", c.dim("◐ stalled")],
681
+ ["starting", c.dim("◌ starting")],
682
+ ["waiting", `${c.amber("●")} ${c.red("waiting on you")}`],
683
+ ["done", c.dim("○ done")],
684
+ ["abandoned", c.dim("○ abandoned")],
685
+ ["idea", c.dim("· idea")],
686
+ ];
687
+ const parts = all.filter(([key]) => present.has(key)).map(([, text]) => text);
688
+ return parts.length ? "\n " + parts.join(" ") + "\n" : "\n";
689
+ };
690
+
691
+ // Under 100 columns the board stacks: same cards, same keys, one list.
692
+ const drawNarrow = () => {
693
+ stdout.write("\x1b[H\x1b[2J");
694
+ stdout.write(`\n ${c.bold("Board")} ${c.dim("narrow terminal — stacked")}\n\n`);
695
+ hits = [];
696
+ // The header writes three newlines, so the first heading lands on line 4.
697
+ let y = 4;
698
+ for (const [n, meta] of COLUMNS.entries()) {
699
+ const items = inColumn(n);
700
+ if (!items.length) continue;
701
+ stdout.write(` ${n === col ? c.cyan(meta.title) : c.dim(meta.title)}\n`);
702
+ y++;
703
+ for (const [idx, k] of items.entries()) {
704
+ const chosen = n === col && items[row] === k;
705
+ const text = k.awaiting ? c.red(k.text) : k.dim ? c.dim(k.text) : k.text;
706
+ stdout.write(` ${chosen ? c.cyan("›") : " "} ${k.dot} ${cut(text, (stdout.columns ?? 80) - 8)}\n`);
707
+ hits.push({ y, x0: 1, x1: stdout.columns ?? 80, col: n, row: idx });
708
+ y++;
709
+ }
710
+ stdout.write("\n");
711
+ y++;
712
+ }
713
+ stdout.write("\n" + footer(current()) + legend());
714
+ };
715
+
716
+ const cardAt = (x: number, y: number) =>
717
+ hits.find((h) => h.y === y && x >= h.x0 && x <= h.x1) ?? null;
718
+
719
+ const clamp = () => {
720
+ col = Math.min(COLUMNS.length - 1, Math.max(0, col));
721
+ row = Math.min(Math.max(0, inColumn(col).length - 1), Math.max(0, row));
722
+ };
723
+
724
+ refresh();
725
+ stdout.write("\x1b[?1049h\x1b[?25l");
726
+ stdin.setRawMode(true);
727
+ stdin.resume();
728
+
729
+ // Kaizen writes these files from other processes; watching them is what makes
730
+ // the board move on its own.
731
+ let timer: ReturnType<typeof setTimeout> | null = null;
732
+ let watchers: ReturnType<typeof watch>[] = [];
733
+ const onChange = () => {
734
+ if (timer) clearTimeout(timer);
735
+ // A state.json being rewritten is briefly unparseable; readRuns already
736
+ // drops what it cannot parse, and the delay keeps that window off screen.
737
+ timer = setTimeout(() => { refresh(); clamp(); draw(); arm(); }, 150);
738
+ };
739
+
740
+ // fs.watch is not recursive here, and the file that actually changes is
741
+ // runs/<id>/state.json -- two levels down. Watching only the state dir and
742
+ // runs/ sees a new run appear and never sees an existing one move, so every
743
+ // run directory is watched too, and the set is rebuilt whenever anything
744
+ // fires in case the change was a run being created.
745
+ function arm() {
746
+ for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
747
+ watchers = [];
748
+ for (const st of states()) {
749
+ const runs = join(st, "runs");
750
+ const targets = [st, runs];
751
+ try { for (const id of readdirSync(runs)) targets.push(join(runs, id)); } catch { /* no runs yet */ }
752
+ for (const target of targets) {
753
+ try { if (existsSync(target)) watchers.push(watch(target, { persistent: false }, onChange)); }
754
+ catch { /* unwatchable path */ }
755
+ }
756
+ }
757
+ }
758
+ arm();
759
+
760
+ try {
761
+ draw();
762
+ for (;;) {
763
+ const key = await nextKey();
764
+ if (key === "quit") break;
765
+ if (key === "a") { allProjects = !allProjects; refresh(); clamp(); draw(); arm(); continue; }
766
+ if (key === "left") { col--; row = 0; clamp(); draw(); continue; }
767
+ if (key === "right") { col++; row = 0; clamp(); draw(); continue; }
768
+ if (key === "up") { row--; clamp(); draw(); continue; }
769
+ if (key === "down") { row++; clamp(); draw(); continue; }
770
+
771
+ const target = current();
772
+ if (key === "n") {
773
+ const text = await promptLine("New idea", "");
774
+ if (text) {
775
+ const st = target?.state ?? state!;
776
+ writeInbox(st, [...readInbox(st), { status: "open", text }]);
777
+ }
778
+ refresh(); col = 0; clamp(); draw(); continue;
779
+ }
780
+ if (!target) { draw(); continue; }
781
+ // Runs are otherwise read-only -- their stage belongs to kaizen -- but
782
+ // abandoning one is the user's decision to make, and /kaizen abort is the
783
+ // same single transition this writes.
784
+ if (target.kind === "run") {
785
+ if (key === "x" && target.id) {
786
+ const why = await promptLine(`Abandon ${target.text}? Reason`, "");
787
+ if (why) {
788
+ const failed = abandonRun(target.state, target.id, why);
789
+ if (failed) await outside(() => report([c.bold("Could not abandon"), "", c.dim(failed)]));
790
+ }
791
+ refresh(); clamp();
792
+ }
793
+ draw(); continue;
794
+ }
795
+ if (key === "e") {
796
+ const text = await promptLine("Edit idea", target.text);
797
+ if (text) replaceIdea(target, { status: "open", text });
798
+ } else if (key === "x") {
799
+ const why = await promptLine(`Reject: ${target.text}`, "");
800
+ // The spec requires a reason on a rejected item; no reason, no rejection.
801
+ if (why) replaceIdea(target, { status: "rejected", text: `${target.text} | ${why}` });
802
+ } else if (key === "d") {
803
+ if (await confirm(`Delete "${cut(target.text, 40)}"?`)) replaceIdea(target, null);
804
+ } else if (key === "r") {
805
+ const mode = await pickMode();
806
+ // "" is the default-mode choice, and must not leave a double space in
807
+ // the prompt the agent is launched with.
808
+ if (mode !== null) {
809
+ const request = `${mode} ${target.text}`.trim();
810
+ await outside(() => startBacklogItem(parseItem(request), target.state));
811
+ // Without this the idea stays open forever and the board shows the
812
+ // same work twice: once as an idea, once as the run it became.
813
+ replaceIdea(target, { status: "started", text: target.text });
814
+ }
815
+ }
816
+ refresh(); clamp(); draw();
817
+ }
818
+ } finally {
819
+ for (const w of watchers) { try { w.close(); } catch { /* already gone */ } }
820
+ watchers = [];
821
+ if (timer) clearTimeout(timer);
822
+ stdin.setRawMode(false);
823
+ stdin.pause();
824
+ stdout.write("\x1b[?25h\x1b[?1049l");
825
+ }
826
+
827
+ // The only run state the board writes, and only this transition: stage becomes
828
+ // abandoned, awaiting clears. Every other field is preserved, so a run abandoned
829
+ // here still resumes and reads like one abandoned by /kaizen abort.
830
+ function abandonRun(st: string, id: string, why: string): string | null {
831
+ const file = join(st, "runs", id, "state.json");
832
+ try {
833
+ const run = JSON.parse(readFileSync(file, "utf8"));
834
+ if (run.stage === "abandoned") return "That run is already abandoned.";
835
+ run.stage = "abandoned";
836
+ run.awaiting = null;
837
+ run.updated = new Date().toISOString();
838
+ writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
839
+ // The reason belongs in the record, not in state.json, whose shape is the
840
+ // resume contract every kaizen tool reads.
841
+ appendFileSync(join(st, "runs", id, "02-approval.md"),
842
+ `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
843
+ return null;
844
+ } catch (err: any) {
845
+ return err?.message ?? String(err);
846
+ }
847
+ }
848
+
849
+ function replaceIdea(card: Card, next: InboxLine | null) {
850
+ const lines = readInbox(card.state);
851
+ const at = lines.findIndex((l) => (l.status === "open" || l.status === "started") && l.text === card.text);
852
+ if (at < 0) return; // changed underneath us; the redraw will show why
853
+ if (next) lines[at] = next; else lines.splice(at, 1);
854
+ writeInbox(card.state, lines);
855
+ }
856
+
857
+ // startBacklogItem paints its own screens, so the board steps out of the way
858
+ // and takes the terminal back afterwards.
859
+ async function outside(fn: () => Promise<void>) {
860
+ stdin.setRawMode(false);
861
+ stdout.write("\x1b[?25h\x1b[?1049l");
862
+ try {
863
+ await fn();
864
+ } finally {
865
+ // Without this the board's own finally restores a terminal that was never
866
+ // put back into raw mode, and the user is left with no echo.
867
+ stdout.write("\x1b[?1049h\x1b[?25l");
868
+ stdin.setRawMode(true);
869
+ stdin.resume();
870
+ }
871
+ }
872
+
873
+ // One chunk can carry several keys -- a held arrow, a paste, fast typing --
874
+ // so it is walked rather than compared whole.
875
+ function nextKey(): Promise<string> {
876
+ return new Promise((resolve) => {
877
+ const onData = (chunk: Buffer) => {
878
+ const keys = chunk.toString();
879
+ if (keys.includes("\x03")) process.exit(130);
880
+ const done = (v: string) => { stdin.off("data", onData); resolve(v); };
881
+ for (let i = 0; i < keys.length; i++) {
882
+ const rest = keys.slice(i);
883
+ const mouse = readMouse(rest);
884
+ if (mouse) {
885
+ i += mouse.len - 1;
886
+ const { ev } = mouse;
887
+ if (ev.button === WHEEL_UP) return done("up");
888
+ if (ev.button === WHEEL_DOWN) return done("down");
889
+ if (!isClick(ev)) continue;
890
+ const target = cardAt(ev.x, ev.y);
891
+ if (!target) continue;
892
+ // Click to select; click the selected card to act, which on an idea
893
+ // means the same thing r does.
894
+ if (target.col === col && target.row === row) return done("r");
895
+ col = target.col; row = target.row;
896
+ draw();
897
+ continue;
898
+ }
899
+ if (rest.startsWith("\x1b[D")) return done("left");
900
+ if (rest.startsWith("\x1b[C")) return done("right");
901
+ if (rest.startsWith("\x1b[A")) return done("up");
902
+ if (rest.startsWith("\x1b[B")) return done("down");
903
+ const ch = keys[i]!;
904
+ if (ch === "h") return done("left");
905
+ if (ch === "l") return done("right");
906
+ if (ch === "k") return done("up");
907
+ if (ch === "j") return done("down");
908
+ if (ch === "q" || ch === "\x1b" || ch === "\x7f" || ch === "\b") return done("quit");
909
+ if ("nexdra".includes(ch)) return done(ch);
910
+ }
911
+ };
912
+ stdin.on("data", onData);
913
+ });
914
+ }
915
+
916
+ async function promptLine(title: string, initial: string): Promise<string | null> {
917
+ let buf = initial;
918
+ const paint = () => {
919
+ stdout.write("\x1b[H\x1b[2J");
920
+ stdout.write(`\n ${c.bold(title)}\n\n ${buf}${c.cyan("█")}\n\n ${c.dim("enter save · esc cancel")}\n`);
921
+ };
922
+ paint();
923
+ return new Promise((resolve) => {
924
+ const onData = (chunk: Buffer) => {
925
+ const keys = chunk.toString();
926
+ if (keys.includes("\x03")) process.exit(130);
927
+ const done = (v: string | null) => { stdin.off("data", onData); resolve(v); };
928
+ // Character by character: a held backspace or a paste arrives as one
929
+ // chunk, and comparing the whole chunk to "\x7f" writes the raw bytes
930
+ // into the file instead of deleting anything.
931
+ for (let i = 0; i < keys.length; i++) {
932
+ // Skipped whole, or its digits and semicolons land in the text.
933
+ const mouse = readMouse(keys.slice(i));
934
+ if (mouse) { i += mouse.len - 1; continue; }
935
+ const ch = keys[i]!;
936
+ if (ch === "\x1b") return done(null);
937
+ if (ch === "\r" || ch === "\n") return done(buf.trim() || null);
938
+ if (ch === "\x7f" || ch === "\b") { buf = buf.slice(0, -1); continue; }
939
+ // Anything else unprintable is dropped rather than stored: control
940
+ // bytes in an inbox line survive every later read of that file.
941
+ if (ch >= " " && ch !== "\x7f") buf += ch;
942
+ }
943
+ paint();
944
+ };
945
+ stdin.on("data", onData);
946
+ });
947
+ }
948
+
949
+ async function confirm(question: string): Promise<boolean> {
950
+ stdout.write("\x1b[H\x1b[2J");
951
+ stdout.write(`\n ${c.bold(question)}\n\n ${c.dim("y delete · any other key keep")}\n`);
952
+ const key = await nextRaw();
953
+ return key === "y" || key === "Y";
954
+ }
955
+
956
+ // full or lite, asked at the moment a run starts rather than fixed in config.
957
+ async function pickMode(): Promise<string | null> {
958
+ stdout.write("\x1b[H\x1b[2J");
959
+ stdout.write(`\n ${c.bold("Start this run how?")}\n\n`);
960
+ stdout.write(` ${c.cyan("f")} full ${c.dim("every stage its own cold agent — the reviewer starts blind")}\n`);
961
+ stdout.write(` ${c.cyan("l")} lite ${c.dim("one session runs every stage — cheaper, review has seen the work")}\n`);
962
+ 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`);
963
+ // Options start on the fourth line: blank, title, blank, then f, l, d.
964
+ const key = await nextRaw((y) => ["f", "l", "d"][y - 4] ?? null);
965
+ return key === "f" ? "full" : key === "l" ? "lite" : key === "d" ? "" : null;
966
+ }
967
+
968
+ // A click arrives as two reports, press and release. The press is what opened
969
+ // this screen, so the release must not be read as its answer -- and a click on
970
+ // one of the offered lines answers it properly, via `clickable`.
971
+ function nextRaw(clickable?: (y: number) => string | null): Promise<string> {
972
+ return new Promise((resolve) => {
973
+ const onData = (chunk: Buffer) => {
974
+ const keys = chunk.toString();
975
+ if (keys.includes("\x03")) process.exit(130);
976
+ const done = (v: string) => { stdin.off("data", onData); resolve(v); };
977
+ for (let i = 0; i < keys.length; i++) {
978
+ const mouse = readMouse(keys.slice(i));
979
+ if (mouse) {
980
+ i += mouse.len - 1;
981
+ if (!clickable || !isClick(mouse.ev)) continue;
982
+ const answer = clickable(mouse.ev.y);
983
+ if (answer) return done(answer);
984
+ continue;
985
+ }
986
+ return done(keys.slice(i));
987
+ }
988
+ };
989
+ stdin.on("data", onData);
990
+ });
991
+ }
992
+ }
993
+
446
994
  async function startBacklogItem(it: Item, from: string) {
447
995
  const projectDir = from === join(home, ".kaizen")
448
996
  ? process.cwd()
@@ -511,9 +1059,13 @@ export async function dashboard(
511
1059
  }
512
1060
 
513
1061
  async function menu() {
1062
+ let actsTop = 0; // screen line of the first action row
1063
+ let printed = 0; // lines this draw has written
1064
+ const w = (text: string) => { printed += (text.match(/\n/g) ?? []).length; stdout.write(text); };
514
1065
  const draw = () => {
1066
+ printed = 0;
515
1067
  const acts = actions();
516
- stdout.write("\x1b[H\x1b[2J");
1068
+ w("\x1b[H\x1b[2J");
517
1069
 
518
1070
  // The mascot sits beside the header rather than above it; stacked, it pushes
519
1071
  // the runs -- the thing you opened this for -- below the fold on a short window.
@@ -552,7 +1104,7 @@ export async function dashboard(
552
1104
  beside[at + 1] = c.dim(state ? tilde(dirname(state)) : "no project here");
553
1105
  beside[at + 3] = c.dim(agents.join(", "));
554
1106
  }
555
- stdout.write("\n");
1107
+ w("\n");
556
1108
  if (wide) {
557
1109
  // Cut what sits beside the mascot to the room left over. The agent list is
558
1110
  // long and grows with every agent installed, and it wrapped here before the
@@ -562,11 +1114,11 @@ export async function dashboard(
562
1114
  const r = Math.round(235 - t * 135);
563
1115
  const g = Math.round(245 - t * 110);
564
1116
  const b = Math.round(255 - t * 20);
565
- stdout.write(" " + rgb(r, g, b, line.padEnd(gutter)) + cut(beside[i] ?? "", cols - gutter - 2) + "\n");
1117
+ w(" " + rgb(r, g, b, line.padEnd(gutter)) + cut(beside[i] ?? "", cols - gutter - 2) + "\n");
566
1118
  }
567
1119
  } else {
568
- stdout.write(` ${c.bold("kaizen")} ${c.dim(version(repo))} ${c.dim(state ? tilde(dirname(state)) : "no project here")}\n`);
569
- stdout.write(` ${c.dim(agents.join(", "))}\n`);
1120
+ w(` ${c.bold("kaizen")} ${c.dim(version(repo))} ${c.dim(state ? tilde(dirname(state)) : "no project here")}\n`);
1121
+ w(` ${c.dim(agents.join(", "))}\n`);
570
1122
  }
571
1123
 
572
1124
  if (state) {
@@ -606,28 +1158,31 @@ export async function dashboard(
606
1158
  while (backLines.length < tall) backLines.push("");
607
1159
  const left = panel("Runs", runLines, inner);
608
1160
  const right = panel(`Backlog — ${items.length} open`, backLines, inner);
609
- stdout.write("\n");
1161
+ w("\n");
610
1162
  for (let i = 0; i < Math.max(left.length, right.length); i++) {
611
- stdout.write(" " + (left[i] ?? " ".repeat(inner + 2)) + " " + (right[i] ?? "") + "\n");
1163
+ w(" " + (left[i] ?? " ".repeat(inner + 2)) + " " + (right[i] ?? "") + "\n");
612
1164
  }
613
1165
  } else {
614
- stdout.write("\n");
615
- for (const line of panel("Runs", runLines, cols - 8)) stdout.write(" " + line + "\n");
616
- for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8)) stdout.write(" " + line + "\n");
1166
+ w("\n");
1167
+ for (const line of panel("Runs", runLines, cols - 8)) w(" " + line + "\n");
1168
+ for (const line of panel(`Backlog — ${items.length} open`, backLines, cols - 8)) w(" " + line + "\n");
617
1169
  }
618
1170
  } else {
619
- stdout.write(`\n ${c.dim("This folder has no .kaizen/. Set it up, or just ask your agent for something —")}\n`);
620
- stdout.write(` ${c.dim("the first run creates it.")}\n`);
1171
+ w(`\n ${c.dim("This folder has no .kaizen/. Set it up, or just ask your agent for something —")}\n`);
1172
+ w(` ${c.dim("the first run creates it.")}\n`);
621
1173
  }
622
1174
 
623
- stdout.write("\n");
1175
+ w("\n");
1176
+ // Counted, not derived: the panels above change height with the terminal and
1177
+ // with what exists, so nothing else can know where the actions start.
1178
+ actsTop = printed + 1;
624
1179
  for (const [i, a] of acts.entries()) {
625
1180
  const on = i === active;
626
- stdout.write(on
1181
+ w(on
627
1182
  ? ` ${c.cyan("›")} ${c.bold(a.label.padEnd(20))}${c.dim(a.hint)}\n`
628
1183
  : ` ${c.dim(a.label)}\n`);
629
1184
  }
630
- stdout.write(`\n ${c.dim("↑↓ move · enter choose · q quit")} ${c.dim("⭐ github.com/hfadhlullah/kaizen")}\n`);
1185
+ w(`\n ${c.dim("↑↓ move · enter choose · q quit")} ${c.dim("⭐ github.com/hfadhlullah/kaizen")}\n`);
631
1186
  };
632
1187
 
633
1188
  stdout.write("\x1b[?1049h\x1b[?25l");
@@ -650,6 +1205,21 @@ export async function dashboard(
650
1205
  chosen = acts[active]!.key;
651
1206
  stdin.off("data", onData); return resolve();
652
1207
  }
1208
+ const mouse = readMouse(rest);
1209
+ if (mouse) {
1210
+ i += mouse.len - 1;
1211
+ const { ev } = mouse;
1212
+ if (ev.button === WHEEL_UP) active = (active - 1 + acts.length) % acts.length;
1213
+ else if (ev.button === WHEEL_DOWN) active = (active + 1) % acts.length;
1214
+ else if (isClick(ev)) {
1215
+ const row = ev.y - actsTop;
1216
+ if (row < 0 || row >= acts.length) continue;
1217
+ if (row === active) { chosen = acts[active]!.key; stdin.off("data", onData); return resolve(); }
1218
+ active = row;
1219
+ } else continue;
1220
+ draw();
1221
+ continue;
1222
+ }
653
1223
  if (rest.startsWith("\x1b[A")) { active = (active - 1 + acts.length) % acts.length; i += 2; }
654
1224
  else if (rest.startsWith("\x1b[B")) { active = (active + 1) % acts.length; i += 2; }
655
1225
  else if (rest.startsWith("k")) active = (active - 1 + acts.length) % acts.length;
@@ -728,8 +1298,13 @@ function readRuns(state: string): Run[] {
728
1298
  if (!existsSync(dir)) return [];
729
1299
  return readdirSync(dir).flatMap((id) => {
730
1300
  try {
731
- const s = JSON.parse(readFileSync(join(dir, id, "state.json"), "utf8"));
732
- return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null }];
1301
+ const file = join(dir, id, "state.json");
1302
+ const s = JSON.parse(readFileSync(file, "utf8"));
1303
+ // When the run last moved. Kaizen records no liveness signal, so the file's
1304
+ // own mtime is the only evidence that anything is still working on it.
1305
+ let moved = 0;
1306
+ try { moved = statSync(file).mtimeMs; } catch { /* vanished mid-read */ }
1307
+ return [{ id, stage: s.stage ?? "?", awaiting: s.awaiting ?? null, moved }];
733
1308
  } catch { return []; }
734
1309
  }).reverse();
735
1310
  }
@@ -818,6 +1393,136 @@ function allBacklog(state: string) {
818
1393
  return out;
819
1394
  }
820
1395
 
1396
+
1397
+ // ---------------------------------------------------------------- board data
1398
+
1399
+ type Card = {
1400
+ kind: "idea" | "run";
1401
+ id?: string; // runs only: the directory name
1402
+ dot: string;
1403
+ status: string; // what the legend calls this card
1404
+ text: string;
1405
+ state: string;
1406
+ where: string;
1407
+ column: number;
1408
+ awaiting: string | null;
1409
+ dim: boolean;
1410
+ };
1411
+
1412
+ const COLUMNS = [
1413
+ { key: "idea", title: "IDEA" },
1414
+ { key: "plan", title: "PLANNING" },
1415
+ { key: "build", title: "BUILDING" },
1416
+ { key: "review", title: "REVIEW" },
1417
+ { key: "done", title: "DONE" },
1418
+ ];
1419
+
1420
+
1421
+ // A run is called running on the evidence that it moved recently; there is no PID
1422
+ // to ask. Anything in flight and older than this is stalled, not running -- the
1423
+ // difference the board exists to show.
1424
+
1425
+ // The verbatim request of every run, normalised, so an idea that has become a run
1426
+ // can be retired from the board.
1427
+ // ponytail: reads each run's 00-request.md on every refresh; fine at tens of runs,
1428
+ // cache by mtime if a project ever has hundreds.
1429
+ function startedRuns(state: string): string[] {
1430
+ const dir = join(state, "runs");
1431
+ if (!existsSync(dir)) return [];
1432
+ const out: string[] = [];
1433
+ for (const id of readdirSync(dir)) {
1434
+ try { out.push(normalise(readFileSync(join(dir, id, "00-request.md"), "utf8"))); }
1435
+ catch { /* no request file, or unreadable */ }
1436
+ }
1437
+ return out;
1438
+ }
1439
+
1440
+ function normalise(text: string) {
1441
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
1442
+ }
1443
+
1444
+ const RUNNING_WITHIN_MS = 30 * 60_000;
1445
+
1446
+ // The status is what the legend lists; the dot is how it is drawn. Deriving one
1447
+ // from the other by comparing coloured strings breaks the moment a colour moves.
1448
+ function dotFor(r: Run, now: number): { dot: string; status: string } {
1449
+ if (r.stage === "abandoned") return { dot: DIM + "○" + OFF, status: "abandoned" };
1450
+ if (r.awaiting) return { dot: c.amber("●"), status: "waiting" };
1451
+ if (r.stage === "done") return { dot: c.dim("○"), status: "done" };
1452
+ return now - r.moved < RUNNING_WITHIN_MS
1453
+ ? { dot: c.cyan("●"), status: "running" }
1454
+ : { dot: c.dim("◐"), status: "stalled" };
1455
+ }
1456
+
1457
+ // A stage this build has never heard of still belongs somewhere visible, and
1458
+ // PLANNING is the honest guess: the run has started and has not finished.
1459
+ function columnOf(stage: string) {
1460
+ if (stage === "build") return 2;
1461
+ if (stage === "review") return 3;
1462
+ if (stage === "done" || stage === "abandoned") return 4;
1463
+ return 1;
1464
+ }
1465
+
1466
+ type InboxLine = { status: string; text: string };
1467
+
1468
+ // Ideas with no run yet. Deliberately not backlog.md, which the spec defines as
1469
+ // an orphanage for items rescued from pruned runs, and not a run stub each.
1470
+ function readInbox(state: string): InboxLine[] {
1471
+ const f = join(state, "inbox.md");
1472
+ if (!existsSync(f)) return [];
1473
+ return readFileSync(f, "utf8").split("\n")
1474
+ .map((l) => /^-\s*(open|started|done|rejected):\s*(.+)$/.exec(l.trim()))
1475
+ .filter((m): m is RegExpExecArray => !!m)
1476
+ .map((m) => ({ status: m[1]!, text: m[2]!.trim() }));
1477
+ }
1478
+
1479
+ // Whole-file rewrite. The board is the only writer today.
1480
+ // ponytail: single writer assumed; if a kaizen stage ever appends here too, this
1481
+ // needs a lock or an append-only journal.
1482
+ function writeInbox(state: string, lines: InboxLine[]) {
1483
+ mkdirSync(state, { recursive: true });
1484
+ const body = [
1485
+ "# Inbox",
1486
+ "",
1487
+ "Ideas with no run yet. One line each, same format as a run's backlog.",
1488
+ "",
1489
+ ...lines.map((l) => `- ${l.status}: ${l.text}`),
1490
+ ];
1491
+ writeFileSync(join(state, "inbox.md"), body.join("\n") + "\n");
1492
+ }
1493
+
1494
+ // Best effort: a machine without notify-send loses the notification, not the board.
1495
+ function notify(title: string, body: string) {
1496
+ try {
1497
+ if (!Bun.which("notify-send")) return;
1498
+ Bun.spawn(["notify-send", title, body], {
1499
+ stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true,
1500
+ }).unref();
1501
+ } catch { /* notifications are not worth an exception */ }
1502
+ }
1503
+
1504
+ // wrap() above fills the terminal; a board column has its own width.
1505
+ function wrapTo(text: string, room: number) {
1506
+ const out: string[] = [];
1507
+ let line = "";
1508
+ // A run id is a single word to any whitespace split, so without breaking on
1509
+ // hyphens too it can never wrap and is always cut. Kept in the same order the
1510
+ // text arrived, hyphen retained on the line it ends.
1511
+ const words = text.split(/\s+/).flatMap((w) =>
1512
+ w.length <= room ? [w] : w.split(/(?<=-)/));
1513
+ for (const word of words) {
1514
+ if (line && line.length + word.length + (line.endsWith("-") ? 0 : 1) > room) { out.push(line); line = word; }
1515
+ else line = line ? (line.endsWith("-") ? line + word : `${line} ${word}`) : word;
1516
+ }
1517
+ if (line) out.push(line);
1518
+ return out.length ? out.map((l) => cut(l, room)) : [""];
1519
+ }
1520
+
1521
+ function pad(s: string, room: number) {
1522
+ const body = cut(s, room);
1523
+ return body + " ".repeat(Math.max(0, room - vis(body)));
1524
+ }
1525
+
821
1526
  // Printable width: a row is mostly colour codes by the time it is drawn, and they
822
1527
  // occupy no columns.
823
1528
  function vis(s: string) { return s.replace(/\x1b\[[0-9;]*m/g, "").length; }
package/cli/settings.ts CHANGED
@@ -109,6 +109,7 @@ const SETTINGS: Setting[] = [
109
109
  { key: "state.keep_runs", values: ["5", "10", "20", "50"], help: "Completed runs kept before the oldest is pruned" },
110
110
  { key: "git.auto_commit", values: ["false", "true"], help: "Commit the work when a run finishes" },
111
111
  { key: "git.branch_before_implement", values: ["true", "false"], help: "Branch before building when on the default branch" },
112
+ { key: "ui.mouse", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
112
113
  { key: "agent.default", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "Default coding agent to launch from dashboard backlog" },
113
114
  ];
114
115
 
@@ -120,6 +121,12 @@ const c = {
120
121
  inv: (s: string) => `\x1b[7m${s}\x1b[0m`,
121
122
  };
122
123
 
124
+ // Registered once at module scope, not per call: opening settings, going back and
125
+ // opening it again would otherwise stack a listener each time, and Node starts
126
+ // printing warnings into the alternate screen at eleven.
127
+ let mouseArmed = false;
128
+ process.on("exit", () => { if (mouseArmed) process.stdout.write("\x1b[?1006l\x1b[?1000l"); });
129
+
123
130
  export async function settings(repoRoot: string, standalone = true) {
124
131
  const file = locate();
125
132
  if (!existsSync(file)) {
@@ -155,15 +162,70 @@ export async function settings(repoRoot: string, standalone = true) {
155
162
  };
156
163
 
157
164
  stdout.write("\x1b[?1049h\x1b[?25l"); // alternate screen, hide cursor
165
+ const mouse = /^\s*ui:\s*\n(?:\s*#.*\n)*\s*mouse:\s*(\S+)/m.exec(readFileSync(file, "utf8"));
166
+ const mouseOn = mouse?.[1] === "true";
167
+ if (mouseOn) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
158
168
  stdin.setRawMode(true);
159
169
  stdin.resume();
160
170
  draw();
161
171
 
172
+ function cycle(step: number) {
173
+ const s = SETTINGS[active]!;
174
+ const wasMouse = s.key === "ui.mouse";
175
+ const text = readFileSync(file, "utf8");
176
+ if (s.key === "preset") {
177
+ const current = detectPreset(text, defaults);
178
+ const presets: PresetName[] = ["low", "medium", "ultra"];
179
+ const at = presets.indexOf(current as PresetName);
180
+ const next = presets[((at < 0 ? 0 : at) + step + presets.length) % presets.length]!;
181
+ const updated = applyPreset(text, next, defaults);
182
+ writeFileSync(file, updated);
183
+ saved = c.green(`applied preset: ${next}`);
184
+ } else {
185
+ const now = read(text, s.key) ?? s.values[0]!;
186
+ const at = s.values.indexOf(now);
187
+ const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
188
+ let updated = write(text, s.key, next, defaults);
189
+ const detected = detectPreset(updated, defaults);
190
+ updated = write(updated, "preset", detected, defaults);
191
+ writeFileSync(file, updated);
192
+ // Only claim a save that happened. Reporting one that did not is worse
193
+ // than failing loudly: the setting reads back unchanged and nobody knows why.
194
+ saved = read(updated, s.key) === next
195
+ ? c.green(`saved ${s.key} = ${next || '""'} (preset: ${detected})`)
196
+ : `\x1b[33mcould not write ${s.key}\x1b[0m`;
197
+ // Applied here rather than at the next launch: a toggle that needs a restart
198
+ // reads as a toggle that does not work.
199
+ if (wasMouse) {
200
+ if (next === "true" && !mouseArmed) { stdout.write("\x1b[?1000h\x1b[?1006h"); mouseArmed = true; }
201
+ if (next === "false" && mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
202
+ }
203
+ }
204
+ }
205
+
162
206
  await new Promise<void>((resolve) => {
163
207
  const onData = (chunk: Buffer) => {
164
208
  const keys = chunk.toString();
165
209
  for (let i = 0; i < keys.length; i++) {
166
210
  const rest = keys.slice(i);
211
+ // Rows begin on the fourth line: blank, title, blank, then the settings.
212
+ const m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(rest);
213
+ if (m) {
214
+ i += m[0].length - 1;
215
+ const button = +m[1]!, y = +m[3]!, press = m[4] === "M";
216
+ if (button === 64) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
217
+ else if (button === 65) active = (active + 1) % SETTINGS.length;
218
+ else if (press && button === 0) {
219
+ const row = y - 4;
220
+ if (row < 0 || row >= SETTINGS.length) continue;
221
+ // Click to select; click the selected row to cycle it, which is what
222
+ // the right arrow already does.
223
+ if (row === active) { cycle(1); continue; }
224
+ active = row;
225
+ } else continue;
226
+ draw();
227
+ continue;
228
+ }
167
229
  // esc, q, backspace, and left-at-the-edge all mean "back to where I came
168
230
  // from" -- the settings screen is always something you opened from
169
231
  // somewhere else.
@@ -174,31 +236,7 @@ export async function settings(repoRoot: string, standalone = true) {
174
236
  if (rest.startsWith("\x1b[A")) { active = (active - 1 + SETTINGS.length) % SETTINGS.length; i += 2; }
175
237
  else if (rest.startsWith("\x1b[B")) { active = (active + 1) % SETTINGS.length; i += 2; }
176
238
  else if (rest.startsWith("\x1b[C") || rest.startsWith("\x1b[D") || rest.startsWith("\r")) {
177
- const step = rest.startsWith("\x1b[D") ? -1 : 1;
178
- const s = SETTINGS[active]!;
179
- const text = readFileSync(file, "utf8");
180
- if (s.key === "preset") {
181
- const current = detectPreset(text, defaults);
182
- const cycle: PresetName[] = ["low", "medium", "ultra"];
183
- const at = cycle.indexOf(current as PresetName);
184
- const next = cycle[((at < 0 ? 0 : at) + step + cycle.length) % cycle.length]!;
185
- const updated = applyPreset(text, next, defaults);
186
- writeFileSync(file, updated);
187
- saved = c.green(`applied preset: ${next}`);
188
- } else {
189
- const now = read(text, s.key) ?? s.values[0]!;
190
- const at = s.values.indexOf(now);
191
- const next = s.values[((at < 0 ? 0 : at) + step + s.values.length) % s.values.length]!;
192
- let updated = write(text, s.key, next, defaults);
193
- const detected = detectPreset(updated, defaults);
194
- updated = write(updated, "preset", detected, defaults);
195
- writeFileSync(file, updated);
196
- // Only claim a save that happened. Reporting one that did not is worse
197
- // than failing loudly: the setting reads back unchanged and nobody knows why.
198
- saved = read(updated, s.key) === next
199
- ? c.green(`saved ${s.key} = ${next || '""'} (preset: ${detected})`)
200
- : `\x1b[33mcould not write ${s.key}\x1b[0m`;
201
- }
239
+ cycle(rest.startsWith("\x1b[D") ? -1 : 1);
202
240
  if (!rest.startsWith("\r")) i += 2;
203
241
  } else if (rest.startsWith("k")) active = (active - 1 + SETTINGS.length) % SETTINGS.length;
204
242
  else if (rest.startsWith("j")) active = (active + 1) % SETTINGS.length;
@@ -211,6 +249,7 @@ export async function settings(repoRoot: string, standalone = true) {
211
249
 
212
250
  stdin.setRawMode(false);
213
251
  stdin.pause();
252
+ if (mouseArmed) { stdout.write("\x1b[?1006l\x1b[?1000l"); mouseArmed = false; }
214
253
  stdout.write("\x1b[?25h\x1b[?1049l"); // restore cursor and screen
215
254
  // Returning to the dashboard means returning to its alternate screen, where a
216
255
  // line printed here would never be seen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Staged workflow for AI agents \u2014 code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -94,6 +94,7 @@ whichever the user picks.
94
94
  | `review.write_memory` | `true`, `false` | Let the reviewer append lessons to `memory.md` |
95
95
  | `git.auto_commit` | `true`, `false` | Commit the work when a run finishes |
96
96
  | `git.branch_before_implement` | `true`, `false` | Branch before building when on the default branch |
97
+ | `ui.mouse` | `false`, `true` | Click to select, click again to act; while on, the terminal cannot select text |
97
98
  | `agent.default` | `auto`, `claude`, `codex`, `agy`, `opencode`, `gemini` | Coding agent to launch from dashboard backlog |
98
99
 
99
100
  Rules for writing the file:
@@ -129,6 +129,13 @@ git:
129
129
  # Ignored when the project is not a git repository.
130
130
  branch_before_implement: true
131
131
 
132
+ ui:
133
+ # Mouse in the TUI: click a row or card to select it, click it again to open or
134
+ # run it, wheel to scroll. Off by default, and the reason is a real trade: while
135
+ # the TUI holds the mouse, the terminal cannot select text with it, so copying a
136
+ # run id out of the dashboard stops working until you leave.
137
+ mouse: false
138
+
132
139
  agent:
133
140
  # Default coding agent to launch from dashboard backlog (auto | claude | codex | agy | opencode | gemini).
134
141
  # auto picks Claude Code if installed, otherwise the first agent found on PATH.