kaizen-agent 1.43.1 → 1.43.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/cli/pty.test.ts CHANGED
@@ -186,6 +186,16 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
186
186
  await Bun.sleep(200);
187
187
  expect(alive(used)).toBe(true);
188
188
  process.kill(used, "SIGTERM");
189
+
190
+ // Only looked at: the page's focus and query answers reach the tty but are not typing.
191
+ done(Date.now() - 60_000);
192
+ const looked = launch("looked");
193
+ await Bun.sleep(200);
194
+ writePty("looked", "\x1b[I\x1b[O\x1b[?0u\x1b[<64;10;5M\x1bP>|xterm.js(6.0)\x1b\\");
195
+ await Bun.sleep(200);
196
+ expect(ptyState("looked")!.typed).toBe(0);
197
+ sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
198
+ expect(await gone(looked)).toBe(true);
189
199
  });
190
200
 
191
201
  test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
package/cli/pty.ts CHANGED
@@ -11,7 +11,7 @@ const WIN = process.platform === "win32";
11
11
  // Windows PowerShell, which every Windows has, both hosts the console and runs the agent in it.
12
12
  const winPs = () => join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
13
13
 
14
- type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
14
+ type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; typed: number; onChange?: () => void };
15
15
 
16
16
  // What a page attaching late gets replayed. A full-screen tool redraws often, so the
17
17
  // tail of its output is enough to rebuild the screen.
@@ -38,7 +38,7 @@ export const ptySupported = WIN || typeof (Bun as any).Terminal === "function";
38
38
  // onChange: called when the agent ends or starts or stops asking, so the board can redraw.
39
39
  export function startPty(key: string, cmd: string[], cwd: string, pidFile?: string, onChange?: () => void): number {
40
40
  const dec = new TextDecoder();
41
- const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, onChange };
41
+ const p: Pty = { proc: null as unknown as Bun.Subprocess, out: [], size: 0, listeners: new Set(), exit: null, asking: false, total: 0, since: 0, typed: 0, onChange };
42
42
  const flag = (v: boolean) => { if (p.asking !== v) { p.asking = v; onChange?.(); } };
43
43
  const push = (d: string) => {
44
44
  p.out.push(d);
@@ -99,11 +99,19 @@ export function attachPty(key: string, fn: (d: string | null) => void): (() => v
99
99
  return () => p.listeners.delete(fn);
100
100
  }
101
101
 
102
+ // What the page's terminal sends by itself: focus in/out (Claude Code turns focus
103
+ // reporting on), mouse and wheel reports, and answers to the agent's queries (cursor,
104
+ // device, mode, colour, version). None of it is the user typing.
105
+ const REPORT = /\x1b\[[IO]|\x1b\[<[\d;]*[Mm]|\x1b\[\?[\d;]*(?:[cnu]|\$y)|\x1b\[>[\d;]*c|\x1b\[\d+;\d+R|\x1b\[\d*n|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1bP[^\x1b]*\x1b\\/g;
106
+
102
107
  export function writePty(key: string, d: string) {
103
108
  const p = ptys.get(key);
104
109
  if (!p || p.exit !== null) return;
105
- p.since = p.total;
106
- if (p.asking) { p.asking = false; p.onChange?.(); }
110
+ if (d.replace(REPORT, "")) {
111
+ p.typed = Date.now();
112
+ p.since = p.total;
113
+ if (p.asking) { p.asking = false; p.onChange?.(); }
114
+ }
107
115
  send(p, d);
108
116
  }
109
117
 
@@ -122,7 +130,7 @@ export const ptyKeys = () => [...ptys.keys()];
122
130
 
123
131
  export const ptyState = (key: string) => {
124
132
  const p = ptys.get(key);
125
- return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking } : null;
133
+ return p ? { alive: p.exit === null, exit: p.exit, asking: p.asking, typed: p.typed } : null;
126
134
  };
127
135
 
128
136
  // The environment the user's login shell builds: PATH and anything exported in their
package/cli/state.ts CHANGED
@@ -5,7 +5,7 @@ import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, m
5
5
  import { Database } from "bun:sqlite";
6
6
  import { join, dirname, win32, posix } from "node:path";
7
7
  import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
8
- import { startPty, ptySupported } from "./pty.ts";
8
+ import { startPty, ptySupported, ptyState } from "./pty.ts";
9
9
  import { codeDir, projectFromWorktree } from "./worktree.ts";
10
10
 
11
11
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
@@ -1343,7 +1343,7 @@ function opencodeRows(sql: string, ...args: (string | number)[]): string[] {
1343
1343
  } catch { return []; }
1344
1344
  }
1345
1345
 
1346
- export const CLOSE_GRACE_MS = 2 * 60_000;
1346
+ export const CLOSE_GRACE_MS = 60_000;
1347
1347
  const sessionsDir = (st: string) => join(st, "sessions");
1348
1348
 
1349
1349
  export function readSessions(st: string): Session[] {
@@ -1509,7 +1509,11 @@ export function sweepSessions(st: string, now: number) {
1509
1509
  // Whether the user typed into the session's terminal after `t`: its tty's access time
1510
1510
  // moves when input is read. Such a session is the user's now, not the run's to close.
1511
1511
  // No tty (Windows, a stranger's PID): false, so the sweep closes as before.
1512
+ // A board card's session is judged by its keystrokes: the page's terminal answers the
1513
+ // agent's focus and query reports on that tty too, which moved its time on every look.
1512
1514
  function typedSince(st: string, s: Session, t: number): boolean {
1515
+ const card = ptyState(s.key);
1516
+ if (card) return card.typed > t;
1513
1517
  try {
1514
1518
  if (process.platform === "win32") return false;
1515
1519
  const pid = parseInt(readText(join(sessionsDir(st), s.key + ".pid")));
package/cli/web.ts CHANGED
@@ -209,6 +209,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
209
209
  const key = c.kind === "run" ? runs.get(`${c.state}\0${c.id}`) : ideaPty.get(`${c.state}\0${c.text}`);
210
210
  if (!key || !live.has(key)) return undefined;
211
211
  const st = ptyState(key)!;
212
+ // A finished run's agent that has ended has nothing left to show or resume.
213
+ if (!st.alive && (c.status === "done" || c.status === "abandoned")) return undefined;
212
214
  return { key, alive: st.alive, asking: st.asking };
213
215
  };
214
216
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.43.1",
3
+ "version": "1.43.2",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,7 +114,7 @@ agent:
114
114
  # auto picks Claude Code if installed, otherwise the first agent found on PATH.
115
115
  default: auto
116
116
  # on: a board decision ends the run's waiting session before continuing its
117
- # conversation, and abandoned or done runs (after 2 minutes) have theirs ended.
117
+ # conversation, and abandoned or done runs (after 1 minute) have theirs ended.
118
118
  # Only sessions the board or dashboard launched are touched. off: nothing is
119
119
  # ended, and a resumed conversation opens beside the window still waiting.
120
120
  close: on
@@ -108,7 +108,9 @@ updated: YYYY-MM-DD
108
108
  2. Split it into 5–12 areas a newcomer would name — by what the parts do, not by
109
109
  folder alone. Fewer for a small project.
110
110
  3. Write one note per area in the shape above, then `Index.md`, then `Log.md` with the
111
- first line.
111
+ first line. Every source file lands in some note's `sources` — the area's helpers,
112
+ small screens and scripts too, not only its main files — named directly or through a
113
+ folder that holds it (see *Coverage* below).
112
114
  4. Write the pointer (below).
113
115
  5. Lint, and report.
114
116
 
@@ -124,8 +126,9 @@ updated: YYYY-MM-DD
124
126
  rewrite the sections they affect — never `## Notes` — and set `updated`.
125
127
  3. Every `sources` path is gone: move the note to `Project/Archive/` and drop it from
126
128
  the Index. Never delete a note.
127
- 4. A part of the repository no note covers (a new top-level folder, a new entry point):
128
- write a note for it.
129
+ 4. A source file no note covers (see *Coverage*): add it to the `sources` of the note
130
+ whose area it belongs to, and mention it there; where it fits no area (a new top-level
131
+ folder, a new entry point), write a note for it.
129
132
  5. Refresh the Index lines whose description changed, append one Log line naming every
130
133
  note touched, refresh the pointer, lint, and report.
131
134
 
@@ -138,10 +141,20 @@ Checks, reported and not silently fixed:
138
141
  - every note has `description`, `tags`, `sources` and `updated` (the Index and Log need
139
142
  no `sources`), and is listed in the Index;
140
143
  - every `sources` path exists;
144
+ - **uncovered**: source files no note's `sources` names or holds, listed one per line;
141
145
  - **stale**: notes whose `sources` changed after their `updated` day, found as in
142
146
  *Update* step 1;
143
147
  - `Index.md` is under 60 lines.
144
148
 
149
+ ## Coverage
150
+
151
+ A **source file** is one the repository tracks (`git ls-files`; outside git, every file
152
+ not ignored), leaving out lockfiles, generated or built output, vendored code, binaries,
153
+ and bulk data folders. A note covers a file when its `sources` names the file or a folder
154
+ that holds it, so a data folder such as `public/quran` is covered by naming the folder.
155
+ Lint's *uncovered* list is what makes this checkable: a file no note covers is also a file
156
+ whose changes *stale* never sees, so the vault falls behind without saying so.
157
+
145
158
  ## The pointer
146
159
 
147
160
  What makes an agent open the Index first. One block, identical everywhere:
@@ -191,3 +191,31 @@ When a child run reaches `done`, the board appends `Child <child id> done: <titl
191
191
  the parent's `notes.md` by itself. The parent documents it from there: if it is still in
192
192
  flight its next stage reads `notes.md`; if it is already `done`, the entry is the record.
193
193
  Never reopen a finished parent.
194
+
195
+ **A roadmap's increments are filed at its approval.** When the approved plan has a
196
+ `## Roadmap` (see *A whole application is a roadmap* in `spec-plan.md`), the same write
197
+ that records the approval files increments 2 to N, in order, from the project folder:
198
+ `kaizen card add "<title> (roadmap #N · after #a, #b)" --parent <run id>`, with
199
+ `any time` in place of `after …` where it has no prerequisite and its `blocked: <reason>`
200
+ kept in the text where it has one. Increment 1 is this run's own build. Nothing starts
201
+ by itself: the user starts an increment from its card when they choose, and the parent's
202
+ card lists its children with their status. An increment cut at the approval is not
203
+ filed; it goes to the backlog like anything else cut there.
204
+
205
+ ## Discovery rounds
206
+
207
+ A roadmap plan's Discovery and any run's REA offer reach the user through the main
208
+ thread, under every runner: each round's questions are asked with the tool's structured
209
+ question mechanism (AskUserQuestion in Claude Code), all of a round's questions in one
210
+ call, each with the planner's recommended option first. Under `runner: full`, the
211
+ planner stops after writing a round; dispatch it again with the answers, recorded in
212
+ `02-approval.md` as that round's decisions, until it writes a plan with no round left
213
+ to ask, then present that plan at the plan approval as usual. Answers are not an
214
+ approval: the run stays at `stage: "plan"` with `awaiting: null` throughout, and
215
+ `awaiting: "approvals.plan"` is set only once `01-plan.md` has a `## Roadmap` — a round
216
+ written to that file is not a plan to approve, and a tool resuming the run re-asks its
217
+ questions. Where the user says yes to REA before the plan exists, run the study here,
218
+ write what it found to the run's `notes.md`, and dispatch the planner with it. Where
219
+ REA has to be installed first, the install in *Offer REA* (`spec-plan.md`) is the main
220
+ thread's too: doctor, the dry-run shown to the user, setup only on their approval,
221
+ then the restart, after which the run resumes at `stage: "plan"` from `state.json`.
@@ -27,6 +27,39 @@ different work behind each, nothing available to break the tie — and even then
27
27
  its recommendation with the question. Questions the existing material can answer are
28
28
  never asked; that is the planner's own work.
29
29
 
30
+ **Offer REA for what exists but is not here.** In every run and every track: when the
31
+ request or what came with it names something that already exists but whose source is
32
+ not at hand — an app or site to copy, replace or integrate with, an APK or IPA, a
33
+ binary, a package, a file format, a device or its firmware, a protocol or a capture —
34
+ and REA's tools are connected in the session, the plan asks once:
35
+ `Q: Study <the thing> with REA first?`, with `yes` (recommended where how it works
36
+ inside decides the design) and `no`. Under `runner: lite` or an inline planner, a yes
37
+ given before the plan is written lets the planner run REA itself and plan from what it
38
+ found. Under `full`, where the planner has no REA tools, the main thread runs the study
39
+ on a yes, writes what it found to the run's `notes.md`, and dispatches the planner
40
+ again; a yes given at the plan approval instead makes the study the build's first
41
+ step. What REA
42
+ returns is cited with its limitations, as *the track* in `spec.md` already requires.
43
+ REA not connected: ask instead
44
+ `Q: REA isn't installed — set it up first?`, with `help me install it` (recommended
45
+ where how it works inside decides the design), `I'll install it myself` and `skip
46
+ REA`. On *help me install it* the main thread follows REA's own setup: it runs
47
+ `npx -y rea-agents@<version> doctor --client <client> --json` — the version pinned,
48
+ as REA's own guide pins it, taken from `npm view rea-agents version` and shown to the
49
+ user; the client from the run's `agent` (`claude` → `claude_code`, `codex` → `codex`,
50
+ `agy` → `antigravity`, `opencode` → `opencode`, `gemini` → `gemini_cli`, `cursor` →
51
+ `cursor`) — then `setup --client <client> --dry-run --json` with the same version, shows the user the exact files it
52
+ would change, and runs `setup --client <client> --yes` only after they approve it —
53
+ setup writes the agent's own configuration, so it is never run unseen, in any mode.
54
+ On *I'll install it myself* it gives that same command and waits. Either way the new
55
+ tools appear only after the agent restarts: say so, and the run resumes from its
56
+ `state.json`, the yes to REA recorded beforehand in `02-approval.md` so the resumed
57
+ session studies the thing rather than asking again. REA's prerequisites (Node, and
58
+ Java, Ghidra or JADX for some targets) are named where doctor reports them missing,
59
+ never installed by the run. On *skip REA*, or when setup fails, the plan says so
60
+ once and claims about that thing's internals are marked unverified. Nothing of the
61
+ kind named: REA is not mentioned.
62
+
30
63
  **Building from nothing is the exception: settle the foundation first.** When the
31
64
  track is a new product or app, there is no material to answer the questions that
32
65
  decide everything after them, and "wrong" for this track is a wrong foundation — the
@@ -39,6 +72,67 @@ materially different builds — put to the user as an *Open question* with optio
39
72
  all in one round, before anything is built. Three quick questions and a default for
40
73
  the rest is how a run ends up writing the second app instead of the first.
41
74
 
75
+ **A whole application is a roadmap, not a run.** When the track is a new product or
76
+ app — web, mobile, PWA, desktop, anything — and it covers more than one independent
77
+ module, one run cannot hold it: the plan, the diff and the review all outgrow what a
78
+ reader can check. A single screen or a single feature stays one run. The Framing says
79
+ in one line that the plan is a roadmap, so the user can refuse it. A roadmap plan adds
80
+ three sections right after the Goal, each written for a person, and the
81
+ foundation round above becomes up to three rounds:
82
+
83
+ - **`## Discovery`** — the planner goes deep before it designs. It reads everything the
84
+ user brought first (decks, forms, sheets, screenshots, an existing repo, REA's
85
+ findings), then asks only what is still open, in up to three rounds of `Q:` pick
86
+ lists, each question with a recommended option: (1) purpose, who uses it and in what
87
+ roles, what it replaces; (2) the flows of each module and their domain rules;
88
+ (3) the foundation choices, among them how it ships, as a multi-select — full CI/CD
89
+ (tests and deploy on every merge), Docker (an image and a compose file that run
90
+ anywhere), a managed platform (Vercel, Fly, Railway or similar), mobile app ready
91
+ (store builds with signing, for example Capacitor or Expo/EAS), or a simple manual
92
+ deploy — and, where the app has a screen and no design system exists, its look and
93
+ feel, asked as item 7 *Design direction* below says: the feel (for example rich and
94
+ animated, calm and simple, enterprise and dense, gen-z and playful) and how much
95
+ motion. A round with nothing left to ask is skipped. Under `runner: full` the
96
+ planner writes `01-plan.md` with the Framing, `## Discovery` so far and the round's
97
+ questions as *Open questions*, and no `## Roadmap`, then stops; that file is a
98
+ round, not a plan. The main thread asks them and dispatches it again with the
99
+ answers. Under
100
+ `lite` or inline, the session asks them directly. The section records the roles, the
101
+ main flows, the things the app keeps track of and how they relate, a glossary of
102
+ the domain's words, and every answer given.
103
+ - **`## Foundation`** — a decision with its reason, derived from Discovery, for each
104
+ of: platform; frontend (framework, UI kit, state, the design direction chosen);
105
+ backend (runtime, framework, API style); the contract between them (REST, RPC or
106
+ realtime, and where its types live); hosting and deploy, as chosen in discovery;
107
+ database (engine, where it runs, the main tables or collections and their
108
+ relations, how migrations are done); auth and access; repo layout and conventions.
109
+ Every increment builds on it.
110
+ - **`## Roadmap`** — the increments, numbered, one line each:
111
+ `N. <title> — <what it delivers> — after: #a, #b` (or `any time`), with
112
+ `— blocked: <reason>` when something outside the work holds it. Increment 1 is always
113
+ the skeleton: the scaffold, the stack wired, the database schema and migration setup,
114
+ an auth stub, and a deploy of the empty app the way the Foundation ships (in its
115
+ Docker image, through its pipeline, as a store test build). Every feature increment
116
+ after it is one feature end to end — its screens, its API, its tables and migration,
117
+ and its tests — so each can be tried on its own; splitting by layer ("all the APIs", then "all the screens")
118
+ is only for an app with no UI or no backend. Each increment is sized to one ordinary
119
+ run. Where real people will use the app, the last increment is going live: what
120
+ the deploy choice still needs (the full pipeline, the production image, store
121
+ listings and release builds), backups, monitoring and error tracking, and the
122
+ production deploy. A last line says which must go in order and which can start any
123
+ time.
124
+
125
+ The rest of a roadmap plan — Work list, Steps, File manifest, gate — covers increment 1
126
+ only; it is this run's build. The others become child cards at the approval (see
127
+ *Child cards* in `spec-main.md`).
128
+
129
+ **An increment run builds inside its parent's decisions.** A request with a `Parent:`
130
+ line whose parent run's `01-plan.md` has a `## Roadmap` is an increment. Its planner
131
+ reads that plan's Discovery, Foundation and its own Roadmap line before anything else.
132
+ They bind it: a deviation from the Foundation is an *Open question* to the user, and a
133
+ change to the roadmap is an *Out of scope suggestion*, never a silent edit of the
134
+ parent's plan.
135
+
42
136
  The goals stay the user's. The planner may propose a better solution to what was
43
137
  asked, or argue that the request as stated will not achieve what the user wants, but
44
138
  it may not widen the goal on its own. Anything beyond the request goes in *Out of
@@ -132,7 +226,7 @@ questions that are open. An alternative rejected gets a line saying what and why
132
226
  a case against it.
133
227
 
134
228
  **Write the parts a person reads for a person.** Framing, the Work list, Goal, Scope,
135
- Risks and Out of scope suggestions are read by someone deciding whether to approve,
229
+ Discovery, Foundation, Roadmap, Risks and Out of scope suggestions are read by someone deciding whether to approve,
136
230
  often on a narrow panel. Write them in everyday words: short sentences, one idea per
137
231
  bullet, things named by what the user sees ("the chat bubble", "the Plan tab") rather
138
232
  than by function, file, CSS value or gate id. Code names, `path:line` and gate ids
@@ -31,7 +31,7 @@ anything.
31
31
  | `/kaizen backlog` | Print open backlog items across all runs, grouped by source run, rescued items last under `Orphaned` |
32
32
  | `/kaizen gather` | Read request sources kaizen cannot read by itself; their requests land in the icebox, nothing starts |
33
33
  | `/kaizen discover` | Group what is in the icebox and promote the ideas you pick; nothing starts |
34
- | `/kaizen docs [lint]` | Write or refresh the project's docs vault in the notebook, which agents read first; `lint` only reports what is stale or broken |
34
+ | `/kaizen docs [lint]` | Write or refresh the project's docs vault in the notebook, which agents read first; `lint` only reports what is stale, broken or uncovered |
35
35
  | `/kaizen approve` | Approve whatever the current run is waiting on |
36
36
  | `/kaizen reject <reason>` | Reject it; the reason decides revise (back to planner) vs kill (abandon) |
37
37
  | `/kaizen abort` | Mark the current run abandoned |
@@ -29,12 +29,18 @@ const save = () => {
29
29
  // key -> its tile and the function that unmounts its terminal.
30
30
  const tiles = new Map<string, { el: HTMLElement; off: (() => void) | null }>();
31
31
  let showing = false;
32
+ // A phone shows one terminal at a time, picked from tabs: stacked, each terminal took every
33
+ // swipe and the page could only be scrolled by its thin headers. The pick is per visit.
34
+ // Made on first show, so importing this module needs no window.
35
+ let phone: MediaQueryList | null = null,
36
+ pick: string | null = null;
32
37
 
33
38
  // The header: run name - project name. A run's name is its id without the date, as card chips name runs.
34
39
  const nameOf = (c) =>
35
40
  c.kind === "run"
36
41
  ? String(c.id || c.text).replace(/^\d{4}-\d\d-\d\d-/, "").replace(/-/g, " ")
37
42
  : String(c.text).split("\n")[0].slice(0, 80);
43
+ const tst = (c) => (!c.pty.alive ? "ended" : c.pty.asking ? "asking" : "running");
38
44
  const projOf = (c) => String(c.where || "").split(/[\\/]/).filter(Boolean).pop() || c.where || "";
39
45
  // One card per session; the newest card wins a shared key.
40
46
  function sessions() {
@@ -52,6 +58,7 @@ export function termsStop() {
52
58
 
53
59
  export function openTerms() {
54
60
  showing = true;
61
+ if (!phone) (phone = matchMedia("(max-width:640px)")).onchange = () => showing && openTerms();
55
62
  // A /state tick mid-drag would rebuild the handle being dragged.
56
63
  if (document.body.classList.contains("tdrag")) return;
57
64
  const box = $("terms"),
@@ -68,6 +75,8 @@ export function openTerms() {
68
75
  const keys = [...all.keys()].filter((k) => !lay.hidden.includes(k)).sort((a, b) => ended(a) - ended(b) || rank(a) - rank(b));
69
76
  for (const k of keys) if (!lay.order.includes(k)) lay.order.push(k);
70
77
  if (lay.max && !keys.includes(lay.max)) lay.max = null;
78
+ // The one tile showing: on a phone the tab picked (the first by default), else the maximised one.
79
+ const one = phone.matches ? (pick && keys.includes(pick) ? pick : keys[0]) : lay.max;
71
80
  let grid = box.querySelector<HTMLElement>(".tgrid"),
72
81
  bar = box.querySelector<HTMLElement>(".tbar");
73
82
  if (!grid) {
@@ -77,7 +86,15 @@ export function openTerms() {
77
86
  }
78
87
  // An ended session closed with × has nothing to come back to: no chip for it.
79
88
  const hid = [...all.keys()].filter((k) => lay.hidden.includes(k) && all.get(k).pty.alive);
80
- bar!.innerHTML = html`<span class="bar" role="group" aria-label="Layout">${MODES.map(([v, l]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}">${l}</button>`)}</span>${hid.length ? html`<span class="dim">Hidden</span>${hid.map((k) => html`<button class="chip" data-show="${k}" title="Show this terminal">${ico("plus")}${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}` : ""}`.s;
89
+ bar!.innerHTML = html`<span class="bar" role="group" aria-label="Layout">${MODES.map(([v, l]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}">${l}</button>`)}</span>${hid.length ? html`<span class="dim">Hidden</span>${hid.map((k) => html`<button class="chip" data-show="${k}" title="Show this terminal">${ico("plus")}${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}` : ""}${phone.matches && keys.length > 1 ? html`<span class="ttabs" role="group" aria-label="Terminals">${keys.map((k) => html`<button class="${k === one ? "on" : ""}" data-pick="${k}" aria-pressed="${String(k === one)}"><span class="tdot ${tst(all.get(k))}"></span>${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}</span>` : ""}`.s;
90
+ bar!.querySelectorAll<HTMLElement>("[data-pick]").forEach(
91
+ (b) =>
92
+ (b.onclick = () => {
93
+ pick = b.dataset.pick!;
94
+ openTerms();
95
+ }),
96
+ );
97
+ bar!.querySelector(".ttabs .on")?.scrollIntoView({ block: "nearest", inline: "nearest" });
81
98
  bar!.querySelectorAll<HTMLElement>("[data-mode]").forEach(
82
99
  (b) =>
83
100
  (b.onclick = () => {
@@ -118,24 +135,24 @@ export function openTerms() {
118
135
  mount(k, t);
119
136
  wire(el);
120
137
  }
121
- const st = !c.pty.alive ? "ended" : c.pty.asking ? "asking" : "running",
138
+ const st = tst(c),
122
139
  off = st === "ended";
123
140
  const hd = t.el.querySelector("header")!;
124
141
  hd.title = (c.kind === "run" ? c.id : c.text) + " · " + c.where;
125
142
  // An ended run can be resumed from here; an idea whose agent ended has no run to resume.
126
143
  hd.innerHTML = html`<span class="tdot ${st}" role="img" title="${st}" aria-label="${st}"></span><b>${nameOf(c)} - ${projOf(c)}</b>${off ? html`<span class="tend">ended</span>` : ""}<span class="sp"></span>${off && c.kind === "run" ? html`<button class="btn sm" data-a="resume" title="Continue this run's agent">Resume</button>` : ""}<button class="ib sm" data-a="card" title="Open card" aria-label="Open card">${ico("open")}</button><button class="ib sm" data-a="max" title="${lay.max === k ? "Restore" : "Maximise"}" aria-label="${lay.max === k ? "Restore" : "Maximise"}">${ico(lay.max === k ? "min" : "max")}</button><button class="ib sm" data-a="hide" title="${off ? "Close" : "Hide"}" aria-label="${off ? "Close" : "Hide"}">${ico("close")}</button>`.s;
127
144
  t.el.classList.toggle("ended", off);
128
- t.el.classList.toggle("off", !!lay.max && lay.max !== k);
145
+ t.el.classList.toggle("off", !!one && one !== k);
129
146
  t.el.style.gridColumn = "";
130
147
  grid.append(t.el); // in order
131
148
  }
132
149
  // Grid is √n, the last tile spanning what its row leaves empty; side by side is one row, stacked one column.
133
- const n = lay.max ? 1 : keys.length,
150
+ const n = one ? 1 : keys.length,
134
151
  cols = lay.mode === "cols" ? n : lay.mode === "rows" ? 1 : Math.ceil(Math.sqrt(n)),
135
152
  rows = Math.ceil(n / cols);
136
153
  // The column where the last row's tile starts spanning; borders right of it are not inner ones there.
137
154
  let spanFrom = cols;
138
- if (!lay.max && cols * rows > n) {
155
+ if (!one && cols * rows > n) {
139
156
  tiles.get(keys[keys.length - 1])!.el.style.gridColumn = `span ${cols * rows - n + 1}`;
140
157
  spanFrom = n - (rows - 1) * cols - 1;
141
158
  }
@@ -143,7 +160,7 @@ export function openTerms() {
143
160
  sz = lay.sizes[shape];
144
161
  const c = sz?.c?.length === cols ? sz.c : Array(cols).fill(1),
145
162
  r = sz?.r?.length === rows ? sz.r : Array(rows).fill(1);
146
- layout(grid, c, r, shape, spanFrom);
163
+ layout(grid, c, r, shape, spanFrom, !one);
147
164
  }
148
165
 
149
166
  function mount(k, t) {
@@ -177,7 +194,8 @@ function wire(el: HTMLElement) {
177
194
  openTerms();
178
195
  };
179
196
  el.querySelector("header")!.ondblclick = (e) => {
180
- if ((e.target as HTMLElement).closest("button")) return;
197
+ // A phone shows one tile anyway: a double-tap there would only leave desktop maximised.
198
+ if ((e.target as HTMLElement).closest("button") || phone?.matches) return;
181
199
  lay.max = lay.max === k ? null : k;
182
200
  save();
183
201
  openTerms();
@@ -215,7 +233,7 @@ const at = (tr: number[], i: number) => {
215
233
  S = tr.slice(0, i + 1).reduce((a, b) => a + b, 0);
216
234
  return `calc((100% - ${(tr.length - 1) * GAP}px) * ${S / F} + ${i * GAP + GAP / 2}px)`;
217
235
  };
218
- function layout(grid: HTMLElement, c: number[], r: number[], shape: string, spanFrom: number) {
236
+ function layout(grid: HTMLElement, c: number[], r: number[], shape: string, spanFrom: number, splits: boolean) {
219
237
  grid.querySelectorAll(".tsplit").forEach((s) => s.remove());
220
238
  const place = () => {
221
239
  grid.style.gridTemplateColumns = c.map((x) => x + "fr").join(" ");
@@ -227,7 +245,7 @@ function layout(grid: HTMLElement, c: number[], r: number[], shape: string, span
227
245
  h.style.bottom = i >= spanFrom ? `calc(100% - ${at(r, r.length - 2)} + ${GAP / 2}px)` : "0px";
228
246
  });
229
247
  };
230
- if (!lay.max)
248
+ if (splits)
231
249
  for (const [axis, tr] of [["c", c], ["r", r]] as const)
232
250
  for (let i = 0; i < tr.length - 1; i++) {
233
251
  const h = document.createElement("div");
package/web/board.html CHANGED
@@ -283,8 +283,9 @@ body.termv #terms{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px
283
283
  .tsplit{position:absolute;z-index:2;touch-action:none}.tsplit.c{top:0;width:10px;margin-left:-5px;cursor:col-resize}.tsplit.r{left:0;right:0;height:10px;margin-top:-5px;cursor:row-resize}.tsplit:hover,.tdrag .tsplit:active{background:color-mix(in srgb,var(--accent) 35%,transparent)}
284
284
  body.tdrag{user-select:none}body.tdrag .tile{pointer-events:none}
285
285
  @media(max-width:900px){body.termv #terms{padding-inline:16px}}
286
- /* A phone has no room for a grid: tiles stack, each most of a screen tall, and the splits go */
287
- @media(max-width:640px){.tbar .bar{display:none}.tgrid{display:flex;flex-direction:column;overflow:auto}.tile{flex:none;height:70vh}.tile.off{display:none}.tsplit{display:none}}
286
+ /* A phone has no room for a grid or a stack: one terminal fills the page, and tabs above pick which */
287
+ .ttabs{display:none}
288
+ @media(max-width:640px){.tbar .bar,.tile [data-a=max],.tsplit{display:none}.ttabs{display:flex;order:-1;flex:1 0 100%;min-width:0;gap:6px;overflow-x:auto;scrollbar-width:none}.ttabs button{flex:none;display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border:1px solid var(--line);border-radius:var(--r-pill);background:none;color:var(--dim);font:inherit;font-size:12px;white-space:nowrap}.ttabs button.on{color:var(--ink);background:var(--hover);border-color:var(--accent)}.tgrid{display:flex;flex-direction:column}.tile{flex:1}.tile.off{display:none}}
288
289
  aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
289
290
  aside.docked>header,aside.docked>.body{padding-inline:28px}
290
291
  aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}