SLOPSHOPPER

beads-ui

A Beads task board for the Claude Code TUI: /show-beads-ui opens a pane over the Beads store the session's work lives in, and a band above the prompt counts…

newpanebandcommandtoastprocess
v0.19.0MITupdated 2026-10-25sergeiwallace/beads-ui/plugins/beads-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · beads-ui
│ ┃ Beads ✕ › fix the failing auth test and add an audit log call │ ┃ [t: Tasks] [1: Board] [2: Bklg] [3: Spr] │ ┃ [5: Time] [6: Set] [7: Rev] ⏺ Read(src/auth.ts) │ ┃ no store ⎿ Read 6 lines │ ┃ ╭──────────────────────────────────────────╮ ⏺ Update(src/auth.ts) │ ┃ │ beads-ui found no Beads store for this │ ⎿ Added 2 lines, removed 1 line │ ┃ │ session. │ ⏺ Bash(bun test) │ ┃ │ - bd where did not say whether /work/app │ ⎿ 3 pass, 1 fail │ ┃ │ has a Beads store │ │ ┃ │ - no beads-ui.toml found (looked for │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ /work/app/beads-ui.toml, │ │ ┃ │ /Users/dev/.config/beads-ui/beads-ui.tom │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ l) │ │ ┃ │ - the store inventory: no store │ › /show-beads-ui │ ┃ │ inventory at │ │ ┃ │ /Users/dev/.beads/shared-server/publish- │ │ ┃ │ sweep.json │ │ ┃ │ To read a store, add this to │ │ ┃ │ /Users/dev/.config/beads-ui/beads-ui.tom │ │ ┃ │ l; the pane reads it on its next │ │ ┃ │ refresh: │ │ ┃ │ [directory_stores] │ │ ┃ │ app = "<the directory of the │ │ ┃ │ repository whose store this session │ │ ┃ │ uses>" │ │ ┃ ╰──────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · beads · while holding a tool call
[t: Tasks] [1: Board] [2: Bklg] [3: Spr] [4: Dtl] [5: Time] [6: Set] [7: Rev] no store ╭──────────────────────────────────────────────────────────╮ │ beads-ui found no Beads store for this session. │ │ - bd where did not say whether /work/app has a Beads │ │ store │ │ - no beads-ui.toml found (looked for │ │ /work/app/beads-ui.toml, │ │ /Users/dev/.config/beads-ui/beads-ui.toml) │ │ - the store inventory: no store inventory at │ │ /Users/dev/.beads/shared-server/publish-sweep.json │ │ To read a store, add this to │ │ /Users/dev/.config/beads-ui/beads-ui.toml; the pane │ │ reads it on its next refresh: │ │ [directory_stores] │ │ app = "<the directory of the repository whose store │ │ this session uses>" │ ╰──────────────────────────────────────────────────────────╯
README

beads-ui

A task manager for Beads issues, designed to run in three places from one shared core. Only the first exists today:

  • inside the Claude Code terminal UI, as a Claude Code mod (implemented);
  • [TODO, not started] inside Claude Desktop and Claude on the web, as an MCP UI app;
  • [TODO, not started] in a browser, as a web app you run locally or host from an infra-as-config template.

The goal is the views people expect from Jira, Linear or Todoist (board, backlog, sprints, roadmap, analytics), working on a Beads store, with one view of the tasks of every Claude Code session on a machine.

Status

Only the Claude Code mod (the TUI plugin) is implemented. Phase 0 (the monorepo bootstrap, the DOM-free shared core and the public-repo hygiene) and Phase 1 (the MVP, a read-mostly board in the mod) have landed. The mod's pane now opens on a task panel: the session's own tasks, a nav to the board, backlog and settings, an options menu for every action and a Settings view (docs/procedures/mod-install.md). Each view is registered once and drawn on the band and the pane alike; [surfaces] in beads-ui.toml, or a view's row in Settings, limits a view to band or pane (docs/procedures/mod-install.md). Everything you can press is drawn between accent-coloured brackets, and the band's pane button is the icon [◨]. The band has three levels: the summary line, every count in it a button; a pressed count's tasks under its line, [band] rows at a time with [+M more], [collapse] and [summary]; and the overview ([◫]), the band's views under a nav from the same registry as the pane's: the Tasks view, the Board, the Backlog, a task's Detail, the Timeline, Settings and the Review view all draw there, with the same actions as in the pane and an options menu for the rest. Its keys are letters (p, b, o, d, t, e, v, m, c, s), and [band] level sets the level it opens at (docs/procedures/mod-install.md). The colours follow your Claude Code theme, and [theme] sets any of them to a hex value; [indicators] ascii = true draws every glyph as ASCII (docs/procedures/mod-install.md). At the summary the band adds a figures row when it has a row to spare: in the terminal a Raster row with a sparkline of the done window and a bar for each status count, and in Claude Desktop the same figures as text (docs/procedures/mod-install.md). The pane's Timeline draws a picture of its dated items: an image where the terminal draws one (kitty, Ghostty, iTerm2 3.5 or later, and inside tmux only with allow-passthrough on), an SVG in Claude Desktop and VS Code, and its alt text everywhere else (docs/procedures/mod-install.md). Every task row, in the band, the Tasks view, the Backlog and on Board cards, starts with a gutter of row indicators: ⟳ while an agent is working on the issue (dimmed when that agent's session is no longer live on the host), ⊘ when it is blocked and ✎ when it has an open review item. The help and Settings list them, and [indicators] or Settings switches each one off (docs/procedures/mod-install.md). With [review] command set in your own beads-ui.toml to a held store's tool (on the fleet, ai-harness's held_cli.py), an issue's Detail opens with a Review section above its description: the held decisions, blockers and other items about the issue that you must review or act on, and its bd blockers. An item opens in a reader with its whole body, where you can answer it; the answer is recorded by the held store's own answer verb and handed to the session. The Review view (key 7 in the pane, [Rev] in the band's overview) lists every held item in the session's scope: the items it holds and those about issues it owns, or, for the firstmate, every item of its machine pair, with a switching to its own. Its filter takes kind:, status:, selection:, session:, machine:, task:, epic:, label:, priority: and free text, opening on status:open selection:current; o sorts by updated, created, priority, task, epic, kind or session, and s groups by task, epic, kind or session. A row whose issue has an open, current item shows ✎, and its options menu opens the first (docs/procedures/mod-install.md).

The MCP UI app for Claude Desktop and claude.ai, and the web app, are [TODO]: neither has been started. They are the planned future state in docs/designs/beads-ui-design.md, and the research behind the design is in docs/research/.

The work is tracked in this repo's own Beads store (prefix BU). Later sub-epics cover iOS, macOS and Linux.

Using the pane

How focus reaches the pane and leaves it, and how to close and reopen it. The pane's help (h) shows the same.

  • Focus enters the pane: Ctrl+X then Tab (again for the next pane, then back to the prompt), a click on it (fullscreen rendering), or the band's pane button, [◨] right after the band's text (click it, or Ctrl+X then Tab to the band and Enter).
  • Focus leaves: Esc returns it to the prompt and closes the pane.
  • Close: Esc, Ctrl+X then X, the band's [⊠] button (drawn beside [◨] while the pane is open), or /bu (also /show-beads-ui).
  • Reopen: /bu, the band's [◨] button, or a key chord bound to command:bu in ~/.claude/keybindings.json, such as this one for Meta+B:
{
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "meta+b": "command:bu"
      }
    }
  ]
}

If ~/.claude/keybindings.json already exists, add the Chat entry to its bindings list rather than replacing the file. The chord opens the pane when it is closed and closes it when it is open; it cannot move the focus into a pane that is already open, which Ctrl+X then Tab and the [◨] button do. Claude Code has no way to hide a pane and keep it open, so hiding it is closing it, and /bu brings it back on the same view. It opens on the Tasks view the first time, and on whichever view you left it on after that.

Repository structure

An npm-workspaces monorepo with one shared core and one package per surface.

The shared core holds five layers: data access, domain logic, view models, commands and design tokens. It has no runtime dependency and no DOM, Node.js or timer reference, so it runs unchanged inside a Claude Code mod, which draws to the terminal and has no DOM. The surfaces share these layers and not their UI components: each surface draws its own views.

PathWhat it holds
plugins/beads-ui/The Claude Code mod, with the shared core inside it
plugins/beads-ui/core/The shared core (src/) and its tests (test/); the DOM packages import it as @beads-ui/core
packages/Node and browser packages: the bd adapter, the web components and the MCP server, as later phases add them
packages/bd-node/The Node adapter that runs bd ... --json for the web and MCP servers and returns the core's types; it needs bd on PATH
scripts/check-core-purity.mjsThe purity gate npm run lint runs on the shared core
.claude-plugin/marketplace.jsonThe marketplace that lists the mod, ./plugins/beads-ui
scripts/check-marketplace.mjsThe pre-commit gate that runs claude plugin validate . and checks the marketplace lists the mod
scripts/check-plugin-size.mjsThe pre-commit gate that fails naming any file git tracks or stages under plugins/beads-ui over 1 MB, with no directory exempt
scripts/check-plugin-allowlist.mjsThe pre-commit gate that holds the hooks and $ calls claude plugin validate --json reports to plugins/beads-ui/validate-allowlist.json, and every $ call to the hooks module's own file: outside it no $. call and no claude-code engine type, inside it no bare $ handed to a call

The core sits inside the plugin because an installed plugin cannot reach files above its own directory. The design doc records when the core moves to its own package.

Development

npm ci
npm run lint
npm test

See CONTRIBUTING.md for the rules on the shared core and on secrets.

To load the mod from a checkout, run claude --plugin-dir plugins/beads-ui. To install it from this repository's marketplace (the mod needs Claude Code 2.1.299 or later):

claude plugin marketplace add sergeiwallace/beads-ui
claude plugin install beads-ui@beads-ui

Then run /show-beads-ui (or /bu) in a repository with a Beads store. docs/procedures/mod-install.md covers the keys, quick-add, configuration and how to report a bug.

Open source

This repository is public and MIT licensed. It holds no secrets, credentials or private data, and nothing of that kind may be committed to it. A gitleaks pre-commit hook scans every commit and refuses one that matches a secret pattern. Copy .env.example to .env for local settings; .env is gitignored. Report a vulnerability as SECURITY.md describes.

Repository setup

The scaffold comes from a Copier template that is private, so copier update works only for the maintainer; .copier-answers.yml records the answers it was generated with. Contributors need neither. Git hooks install with bash .githooks/install.sh (needs pre-commit).

License

MIT

Source 74 files
hooks/register.ts 265 lines
1import type { On } from "claude-code";
2
3import { isTaskPanelRetired, pictureForm } from "../core/src/index.ts";
4import { BandSettingsPanel } from "./bandSettings.ts";
5import { drawBand } from "./bands.ts";
6import type { SurfacePicture } from "./components/adapters.ts";
7import { CONFIRM_ID, DeleteConfirm } from "./confirm.ts";
8import type { Host } from "./host.ts";
9import { BandLevels } from "./levels.ts";
10import { BeadsPane, PANE_ID, type TasksRegion } from "./pane.ts";
11import { TASKS_CLIENT_KEY } from "./panel.ts";
12import { StoreResolver } from "./store.ts";
13import { readStream } from "./stream.ts";
14import { BandTally, EditSync } from "./tally.ts";
15import { readTerminal } from "./terminal.ts";
16
17export const PLUGIN_NAME = "beads-ui";
18export const COMMAND_NAME = "show-beads-ui";
19/** D-7 (a): the short alias. The command spec has no alias field, so it is a second command. */
20export const ALIAS_NAME = "bu";
21export { CONFIRM_ID, PANE_ID };
22export const OPEN_FAILED_TEXT = "beads-ui could not open its pane";
23
24const COMMAND_SPECS = [
25  {
26    name: COMMAND_NAME,
27    description: "Open or close the beads-ui board for this session's Beads store",
28  },
29  { name: ALIAS_NAME, description: "Open or close the beads-ui board (short for /show-beads-ui)" },
30] as const;
31
32/**
33 * Registers `/show-beads-ui` and `/bu`, the pane they toggle, its delete confirmation, the band above
34 * the prompt and the publish after each edit (T-2B.6). Every `$` call of the plugin sits in this file; the helpers get a Host of plain
35 * closures, never `$`.
36 *
37 * @param on the engine's registrar
38 */
39export function register(on: On) {
40  const stores = new StoreResolver();
41  const bandSettings = new BandSettingsPanel(stores);
42  const pane = new BeadsPane(stores, bandSettings);
43  const tally = new BandTally(stores, bandSettings);
44  const levels = new BandLevels(tally, pane, () => stores.sessionName);
45  const sync = new EditSync(stores);
46  tally.sync = sync.state;
47  sync.onChange = () => tally.syncChanged();
48  bandSettings.onChange = () => void tally.settingsChanged();
49  // Each write the pane or the confirmation makes names the store it ran in: that store is
50  // woken and published, even when the pane has re-resolved its store since.
51  const wrote = (store: string) => {
52    tally.afterWrite(store);
53    sync.wrote(store);
54  };
55  pane.onWrite = wrote;
56  const confirm = new DeleteConfirm(
57    (text, undo, store) => {
58      pane.afterConfirm(text, undo);
59      wrote(store);
60    },
61    () => void pane.resume(),
62    () => pane.storePath(),
63  );
64  pane.onDelete = (issue, issues) => {
65    void pane
66      .suspend()
67      .then(() => confirm.open(issue, issues))
68      .catch(() => undefined);
69  };
70
71  on("session.start", async ($, e, next) => {
72    const host: Host = {
73      cwd: e.cwd,
74      run: (argv, timeoutMs) =>
75        $.process.run(argv, { cwd: e.cwd, timeoutMs, env: { NO_COLOR: "1" } }),
76      runIn: (cwd, argv, timeoutMs) =>
77        $.process.run(argv, { cwd, timeoutMs, env: { NO_COLOR: "1" } }),
78      sessionEnv: async () => ({
79        taskListId: await $.env.get("CLAUDE_CODE_TASK_LIST_ID"),
80        kind: await $.env.get("CLAUDE_CODE_SESSION_KIND"),
81        backend: await $.env.get("CLAUDE_BG_BACKEND"),
82        name: await $.env.get("CLAUDE_CODE_SESSION_NAME"),
83      }),
84      stream: (cwd, argv, timeoutMs) =>
85        readStream($.process.spawn({ argv, cwd, env: { NO_COLOR: "1" } }), timeoutMs, (ms, fn) =>
86          $.clock.after(ms, fn),
87        ),
88      now: () => $.clock.now(),
89      every: (ms, fn) => $.clock.every(ms, fn),
90      after: (ms, fn) => $.clock.after(ms, fn),
91      storeGet: (key) => $.store.get(key),
92      storeSet: (key, value) => $.store.set(key, value),
93      readFile: (path) => $.fs.read(path),
94      writeFile: (path, text) => $.fs.write(path, text),
95      pluginRoot: $.plugin.root,
96      stateHome: () => $.env.get("XDG_STATE_HOME"),
97      configEnv: async () => ({
98        xdgConfigHome: await $.env.get("XDG_CONFIG_HOME"),
99        home: await $.env.get("HOME"),
100      }),
101      slugBudgetEnv: async () => ({
102        body: await $.env.get("AIH_SLUG_BODY_MAX_CHARS"),
103        child: await $.env.get("AIH_SLUG_BODY_MAX_CHARS_CHILD"),
104      }),
105      invalidate: () => $.ui.invalidate("ui.render"),
106      toast: (text) => $.ui.toast(text),
107      notify: (text) => $.ui.notify(text),
108      submit: (text) => $.prompt.submit({ text }),
109      openPane: (args) => $.ui.open(args),
110      closePane: (id) => $.ui.close({ id }),
111      focus: (requestId, key) => $.ui.focus({ requestId, key }),
112      blit: (requestId, key, cells) =>
113        $.ui
114          .blit({ requestId, key, cells })
115          .then((answer: unknown) => (answer as { deny?: unknown } | undefined)?.deny === undefined)
116          .catch(() => false),
117      terminalEnv: async () => ({
118        term: await $.env.get("TERM"),
119        termProgram: await $.env.get("TERM_PROGRAM"),
120        termProgramVersion: await $.env.get("TERM_PROGRAM_VERSION"),
121        tmux: await $.env.get("TMUX"),
122      }),
123    };
124    for (const spec of COMMAND_SPECS) await $.command.register(spec);
125    confirm.host = host;
126    await stores.bind(host);
127    await bandSettings.bind(host);
128    await pane.bind(host);
129    await tally.bind(host);
130    await levels.bind(host);
131    sync.bind(host);
132    // Whether the terminal draws an Image (T-2C.13): read once, the pane drawn again with it.
133    void readTerminal(host)
134      .then((verdict) => {
135        pane.images = verdict;
136        host.invalidate();
137      })
138      .catch(() => undefined);
139    // T-2B.7: the task panel opens at session start under panel.mode. Claude Code's own task
140    // panel is retired when the settings deny TaskCreate by name, as ai-harness's switch does (D-21).
141    if (e.isInteractive) {
142      void $.settings
143        .read({ key: "permissions" })
144        .catch(() => undefined)
145        .then((permissions) => pane.start(isTaskPanelRetired(permissions)))
146        .catch(() => undefined);
147    }
148
149    return next(e);
150  });
151
152  on("command.run", { command: ["show-beads-ui", "bu"] }, async () => {
153    await pane.toggle();
154
155    return {};
156  }).catch(() => ({ text: OPEN_FAILED_TEXT }));
157
158  // A turn the session refused or dropped is sent again once a turn ends (T-2B.6).
159  on("turn.complete", ($, e, next) => {
160    sync.turnComplete((text) => $.prompt.submit({ text }));
161
162    return next(e);
163  });
164
165  on("ui.close", { id: ["beads", "beads-confirm"] }, async (_$, e, next) => {
166    const result = await next(e);
167    if (result.deny === undefined && e.origin.kind !== "plugin") {
168      if (e.id === PANE_ID) pane.closed();
169      else confirm.cancelled();
170    }
171
172    return result;
173  }).catch((_$, e, next) => next(e));
174
175  on("ui.focus", { requestId: "beads" }, (_$, e, next) => {
176    pane.focused(e.element);
177
178    return next(e);
179  }).catch((_$, e, next) => next(e));
180
181  // The band draws on the two surfaces Claude Code 2.1.310 draws the site on, the terminal and
182  // the desktop. Only the terminal draws a Raster (the desktop resolves one that draws nothing),
183  // so the desktop draws the summary's figures as Text (T-2C.11).
184  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
185    if ((e.surface !== "terminal" && e.surface !== "desktop") || e.props.hasSurvey) {
186      return next(e);
187    }
188    const { Box, Text, Button, Input, Select, Markdown, Raster } = await $.ui.resolve(e);
189    const band = drawBand(
190      { Box, Text, Button, Input, Select, Markdown },
191      levels,
192      { columns: e.props.bodyColumns, maxRows: e.props.maxRows },
193      {
194        open: pane.isOpen,
195        onOpen: () => void pane.openOrFocus(),
196        onClose: () => void pane.close(),
197      },
198      e.surface === "terminal" ? { Raster, requestId: e.requestId } : undefined,
199    );
200    if (band === undefined) return next(e);
201
202    return Box({ flexDirection: "column", children: [band, await next(e)] });
203  });
204
205  on("ui.render", { component: "Pane" }, async ($, e, next) => {
206    if (e.requestId !== PANE_ID && e.requestId !== CONFIRM_ID) {
207      return next(e);
208    }
209    if (e.surface === "mobile") {
210      const { Box, Text } = await $.ui.resolve(e);
211      return Box({
212        children: [Text({ children: "beads-ui draws its board on the terminal and desktop." })],
213      });
214    }
215
216    const { Box, Text, Button, Input, Select, Markdown, Code } = await $.ui.resolve(e);
217    const ui = { Box, Text, Button, Input, Select, Markdown, Code };
218    const width = Math.max(20, e.props.bodyColumns - 1);
219    if (e.requestId === CONFIRM_ID) return confirm.draw(ui, width);
220
221    // A Client draws in the terminal and the desktop; VS Code draws none (D-31). Only the
222    // terminal draws a Raster, the Timeline's bars (T-2B.9).
223    const Client =
224      e.surface === "terminal" || e.surface === "desktop"
225        ? (await $.ui.resolve(e)).Client
226        : undefined;
227    const Raster = e.surface === "terminal" ? (await $.ui.resolve(e)).Raster : undefined;
228    // A view's picture (T-2C.13): an Image only where the terminal draws one, an Svg off the
229    // terminal, its alt text elsewhere.
230    const { Image, Svg } = await $.ui.resolve(e);
231    const form = pictureForm(e.surface, pane.images.images);
232    const picture: SurfacePicture =
233      form === "image" ? { form, Image } : form === "svg" ? { form, Svg } : { form: "alt" };
234    const help = Client?.({ key: "hello", module: "../client/hello.ts" });
235    const tasks: TasksRegion | undefined =
236      Client === undefined
237        ? undefined
238        : (props, width, height) =>
239            Client({ key: TASKS_CLIENT_KEY, module: "../client/tasks.ts", props, width, height });
240    // The tier and the short-height rule come from the props, which Claude Code redraws with on
241    // every resize (T-1B.9).
242    const frame = {
243      width,
244      bodyColumns: e.props.bodyColumns,
245      placement: e.props.placement,
246      ...(e.props.scroll === undefined ? {} : { bodyRows: e.props.scroll.bodyRows }),
247    };
248    return pane.draw(ui, frame, help, tasks, Raster, picture);
249  });
250
251  // The task list's Client (T-2B.5): what it posts runs the pane's own actions, and a fault drops
252  // it for the rest of the session, the element tree drawing the list in its place.
253  on("ui.message", { requestId: "beads" }, async (_$, e, next) => {
254    if (e.element === TASKS_CLIENT_KEY) await pane.clientMessage(e.data);
255
256    return next(e);
257  });
258
259  on("ui.fault", { requestId: "beads" }, (_$, e, next) => {
260    pane.clientFault(e.element);
261
262    return next(e);
263  });
264}
265
core/src/index.ts 723 lines
1export {
2  boardReadArgvs,
3  CLOSED_PAGE_SIZE,
4  cascadePreviewArgv,
5  closeArgv,
6  commentAddArgv,
7  commentsArgv,
8  countTasksArgv,
9  deleteArgv,
10  deletePreviewArgv,
11  headArgv,
12  historyArgv,
13  listArgv,
14  moveArgv,
15  readyArgv,
16  reopenArgv,
17  showArgv,
18  statusesArgv,
19  timelineReadArgvs,
20  whereArgv,
21} from "./argv.ts";
22export type {
23  Column,
24  ColumnOptions,
25  ColumnsResult,
26  Swimlane,
27  SwimlaneMode,
28  SwimlaneModeError,
29} from "./columns.ts";
30export {
31  buildColumns,
32  buildSwimlanes,
33  NO_EPIC_LANE_LABEL,
34  nextSwimlaneMode,
35  parseSwimlaneMode,
36  SWIMLANE_MODES,
37  UNASSIGNED_LANE_LABEL,
38} from "./columns.ts";
39export type { Command, CommandScope, KeyBinding } from "./commands.ts";
40export {
41  COMMANDS,
42  commandById,
43  commandForKey,
44  footerHints,
45} from "./commands.ts";
46export type {
47  BandOverrides,
48  BeadsUiConfig,
49  ResolvedBand,
50  ResolvedConfig,
51  ResolvedGlyphs,
52  ResolvedPanel,
53  ResolvedSurfaces,
54  ResolvedSync,
55  ResolvedTheme,
56  TomlError,
57  TomlTable,
58  TomlValue,
59} from "./config.ts";
60export {
61  BAND_TABLE,
62  bandOverridesOf,
63  CONFIG_FILE_NAME,
64  configCandidates,
65  DEFAULT_CONFIG,
66  INDICATORS_TABLE,
67  MIN_REFRESH_SECONDS,
68  PANEL_TABLE,
69  parseToml,
70  REVIEW_TABLE,
71  resolveBand,
72  resolveConfig,
73  resolveGlyphs,
74  resolvePanel,
75  resolveReview,
76  resolveSurfaces,
77  resolveSync,
78  resolveTheme,
79  SURFACES_TABLE,
80  SYNC_TABLE,
81  THEME_TABLE,
82  userConfigPath,
83  withBandTable,
84  withIndicatorsTable,
85  withOverrides,
86  withPanelTable,
87  withSurfacesTable,
88} from "./config.ts";
89export type {
90  Filter,
91  FilterError,
92  FilterKey,
93  FilterState,
94  FilterTerm,
95  ReadySetMissing,
96} from "./filter.ts";
97export {
98  applyFilter,
99  describeFilterError,
100  EMPTY_FILTER,
101  EMPTY_FILTER_STATE,
102  FILTER_KEYS,
103  parseFilter,
104  submitFilter,
105} from "./filter.ts";
106export type { Glyph, GlyphId, GlyphKind } from "./glyphs.ts";
107export { asciiOf, GLYPHS, glyphOf, isTableGlyph } from "./glyphs.ts";
108export type {
109  GutterCell,
110  Indicator,
111  IndicatorContext,
112  IndicatorRead,
113  IndicatorState,
114  LegendLine,
115} from "./indicators.ts";
116export {
117  AGENT_LABEL,
118  AGENT_TITLE,
119  BLANK_CELL,
120  DEFAULT_INDICATORS,
121  detectAgent,
122  detectBlocked,
123  detectReview,
124  EMPTY_READ,
125  GUTTER_TREE_CHARS,
126  gutterCells,
127  gutterColumns,
128  gutterOf,
129  gutterText,
130  INDICATORS,
131  IndicatorRegistry,
132  indicatorRead,
133  isAgentRow,
134  legendOf,
135  legendText,
136  NARROW_GUTTER_COLUMNS,
137} from "./indicators.ts";
138export type { Labels, Tier } from "./layout.ts";
139export {
140  buttonWidth,
141  CARD_FRAME_COLUMNS,
142  CARD_FRAME_TREE_CHARS,
143  groupLayout,
144  isShort,
145  labels,
146  MEDIUM_COLUMNS,
147  MIN_GROUP_COLUMNS,
148  PRESS_CLOSE,
149  PRESS_FRAME_COLUMNS,
150  PRESS_OPEN,
151  PRESS_TREE_CHARS,
152  packRows,
153  SECTION_COLUMNS,
154  SECTION_ROWS,
155  SHORT_ROWS,
156  SPACE,
157  textWidth,
158  tierOf,
159  WIDE_COLUMNS,
160} from "./layout.ts";
161export type {
162  DisplayIdPlan,
163  DisplayIdPlanInput,
164  IdPolicy,
165  IdPolicyError,
166  MintError,
167  MintInput,
168  PendingDisplayId,
169  PendingDisplayIdStep,
170  SlugBudgets,
171} from "./mint.ts";
172export {
173  DEFAULT_ID_POLICY,
174  DEFAULT_SLUG_BUDGETS,
175  displayIdArgv,
176  envBudget,
177  FLEET_TASK_ID_RULE,
178  ID_POLICIES,
179  mintDisplayId,
180  parseIdPolicy,
181  pendingDisplayIdStep,
182  planDisplayId,
183  readPendingDisplayIds,
184  SLUG_BUDGET_ENV,
185  SLUG_SUFFIX_ALPHABET,
186  SLUG_SUFFIX_LENGTH,
187  slugBudgets,
188  slugifyTitle,
189  storeUsesDisplayIds,
190  takenDisplayIds,
191} from "./mint.ts";
192export type {
193  BdArgv,
194  Comment,
195  DeletePreview,
196  Dependency,
197  HistoryEvent,
198  Issue,
199  Priority,
200  RandomSource,
201  Result,
202  Session,
203  SessionState,
204  Sprint,
205  Status,
206  StatusCategory,
207} from "./model.ts";
208export { STATUS_CATEGORIES } from "./model.ts";
209export type { ParseError, ParseResult } from "./parsers.ts";
210export {
211  parseBdComments,
212  parseBdCount,
213  parseBdCreatedId,
214  parseBdDeletePreview,
215  parseBdHistoryEvents,
216  parseBdList,
217  parseBdReady,
218  parseBdShow,
219  parseBdStatuses,
220  parseIssueRows,
221  parseStatusesJson,
222} from "./parsers.ts";
223export type { QuickAdd, QuickAddError } from "./quickadd.ts";
224export { createArgv, parseQuickAdd } from "./quickadd.ts";
225export type { BdWhere, StoreFacts, StoreResolution, StoreSource } from "./resolve.ts";
226export {
227  DIRECTORY_STORES_TABLE,
228  directoryStores,
229  parseBdWhere,
230  resolveStore,
231  sessionPrefix,
232  storeLabel,
233} from "./resolve.ts";
234export type {
235  JoinStore,
236  ReviewIssue,
237  ReviewItem,
238  ReviewJoin,
239  ReviewOutput,
240  ReviewSnapshot,
241} from "./review.ts";
242export {
243  answerArgv,
244  HELD_REVIEW_SCHEMA,
245  heldBase,
246  issuesByTask,
247  joinArgvs,
248  joinedIssueOf,
249  joinItems,
250  joinPlan,
251  parseReviewOutput,
252  parseReviewSnapshot,
253  REVIEW_SNAPSHOT_SCHEMA,
254  REVIEW_TIMEOUT_MS,
255  REVIEW_WAKE_FILE,
256  reviewArgv,
257  reviewFileName,
258  showItemArgv,
259  storeForTask,
260} from "./review.ts";
261export type {
262  ReviewFilter,
263  ReviewFilterError,
264  ReviewFilterKey,
265  ReviewFilterState,
266  ReviewFilterTerm,
267  ReviewGroup,
268  ReviewItemGroup,
269  ReviewScope,
270  ReviewSort,
271} from "./reviewList.ts";
272export {
273  DEFAULT_REVIEW_FILTER,
274  describeReviewFilterError,
275  groupReviewItems,
276  holdsItem,
277  initialReviewFilter,
278  inPair,
279  isReviewGroup,
280  isReviewSort,
281  matchesReview,
282  nextOf,
283  openItemsOf,
284  openReviewIndex,
285  ownsItem,
286  pairMembers,
287  parseReviewFilter,
288  REVIEW_FILTER_KEYS,
289  REVIEW_GROUPS,
290  REVIEW_SELECTIONS,
291  REVIEW_SORT_LABELS,
292  REVIEW_SORTS,
293  reviewUpdatedAt,
294  scopedItems,
295  sortReviewItems,
296  submitReviewFilter,
297} from "./reviewList.ts";
298export type {
299  BoardEntry,
300  BoardSnapshot,
301  FileLease,
302  HostSnapshot,
303  Lease,
304  ReadAnswer,
305  SessionEntry,
306  StoreRead,
307  StoreSnapshot,
308  Supervision,
309} from "./snapshot.ts";
310export {
311  BEAT_MS,
312  BOARD_MAX_BYTES,
313  BOARDS_DIR,
314  boardFileName,
315  boardPath,
316  boardRow,
317  buildOf,
318  DEFAULT_DONE_WINDOW,
319  isRefresherToken,
320  LEASE_DEAD_MS,
321  LEASE_FILE,
322  LEASE_KEY,
323  LEASE_TTL_MS,
324  laterBuild,
325  leaseOf,
326  MANIFEST_PATH,
327  parseBoardSnapshot,
328  parseFileLease,
329  parseSnapshot,
330  refresherRunning,
331  SESSION_LIVE_MS,
332  SESSIONS_DIR,
333  SNAPSHOT_FILE,
334  STALE_MS,
335  START_GRACE_MS,
336  sessionFileName,
337  snapshotFileName,
338  snapshotLive,
339  stateDirOf,
340  storeReads,
341  storeSnapshotOf,
342  supervise,
343  WAKE_DIR,
344  wakeFileName,
345  wakeText,
346  windowMs,
347} from "./snapshot.ts";
348export type { SortDirection, SortError, SortKey, SortSpec } from "./sort.ts";
349export {
350  DEFAULT_SORT,
351  nextSort,
352  parseSort,
353  SORT_CYCLE,
354  SORT_KEYS,
355  sortIssues,
356  sortLabel,
357} from "./sort.ts";
358export type {
359  Finished,
360  PendingSubmit,
361  SubmitAnswer,
362  SyncFailure,
363  SyncRun,
364  SyncSettings,
365  SyncStep,
366} from "./sync.ts";
367export {
368  cleanDetail,
369  DEDUPE_WINDOW_MS,
370  DEFAULT_QUIET_SECONDS,
371  DEFAULT_SYNC_TIMEOUT_SECONDS,
372  DETAIL_MAX_CHARS,
373  dedupeKey,
374  failureOf,
375  fenceFor,
376  inlineText,
377  MAX_SUBMIT_RETRIES,
378  MAX_SYNC_TIMEOUT_SECONDS,
379  notifyText,
380  programName,
381  STORE_TOKEN,
382  SYNC_STEP_TEXT,
383  SYNC_STEPS,
384  SyncState,
385  sessionText,
386  stepOf,
387  syncArgv,
388  syncedText,
389} from "./sync.ts";
390export type { TerminalColor, ThemeOverrides, Token } from "./tokens.ts";
391export {
392  colorFor,
393  isToken,
394  priorityToken,
395  statusToken,
396  TERMINAL_THEME,
397  TOKENS,
398  terminalColor,
399} from "./tokens.ts";
400export type { BacklogOptions, BacklogRow, BacklogView } from "./views/backlog.ts";
401export { BACKLOG_PAGE_SIZE, backlogView } from "./views/backlog.ts";
402export type {
403  BandCounts,
404  BandLevel,
405  BandLine,
406  BandPart,
407  BandRead,
408  BandSegment,
409  BandSettings,
410  BandStore,
411  BandTotal,
412} from "./views/band.ts";
413export {
414  ALL_LABEL,
415  BAND_LABEL,
416  BAND_LEVELS,
417  BAND_PART_LABELS,
418  BAND_PARTS,
419  bandLine,
420  bandReads,
421  bandStores,
422  bandTotal,
423  DEFAULT_BAND_PARTS,
424  DEFAULT_BAND_ROWS,
425  DEFAULT_BAND_SETTINGS,
426  DIRECT_GLYPH,
427  FIRSTMATE_LABEL,
428  isBandLevel,
429  isBandPart,
430  isBandRows,
431  isFirstmate,
432  MAX_BAND_ROWS,
433  PARTIAL_GLYPH,
434  parseStoreInventory,
435  STORE_INVENTORY_FILE,
436  SYNC_FAILED_MARK,
437  sameStore,
438} from "./views/band.ts";
439export type {
440  BoardEvent,
441  BoardPageLimit,
442  BoardState,
443  BoardView,
444  BoardViewOptions,
445  CardView,
446  ColumnHeaderView,
447  LaneView,
448  LoadError,
449  MoveError,
450  PageView,
451  PendingMove,
452} from "./views/board.ts";
453export {
454  BOARD_PAGE_LIMIT,
455  boardView,
456  cellKey,
457  INITIAL_BOARD_STATE,
458  pageOf,
459  planMove,
460  reduceBoard,
461  STALE_WRITE_EXIT,
462  stepPage,
463} from "./views/board.ts";
464export type { CommentRow, CommentsView } from "./views/comments.ts";
465export { COMMENTS_MAX_CHARS, commentsView, parseCommentDraft } from "./views/comments.ts";
466export type {
467  ConfirmEvent,
468  ConfirmPhase,
469  ConfirmState,
470  ConfirmView,
471  DependentView,
472} from "./views/confirm.ts";
473export {
474  confirmView,
475  deleteEnabled,
476  nextCommand,
477  openDeleteConfirm,
478  reduceConfirm,
479} from "./views/confirm.ts";
480export type {
481  DependencyView,
482  DetailEvent,
483  DetailState,
484  DetailView,
485  DetailViewOptions,
486  EditableField,
487  EditError,
488  EventView,
489  FieldEdit,
490  FieldView,
491  MarkdownView,
492} from "./views/detail.ts";
493export {
494  DESCRIPTION_EDIT_MAX_CHARS,
495  DETAIL_EVENT_LIMIT,
496  detailView,
497  EDITABLE_FIELDS,
498  editArgv,
499  fromOneLine,
500  openDetail,
501  parseFieldEdit,
502  reduceDetail,
503  toOneLine,
504} from "./views/detail.ts";
505export type {
506  BandRowsView,
507  BandRowView,
508  BandTask,
509  Disclosure,
510  DisclosureEvent,
511  DisclosureSettings,
512  RowGroup,
513  SummaryPiece,
514} from "./views/disclosure.ts";
515export {
516  bandRowsView,
517  CONTROL_HOTKEYS,
518  COUNT_HOTKEYS,
519  countHotkey,
520  disclosureOf,
521  initialDisclosure,
522  isRowGroup,
523  OVERVIEW_DEFAULT_VIEW,
524  orderTasks,
525  pieceWidth,
526  ROW_GLYPHS,
527  ROW_GROUPS,
528  reduceDisclosure,
529  rowLabelColumns,
530  settleDisclosure,
531  summaryPieces,
532  taskRowView,
533} from "./views/disclosure.ts";
534export type { BandFigures, FigureBar } from "./views/figures.ts";
535export {
536  bandFigures,
537  FIGURE_PARTS,
538  figureCells,
539  MAX_BAR_COLUMNS,
540  SPARK_CELLS,
541  SPARK_GLYPHS,
542  sparkBuckets,
543  sparkLevel,
544} from "./views/figures.ts";
545export type {
546  ActionContext,
547  ClientKey,
548  MenuItem,
549  MenuSection,
550  MenuView,
551  PanelAction,
552  TaskClientMessage,
553  TaskClientProps,
554  TaskClientRow,
555  TaskClientSpan,
556} from "./views/menu.ts";
557export {
558  clientKeys,
559  menuItemText,
560  menuView,
561  panelActions,
562  parseTaskMessage,
563  REVIEW_MENU_COMMAND,
564  taskClientProps,
565} from "./views/menu.ts";
566export type {
567  NavEntry,
568  NavItem,
569  NavKind,
570  NavLayout,
571  PanelView,
572  PlannedView,
573} from "./views/nav.ts";
574export {
575  hotkeyOf,
576  isPanelView,
577  NAV_ENTRIES,
578  navEntries,
579  navKind,
580  navLayout,
581  PANEL_VIEWS,
582  plannedText,
583  SIDE_NAV_COLUMNS,
584  SIDE_NAV_GAP,
585} from "./views/nav.ts";
586export type {
587  CollapseRule,
588  PanelMode,
589  PanelOverrides,
590  PanelSettings,
591  TaskGroup,
592  TaskListOptions,
593  TaskListView,
594  TaskRowView,
595} from "./views/panel.ts";
596export {
597  COLLAPSE_LABELS,
598  COLLAPSE_RULES,
599  DEFAULT_PANEL_SETTINGS,
600  isCollapseRule,
601  isPanelMode,
602  isShownCount,
603  isTaskPanelRetired,
604  MAX_SHOWN,
605  PANEL_MODE_LABELS,
606  PANEL_MODES,
607  panelOverridesOf,
608  panelTasks,
609  parseShownCount,
610  RETIRED_PANEL_TOOL,
611  rowWindow,
612  showsTasks,
613  TASK_GLYPHS,
614  TASK_GROUPS,
615  taskGroupOf,
616  taskListView,
617  withPanelOverrides,
618} from "./views/panel.ts";
619export type {
620  ImageVerdict,
621  PictureForm,
622  TerminalFacts,
623  TimelinePicture,
624} from "./views/picture.ts";
625export {
626  PICTURE_MAX_ITEMS,
627  palettePng,
628  pictureForm,
629  terminalImages,
630  timelinePicture,
631} from "./views/picture.ts";
632export type { RasterCell } from "./views/raster.ts";
633export {
634  base64,
635  decodeRasterCells,
636  hexColor,
637  RASTER_DEFAULT_COLOR,
638  RASTER_TOKEN_COLORS,
639  RASTER_TRACK_COLOR,
640  rasterCells,
641  rasterColorFor,
642} from "./views/raster.ts";
643export type {
644  BlockedTransition,
645  BlockerRow,
646  ReviewListEntry,
647  ReviewListView,
648  ReviewRow,
649  ReviewSection,
650} from "./views/review.ts";
651export {
652  answerTurn,
653  blockerRows,
654  heldPathOf,
655  itemKey,
656  itemsForIssue,
657  latestBlocked,
658  READER_PAGE_CHARS,
659  REVIEW_HEADING,
660  REVIEW_LIST_PAGE_ITEMS,
661  REVIEW_PAGE_ROWS,
662  REVIEW_UNSET_TEXT,
663  REVIEW_VIEW_TITLE,
664  readerPages,
665  reviewListView,
666  reviewSection,
667  slugOf,
668  stampText,
669} from "./views/review.ts";
670export type {
671  IndicatorRow,
672  SettingOption,
673  SettingRow,
674  SettingsView,
675  SurfaceRow,
676} from "./views/settings.ts";
677export { INDICATOR_ROW_PREFIX, SURFACE_ROW_PREFIX, settingsView } from "./views/settings.ts";
678export type { SurfaceId, SurfaceOverrides, SurfaceSetting } from "./views/surfaces.ts";
679export {
680  drawsOn,
681  entriesOn,
682  isSurfaceSetting,
683  SURFACE_IDS,
684  SURFACE_LABELS,
685  SURFACE_SETTINGS,
686  surfaceOf,
687  surfaceOverridesOf,
688} from "./views/surfaces.ts";
689export {
690  asOf,
691  BLOCK_MAX_CHARS,
692  clockStamp,
693  fitBlock,
694  priorityBadge,
695  storeName,
696  truncate,
697} from "./views/text.ts";
698export type {
699  TimelineAxis,
700  TimelineEntry,
701  TimelineItem,
702  TimelineLayout,
703  TimelinePlacement,
704  TimelineRow,
705  TimelineView,
706  TimelineWindow,
707} from "./views/timeline.ts";
708export {
709  barCells,
710  barRun,
711  DUE_COLUMNS,
712  dayOf,
713  isTimelineIssue,
714  RASTER_COLORS,
715  RASTER_MAX_COLUMNS,
716  TIMELINE_TYPES,
717  timelineLayout,
718  timelinePlacement,
719  timelineView,
720} from "./views/timeline.ts";
721export type { UndoClose, UndoView } from "./views/undo.ts";
722export { CLOSED_STATUS, undoArgv, undoClose, undoForMove, undoView } from "./views/undo.ts";
723
hooks/bandSettings.ts 315 lines
1import type { RenderElement, RenderNode } from "claude-code";
2
3import {
4  BAND_LEVELS,
5  BAND_PART_LABELS,
6  BAND_PARTS,
7  type BandLevel,
8  type BandOverrides,
9  type BandPart,
10  type BandSettings,
11  bandOverridesOf,
12  configCandidates,
13  glyphOf,
14  isBandRows,
15  MAX_BAND_ROWS,
16  resolveBand,
17  type Token,
18  windowMs,
19  withBandTable,
20  withOverrides,
21} from "../core/src/index.ts";
22import type { Host } from "./host.ts";
23import type { PaneUi } from "./pane.ts";
24import type { StoreResolver } from "./store.ts";
25
26/** Where the pane keeps band settings changed in it until Save to file writes them. */
27export const BAND_SETTINGS_KEY = "band-settings";
28
29/**
30 * The band's settings (T-1B.11): `beads-ui.toml`'s `[band]` table, read at call time, with the
31 * changes made in the pane's band settings over it. A change applies at once in this session and
32 * at the next minute in every other (`$.store` is the machine's), and Save to file writes the
33 * result into the config file and clears the held changes, so later edits to the file show.
34 */
35export class BandSettingsPanel {
36  host: Host | undefined;
37  isOpen = false;
38  overrides: BandOverrides = {};
39  notice: { text: string; token: Token } | undefined;
40  /** The band redraws with the new settings; register.ts wires it to the tally. */
41  onChange: (() => void) | undefined;
42  private problems: string[] = [];
43  private shown: BandSettings | undefined;
44
45  constructor(private readonly stores: StoreResolver) {}
46
47  async bind(host: Host): Promise<void> {
48    this.host = host;
49    await this.reload();
50  }
51
52  private async reload(): Promise<void> {
53    this.overrides = bandOverridesOf(
54      await this.host?.storeGet(BAND_SETTINGS_KEY).catch(() => undefined),
55    );
56  }
57
58  /** The settings the band draws with now: the file's, read from `text`, under the pane's. */
59  async current(text: string | undefined): Promise<BandSettings> {
60    await this.reload();
61    const resolved = resolveBand(text);
62    this.problems = resolved.problems;
63    this.shown = withOverrides(resolved.settings, this.overrides);
64    return this.shown;
65  }
66
67  toggle(): void {
68    this.isOpen = !this.isOpen;
69    this.notice = undefined;
70    this.host?.invalidate();
71  }
72
73  private async change(next: BandOverrides): Promise<void> {
74    this.overrides = { ...this.overrides, ...next };
75    this.notice = undefined;
76    await this.host?.storeSet(BAND_SETTINGS_KEY, this.overrides).catch(() => undefined);
77    this.onChange?.();
78    this.host?.invalidate();
79  }
80
81  private partsNow(): BandPart[] {
82    return [...(this.shown?.parts ?? [])];
83  }
84
85  /** Adds a part at the end of the band, or removes it. */
86  async togglePart(part: BandPart): Promise<void> {
87    const parts = this.partsNow();
88    await this.change({
89      parts: parts.includes(part) ? parts.filter((one) => one !== part) : [...parts, part],
90    });
91  }
92
93  /** Moves a drawn part one place left (`-1`) or right (`1`) on the band. */
94  async movePart(part: BandPart, delta: -1 | 1): Promise<void> {
95    const parts = this.partsNow();
96    const at = parts.indexOf(part);
97    const to = at + delta;
98    if (at === -1 || to < 0 || to >= parts.length) return;
99    parts.splice(at, 1);
100    parts.splice(to, 0, part);
101    await this.change({ parts });
102  }
103
104  async setWindow(text: string): Promise<void> {
105    const window = text.trim();
106    if (windowMs(window) === undefined) {
107      this.notice = {
108        text: `${JSON.stringify(window)} is not a window such as 24h, 90m, 7d or 2w`,
109        token: "error",
110      };
111      this.host?.invalidate();
112      return;
113    }
114    await this.change({ doneWindow: window });
115  }
116
117  /** The rows a press on a count draws at a time (D-36); refuses a number it cannot use. */
118  async setRows(text: string): Promise<void> {
119    const trimmed = text.trim();
120    const rows = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
121    if (!isBandRows(rows)) {
122      this.notice = {
123        text: `${JSON.stringify(trimmed)} is not a whole number from 1 to ${MAX_BAND_ROWS}`,
124        token: "error",
125      };
126      this.host?.invalidate();
127      return;
128    }
129    await this.change({ rows });
130  }
131
132  /** The level the band opens at (D-36). */
133  async setLevel(level: BandLevel): Promise<void> {
134    await this.change({ level });
135  }
136
137  /** Debug mode (T-1B.5, T-2B.8): the band shows its read time. */
138  async setDebug(on: boolean): Promise<void> {
139    await this.change({ debug: on });
140  }
141
142  /** The band's settings as last drawn, before the band has drawn them the defaults. */
143  get settings(): BandSettings | undefined {
144    return this.shown;
145  }
146
147  /**
148   * Save to file: writes the band's settings as they draw into the config file the mod reads
149   * (the user's own when there is none yet), keeping every other line of it, then clears the
150   * changes held in `$.store`. `also` rewrites the same text first, as the Settings view does to
151   * save the panel's table in the same write; true when the file was written.
152   */
153  async save(also?: (text: string | undefined) => string): Promise<boolean> {
154    const host = this.host;
155    if (host === undefined) return false;
156    const { config } = await this.stores.resolve();
157    const settings = await this.current(config.text);
158    const path =
159      config.path ??
160      configCandidates(host.cwd, {
161        xdgConfigHome: this.stores.xdgConfigHome,
162        home: this.stores.home,
163      }).at(-1);
164    if (path === undefined) {
165      this.notice = { text: "no config file to save to: HOME is not set", token: "error" };
166      host.invalidate();
167      return false;
168    }
169    const text = await host.readFile(path).catch(() => undefined);
170    const base = also === undefined ? text : also(text);
171    try {
172      await host.writeFile(path, withBandTable(base, settings));
173    } catch (reason) {
174      const why = reason instanceof Error ? reason.message : String(reason);
175      this.notice = { text: `could not write ${path}: ${why}`, token: "error" };
176      host.invalidate();
177      return false;
178    }
179    this.overrides = {};
180    await host.storeSet(BAND_SETTINGS_KEY, {}).catch(() => undefined);
181    this.notice = {
182      text: `saved the ${also === undefined ? "band" : "panel and band"} settings to ${path}`,
183      token: "success",
184    };
185    this.onChange?.();
186    host.invalidate();
187    return true;
188  }
189
190  /**
191   * The band's settings as controls. `saveButton` false leaves out Save to file, for the Settings
192   * view, whose own Save to file writes these with the panel's.
193   */
194  draw(
195    ui: PaneUi,
196    color: (token: Token) => string,
197    options: { saveButton?: boolean } = {},
198  ): RenderElement {
199    return ui.Box({
200      key: "band:settings",
201      flexDirection: "column",
202      borderStyle: "round",
203      paddingX: 1,
204      children: this.lines(ui, color, options),
205    });
206  }
207
208  /**
209   * The band's settings as lines of one row each: the Settings view's band form draws them in
210   * its window (T-2C.8), and `draw` frames them.
211   */
212  lines(
213    ui: Pick<PaneUi, "Box" | "Text" | "Button" | "Input">,
214    color: (token: Token) => string,
215    { saveButton = true }: { saveButton?: boolean } = {},
216  ): RenderNode[] {
217    const { Box, Text, Button, Input } = ui;
218    const settings = this.shown;
219    const parts = settings?.parts ?? [];
220    const hidden = BAND_PARTS.filter((part) => !parts.includes(part));
221    const row = (part: BandPart, drawn: boolean) =>
222      Box({
223        key: `band:part:${part}`,
224        flexDirection: "row",
225        gap: 1,
226        children: present<RenderNode>([
227          Button({
228            key: `band:toggle:${part}`,
229            label: drawn ? "remove" : "add",
230            plain: true,
231            onPress: () => void this.togglePart(part),
232          }),
233          drawn
234            ? Button({
235                key: `band:left:${part}`,
236                label: glyphOf("move.earlier"),
237                plain: true,
238                onPress: () => void this.movePart(part, -1),
239              })
240            : undefined,
241          drawn
242            ? Button({
243                key: `band:right:${part}`,
244                label: glyphOf("move.later"),
245                plain: true,
246                onPress: () => void this.movePart(part, 1),
247              })
248            : undefined,
249          Text({
250            ...(drawn ? {} : { dimColor: true }),
251            children: `${part}: ${BAND_PART_LABELS[part]}`,
252          }),
253        ]),
254      });
255    return present<RenderNode>([
256      Text({ bold: true, children: "Band settings (every session on this machine)" }),
257      ...this.problems.map((problem) => Text({ color: color("warning"), children: problem })),
258      this.notice === undefined
259        ? undefined
260        : Box({
261            key: "band:notice",
262            children: [Text({ color: color(this.notice.token), children: this.notice.text })],
263          }),
264      Input({
265        key: "band:window",
266        label: "Done window",
267        placeholder: "24h, 90m, 7d or 2w",
268        value: settings?.doneWindow ?? "",
269        submitLabel: "set",
270        onSubmit: (value) => void this.setWindow(value),
271      }),
272      Input({
273        key: "band:rows",
274        label: "Rows per press",
275        placeholder: `1 to ${MAX_BAND_ROWS}`,
276        value: String(settings?.rows ?? ""),
277        submitLabel: "set",
278        onSubmit: (value) => void this.setRows(value),
279      }),
280      Box({
281        key: "band:levels",
282        flexDirection: "row",
283        columnGap: 1,
284        children: [
285          Text({ children: "Opens at:" }),
286          ...BAND_LEVELS.map((level) =>
287            Button({
288              key: `band:level:${level}`,
289              label: level,
290              plain: true,
291              ...(settings?.level === level ? {} : { dimColor: true }),
292              onPress: () => void this.setLevel(level),
293            }),
294          ),
295        ],
296      }),
297      Text({ children: "On the band, in order:" }),
298      ...parts.map((part) => row(part, true)),
299      hidden.length === 0 ? undefined : Text({ children: "Not drawn:" }),
300      ...hidden.map((part) => row(part, false)),
301      saveButton
302        ? Button({
303            key: "band:save",
304            label: "Save to file",
305            plain: true,
306            onPress: () => void this.save(),
307          })
308        : undefined,
309    ]);
310  }
311}
312
313const present = <T>(items: readonly (T | undefined)[]): T[] =>
314  items.filter((item): item is T => item !== undefined);
315
hooks/bands.ts 36 lines
1import type { RenderElement } from "claude-code";
2
3import type { SurfaceUi } from "./components/adapters.ts";
4import type { BandLevels, BandRaster } from "./levels.ts";
5
6export { BEADS_BUTTON_KEY, EXPAND_KEY, OVERVIEW_KEY, PANE_CLOSE_KEY } from "./levels.ts";
7
8/** The elements the band draws with: every one the `AbovePrompt` site resolves (D-35). */
9export type BandUi = SurfaceUi;
10
11/** What the band's toggle knows of the pane and does to it (D-27, D-43, T-2C.14). */
12export interface PaneToggle {
13  open: boolean;
14  onOpen: () => void;
15  onClose: () => void;
16}
17
18/**
19 * The band above the prompt (T-1B.10, D-36): `[bd] {T} tasks ({P} in progress, {B} blocked, {O}
20 * open, {D} done last {W})` over the issues the session owns, and for a firstmate session the
21 * `[bd][all]` totals over every owner above its own `[fm]` line, every count a button; the
22 * expand, overview and pane controls right after the first line's text, at the left, never at
23 * the line's right end; under the lines, the level the session chose (summary, a count's rows,
24 * or the overview), within `size.columns` and `size.maxRows`. Nothing is drawn until some store
25 * has answered, so a session with no store shows no band.
26 */
27export function drawBand(
28  ui: BandUi,
29  levels: BandLevels,
30  size: { columns: number; maxRows?: number },
31  toggle: PaneToggle,
32  raster?: BandRaster,
33): RenderElement | undefined {
34  return levels.draw(ui, size, toggle, raster);
35}
36
hooks/components/adapters.ts 126 lines
1import type { Elements, RenderElement } from "claude-code";
2
3import type { SurfaceId, TaskClientProps } from "../../core/src/index.ts";
4import { styledUi } from "./look.ts";
5
6// The two adapters of the component layer (D-35, T-2C.1): what each surface can draw, handed to
7// every component, so one component draws a view on the band and on the pane.
8
9/** The elements a component may ask for that a surface can lack; Box, Text and Button are everywhere. */
10export type ElementKind = "Input" | "Select" | "Markdown";
11export const ELEMENT_KINDS: readonly ElementKind[] = ["Input", "Select", "Markdown"];
12
13type Terminal = Elements["terminal"];
14export type SurfaceUi = Pick<Terminal, "Box" | "Text" | "Button"> &
15  Partial<Pick<Terminal, ElementKind>>;
16
17/** The task list's Client region, where the surface mounts one (T-2B.5, D-31). */
18export type TasksRegion = (props: TaskClientProps, width: number, height: number) => RenderElement;
19
20/** The terminal's Raster (T-2B.9): a grid of coloured cells. */
21export type RasterElement = Terminal["Raster"];
22
23/** A picture's element (T-2C.13): real pixels on the terminal, or an Svg off it. */
24export type ImageElement = Terminal["Image"];
25export type SvgElement = Elements["desktop"]["Svg"];
26
27/**
28 * How the surface draws a picture (D-43, T-2C.13), from the surface profile: an Image where the
29 * terminal can show one, an Svg on the desktop, VS Code and mobile, its alt text elsewhere.
30 */
31export type SurfacePicture =
32  | { form: "image"; Image: ImageElement }
33  | { form: "svg"; Svg: SvgElement }
34  | { form: "alt" };
35
36/**
37 * Where the surface scrolls a list itself rather than following the focus (the band's overview,
38 * D-36): the first row drawn, and what moving it to another first row does.
39 */
40export interface SurfaceScroll {
41  readonly start: number;
42  readonly to: (start: number) => void;
43}
44
45/**
46 * A surface's profile and its element constructors: the adapter interface every component draws
47 * through. `columns` is the view's width, `rows` the rows it may take (undefined: as many as it
48 * needs), `short` the short-height rule of T-1B.9, `elements` the kinds of ElementKind it draws.
49 */
50export interface Surface {
51  readonly id: SurfaceId;
52  readonly ui: SurfaceUi;
53  readonly columns: number;
54  readonly rows: number | undefined;
55  readonly short: boolean;
56  readonly elements: ReadonlySet<ElementKind>;
57  /** Where a Client mounts: the task list draws its rows in one. */
58  readonly client?: TasksRegion;
59  /** Where a Raster draws: the Timeline's bars. */
60  readonly raster?: RasterElement;
61  /** How a view's picture draws; none on the band, whose rows are kept for its levels. */
62  readonly picture?: SurfacePicture;
63  /** Where a list scrolls by its own controls, not the focus. */
64  readonly scroll?: SurfaceScroll;
65}
66
67/**
68 * The band's elements, measured with Claude Code 2.1.310's test kit (D-35's as-built note): the
69 * `AbovePrompt` site resolves every terminal element and draws Select, Input and Markdown. A band
70 * handed only Box, Text and Button, as `drawBand`'s is, draws the button forms (`has`).
71 */
72export const BAND_ELEMENTS: ReadonlySet<ElementKind> = new Set(ELEMENT_KINDS);
73/** The pane's: every element kind. */
74export const PANE_ELEMENTS: ReadonlySet<ElementKind> = new Set(ELEMENT_KINDS);
75
76/**
77 * The band adapter: the elements it was handed, its columns and its maxRows, short always, and
78 * where it scrolls a list by its own controls.
79 */
80export function bandSurface(
81  ui: SurfaceUi,
82  size: { columns: number; maxRows?: number },
83  scroll?: SurfaceScroll,
84): Surface {
85  const { Box, Text, Button, Input, Select, Markdown } = styledUi(ui);
86  return {
87    id: "band",
88    ui: {
89      Box,
90      Text,
91      Button,
92      ...(Input === undefined ? {} : { Input }),
93      ...(Select === undefined ? {} : { Select }),
94      ...(Markdown === undefined ? {} : { Markdown }),
95    },
96    columns: size.columns,
97    rows: size.maxRows,
98    short: true,
99    elements: BAND_ELEMENTS,
100    ...(scroll === undefined ? {} : { scroll }),
101  };
102}
103
104/** The pane adapter: every element, the view's columns and rows, its Client, Raster and picture. */
105export function paneSurface(
106  ui: SurfaceUi,
107  size: { columns: number; rows?: number; short: boolean },
108  extras: { client?: TasksRegion; raster?: RasterElement; picture?: SurfacePicture } = {},
109): Surface {
110  return {
111    id: "pane",
112    ui: styledUi(ui),
113    columns: size.columns,
114    rows: size.rows,
115    short: size.short,
116    elements: PANE_ELEMENTS,
117    ...(extras.client === undefined ? {} : { client: extras.client }),
118    ...(extras.raster === undefined ? {} : { raster: extras.raster }),
119    ...(extras.picture === undefined ? {} : { picture: extras.picture }),
120  };
121}
122
123/** True when the surface draws `kind` and was handed its constructor. */
124export const has = (surface: Surface, kind: ElementKind): boolean =>
125  surface.elements.has(kind) && surface.ui[kind] !== undefined;
126
hooks/confirm.ts 225 lines
1import type { ProcessRunResult, RenderElement, RenderNode } from "claude-code";
2
3import {
4  type ConfirmEvent,
5  type ConfirmPhase,
6  type ConfirmState,
7  confirmView,
8  glyphOf,
9  type Issue,
10  nextCommand,
11  openDeleteConfirm,
12  reduceConfirm,
13  type UndoClose,
14} from "../core/src/index.ts";
15import { colorOf, styledUi } from "./components/look.ts";
16import type { Host } from "./host.ts";
17import type { PaneUi } from "./pane.ts";
18
19const MORE = glyphOf("text.more");
20
21export const CONFIRM_ID = "beads-confirm";
22
23const RUN_TIMEOUT_MS = 15_000;
24
25const messageOf = (reason: unknown): string =>
26  reason instanceof Error ? reason.message : String(reason);
27
28// The event each phase's bd command answers with.
29function answerOf(phase: ConfirmPhase, result: ProcessRunResult): ConfirmEvent | undefined {
30  const { exitCode, stdout, stderr } = result;
31  const message = stderr.trim() || stdout.trim();
32  switch (phase) {
33    case "previewing":
34      return { type: "preview-loaded", exitCode, stdout, stderr };
35    case "loading-cascade":
36      return { type: "cascade-preview-loaded", exitCode, stdout };
37    case "rechecking":
38      return { type: "recheck-loaded", exitCode, stdout, stderr };
39    case "closing":
40      return { type: "close-finished", exitCode, message };
41    case "deleting":
42      return { type: "delete-finished", exitCode, message };
43    default:
44      return undefined;
45  }
46}
47
48/**
49 * The delete confirmation (T-1.5, D-13): a focused pane that holds toasts, drives core's state
50 * machine, and runs only the command core names for the phase it is in. `bd delete --force` is
51 * reachable only from core's `deleting` phase, which follows the preview, the re-check and, for
52 * a cascade, the typed display id; Close instead runs `bd close` and never `bd delete`.
53 */
54export class DeleteConfirm {
55  host: Host | undefined;
56  state: ConfirmState | undefined;
57  /** Moves on every phase change, so an answer for a phase already left is dropped. */
58  private epoch = 0;
59  /**
60   * The store directory the confirmation opened on: its preview, its re-check and its write all
61   * run there, and the write is reported there, whatever the pane re-resolves meanwhile.
62   */
63  private dir = "";
64
65  constructor(
66    /** The confirmation wrote in the store at `store`, or closed with nothing written. */
67    private readonly finished: (text: string, undo: UndoClose | undefined, store: string) => void,
68    private readonly dismissed: () => void,
69    /** The directory of the store the pane reads, where bd runs. */
70    private readonly storeDir: () => string,
71  ) {}
72
73  async open(issue: Issue, issues: readonly Issue[]): Promise<void> {
74    const host = this.host;
75    if (host === undefined) return;
76    this.state = openDeleteConfirm(issue, issues);
77    this.dir = this.storeDir();
78    this.epoch++;
79    await host.openPane({
80      id: CONFIRM_ID,
81      title: `Delete ${this.state.confirmToken}?`,
82      focus: true,
83      closeOnEscape: true,
84      holdToasts: true,
85    });
86    host.invalidate();
87    await this.step();
88  }
89
90  /** The person closed the pane (Esc, the close mark) before a write: nothing more runs. */
91  cancelled(): void {
92    const phase = this.state?.phase;
93    if (phase === "closing" || phase === "deleting") return;
94    this.state = undefined;
95    this.dismissed();
96  }
97
98  dispatch(event: ConfirmEvent): void {
99    const before = this.state;
100    if (before === undefined) return;
101    this.state = reduceConfirm(before, event);
102    this.host?.invalidate();
103    if (this.state.phase !== before.phase) {
104      this.epoch++;
105      void this.step();
106    }
107  }
108
109  private async step(): Promise<void> {
110    const state = this.state;
111    const host = this.host;
112    if (state === undefined || host === undefined) return;
113    const argv = nextCommand(state);
114    const epoch = this.epoch;
115    if (argv === undefined) {
116      await this.settle(state);
117      return;
118    }
119    const result = await host.runIn(this.dir, argv, RUN_TIMEOUT_MS).catch(
120      (reason: unknown): ProcessRunResult => ({
121        exitCode: -1,
122        stdout: "",
123        stderr: `timed out or could not start: ${messageOf(reason)}`,
124      }),
125    );
126    if (this.state === undefined || this.epoch !== epoch) return;
127    const answer = answerOf(state.phase, result);
128    if (answer !== undefined) this.dispatch(answer);
129  }
130
131  private async settle(state: ConfirmState): Promise<void> {
132    if (state.phase !== "closed" && state.phase !== "deleted") return;
133    const view = confirmView(state, 200);
134    this.state = undefined;
135    await this.host?.closePane(CONFIRM_ID).catch(() => undefined);
136    this.finished(view.statusLine ?? "", view.undo, this.dir);
137  }
138
139  draw(elements: PaneUi, width: number): RenderElement {
140    const ui = styledUi(elements);
141    const { Box, Text, Button, Input, Code } = ui;
142    const state = this.state;
143    if (state === undefined) {
144      return Box({ children: [Text({ dimColor: true, children: "Nothing to delete." })] });
145    }
146    const view = confirmView(state, width);
147    const running =
148      state.phase === "previewing" ||
149      state.phase === "loading-cascade" ||
150      state.phase === "rechecking";
151    return Box({
152      flexDirection: "column",
153      gap: 1,
154      children: [
155        Box({ key: "confirm-heading", children: [Text({ bold: true, children: view.heading })] }),
156        view.preview === ""
157          ? Box({
158              key: "confirm-preview",
159              children: [Text({ dimColor: true, children: `running bd delete --dry-run${MORE}` })],
160            })
161          : Box({ key: "confirm-preview", children: [Code({ source: view.preview })] }),
162        ...([
163          view.dependentsLine === undefined
164            ? undefined
165            : Box({
166                key: "confirm-dependents",
167                children: [Text({ color: colorOf("warning"), children: view.dependentsLine })],
168              }),
169          view.input === undefined
170            ? undefined
171            : Input({
172                key: "typed-id",
173                label: "Type the id",
174                placeholder: view.input.placeholder,
175                value: view.input.value,
176                submitLabel: "confirm",
177                onInput: (text) => this.dispatch({ type: "typed", text }),
178                onSubmit: (text) => this.dispatch({ type: "typed", text }),
179              }),
180          view.statusLine === undefined
181            ? undefined
182            : Box({
183                key: "confirm-status",
184                children: [Text({ color: colorOf("warning"), children: view.statusLine })],
185              }),
186        ].filter((node) => node !== undefined) as RenderNode[]),
187        Box({
188          key: "confirm-actions",
189          flexDirection: "row",
190          gap: 2,
191          children: [
192            ...(view.closeInstead.enabled
193              ? [
194                  Button({
195                    key: "close-instead",
196                    label: view.closeInstead.label,
197                    hotkey: view.closeInstead.hotkey,
198                    autoFocus: true as const,
199                    onPress: () => this.dispatch({ type: "close-instead" }),
200                  }),
201                ]
202              : []),
203            view.delete.enabled
204              ? Button({
205                  key: "delete",
206                  label: view.delete.label,
207                  hotkey: view.delete.hotkey,
208                  onPress: () => this.dispatch({ type: "delete" }),
209                })
210              : Text({
211                  dimColor: true,
212                  children: running
213                    ? `${view.delete.label}: waiting for bd`
214                    : view.delete.cascade
215                      ? `${view.delete.label}: type the id above to enable`
216                      : `${view.delete.label}: not available`,
217                }),
218            Text({ dimColor: true, children: "Esc cancels" }),
219          ],
220        }),
221      ],
222    });
223  }
224}
225
hooks/host.ts 96 lines
1import type { PaneOpenArgs, ProcessRunResult, Timer } from "claude-code";
2
3import type { Streamed } from "./stream.ts";
4
5/** What the process environment says about which session this is. */
6export interface SessionEnv {
7  /** `CLAUDE_CODE_TASK_LIST_ID`, which `ai c` pins to the session's name. */
8  taskListId: string | undefined;
9  /** `CLAUDE_CODE_SESSION_KIND`: `bg` in a worker Claude Code's daemon runs a session in. */
10  kind: string | undefined;
11  /** `CLAUDE_BG_BACKEND`: `daemon` in that worker. */
12  backend: string | undefined;
13  /** `CLAUDE_CODE_SESSION_NAME`: the name Claude Code gives that worker's own session. */
14  name: string | undefined;
15}
16
17/**
18 * The engine as `session.start` bound it from its `$`: plain closures, each one `$` call in
19 * register.ts, so no helper file ever holds `$` (the pattern Claude Code's own `diff` mod uses).
20 */
21export interface Host {
22  /**
23   * The session's directory: where `bd where` asks for a store and beads-ui.toml is looked for
24   * first. The store the pane reads can be another directory (D-24, hooks/store.ts).
25   */
26  cwd: string;
27  /** `$.process.run` in `cwd`; rejects when bd cannot start or outlives `timeoutMs`. */
28  run: (argv: readonly string[], timeoutMs: number) => Promise<ProcessRunResult>;
29  /** `$.process.run` in a store's directory `cwd`: the pane's store and the band's every store. */
30  runIn: (cwd: string, argv: readonly string[], timeoutMs: number) => Promise<ProcessRunResult>;
31  /** `$.env.get` of the variables the session's name is read from (hooks/store.ts sessionNameOf). */
32  sessionEnv: () => Promise<SessionEnv>;
33  /**
34   * `$.process.spawn` in the store directory `cwd`, read to its end: the whole output, past
35   * `$.process.run`'s 4 MiB. Rejects when bd cannot start, is ended by a signal or outlives
36   * `timeoutMs`.
37   */
38  stream: (cwd: string, argv: readonly string[], timeoutMs: number) => Promise<Streamed>;
39  /** `$.clock.now`. */
40  now: () => Promise<number>;
41  /** `$.clock.every`. */
42  every: (ms: number, fn: () => void) => Timer;
43  /** `$.clock.after`: `fn` once, `ms` from now. */
44  after: (ms: number, fn: () => void) => Timer;
45  /** `$.store.get`: view preferences only, never issue data. */
46  storeGet: (key: string) => Promise<unknown>;
47  /** `$.store.set`. */
48  storeSet: (key: string, value: unknown) => Promise<void>;
49  /** `$.fs.read`; rejects when the file is missing. */
50  readFile: (path: string) => Promise<string>;
51  /** `$.fs.write`, creating the file and its directories; not atomic. */
52  writeFile: (path: string, text: string) => Promise<void>;
53  /** `$.plugin.root`: the plugin's directory, which holds the host refresher. */
54  pluginRoot: string;
55  /** `$.env.get("XDG_STATE_HOME")`, which places the host refresher's state directory. */
56  stateHome: () => Promise<string | undefined>;
57  /** `$.env.get` of the two variables that place the user's config directory. */
58  configEnv: () => Promise<{ xdgConfigHome: string | undefined; home: string | undefined }>;
59  /** `$.env.get` of bd-new's two slug budget overrides, read when an issue is created. */
60  slugBudgetEnv: () => Promise<{ body: string | undefined; child: string | undefined }>;
61  /** `$.ui.invalidate("ui.render")`: the pane, the confirmation and the band draw again. */
62  invalidate: () => void;
63  /** `$.ui.toast`. */
64  toast: (text: string) => void;
65  /**
66   * `$.ui.notify`: a terminal notification. Resolves `{ isSent: true, channel }`, or `{ isSent:
67   * false, reason }` when no channel took it; rejects when the channel's write failed.
68   */
69  notify: (text: string) => Promise<unknown>;
70  /**
71   * `$.prompt.submit`: a turn in the session once it is idle, with Claude Code naming the mod as
72   * its sender. Resolves `{ drop: reason }` when the session refused or dropped it.
73   */
74  submit: (text: string) => Promise<unknown>;
75  /** `$.ui.open`; resolves what the engine answered, `{ isPlaced: false }` when it held the pane. */
76  openPane: (pane: PaneOpenArgs) => Promise<unknown>;
77  /** `$.ui.close`. */
78  closePane: (id: string) => Promise<void>;
79  /** `$.ui.focus`: moves the pane's focus ring onto one of its elements. */
80  focus: (requestId: string, key: string) => Promise<unknown>;
81  /**
82   * `$.ui.blit`: repaints the Raster keyed `key` that the site `requestId` drew, in place, with
83   * `cells`. Resolves false when the engine refused it.
84   */
85  blit: (requestId: string, key: string, cells: string) => Promise<boolean>;
86  /** `$.env.get` of what the terminal says of itself: TERM, TERM_PROGRAM, its version and TMUX. */
87  terminalEnv: () => Promise<TerminalEnv>;
88}
89
90export interface TerminalEnv {
91  term: string | undefined;
92  termProgram: string | undefined;
93  termProgramVersion: string | undefined;
94  tmux: string | undefined;
95}
96
hooks/levels.ts 667 lines
1import type { RenderElement, RenderNode } from "claude-code";
2
3import {
4  type BandFigures,
5  type BandLine,
6  type BandPart,
7  type BandRowView,
8  type BandSettings,
9  type BandTask,
10  bandFigures,
11  bandRowsView,
12  buttonWidth,
13  CONTROL_HOTKEYS,
14  DEFAULT_BAND_SETTINGS,
15  type Disclosure,
16  type DisclosureEvent,
17  disclosureOf,
18  figureCells,
19  glyphOf,
20  gutterColumns,
21  type IndicatorRead,
22  indicatorRead,
23  isRowGroup,
24  orderTasks,
25  plannedText,
26  ROW_GROUPS,
27  type RowGroup,
28  reduceDisclosure,
29  rowLabelColumns,
30  settleDisclosure,
31  sparkBuckets,
32  storeName,
33  summaryPieces,
34  taskRowView,
35  truncate,
36  windowMs,
37} from "../core/src/index.ts";
38import {
39  bandSurface,
40  type RasterElement,
41  type Surface,
42  type SurfaceUi,
43} from "./components/adapters.ts";
44import {
45  aboveNote,
46  controlsRow,
47  countRows,
48  FIGURES_RASTER_KEY,
49  figuresRow,
50  type LevelControls,
51  moreLabel,
52  ROW_INDENT,
53  summaryLine,
54} from "./components/band.ts";
55import { enabledIndicators, gutterFor } from "./components/indicators.ts";
56import { colorOf, isAscii, rasterColorOf } from "./components/look.ts";
57import { bandNav, navBar } from "./components/nav.ts";
58import { textLine } from "./components/window.ts";
59import type { Host } from "./host.ts";
60import type { BeadsPane } from "./pane.ts";
61import type { BandTally } from "./tally.ts";
62
63/** Where each session's chosen level lives in `$.store`, by session name (D-36). */
64export const BAND_LEVEL_KEY = "band-level";
65
66/** The band's pane toggle (D-27, D-43, T-2C.14): its keys. */
67export const BEADS_BUTTON_KEY = "beads-band:open";
68export const PANE_CLOSE_KEY = "beads-band:close";
69export const EXPAND_KEY = "band:expand";
70export const OVERVIEW_KEY = "band:overview";
71
72/** The band's height when the engine names none: BAND_PROPS' 10, the test kit's. */
73const DEFAULT_MAX_ROWS = 10;
74/**
75 * The fewest rows the overview hands a view the framework draws: the Tasks view's count, edges
76 * and "+M more" take 4 of them, so it draws at least one task. Below it, the view's note says so.
77 */
78export const MIN_VIEW_ROWS = 5;
79
80/** The band site's Raster, on the terminal, and the site's id a blit names (T-2C.11). */
81export interface BandRaster {
82  Raster: RasterElement;
83  requestId: string;
84}
85
86/** One count's rows as last read: its tasks, the stores that failed, and the count they match. */
87interface Read {
88  tasks: BandTask[];
89  failed: string[];
90  count: number | undefined;
91  reading: boolean;
92}
93
94const isRecord = (value: unknown): value is Record<string, unknown> =>
95  typeof value === "object" && value !== null && !Array.isArray(value);
96
97const present = <T>(items: readonly (T | undefined | false)[]): T[] =>
98  items.filter((item): item is T => item !== undefined && item !== false);
99
100/**
101 * The band's disclosure levels (D-36, T-2C.2): the summary line, every count a button and the
102 * expand, overview and pane controls right after its text; a count's task rows under its line,
103 * `band.rows` at a time with "+M more", collapse and summary; and the overview, the views the
104 * view registry draws on the band, under a nav generated from it. The level a session last chose
105 * is kept in `$.store` under its name, and a new `band.level` replaces it at the next redraw.
106 */
107export class BandLevels {
108  host: Host | undefined;
109  private kept: Disclosure | undefined;
110  private loaded = false;
111  private readonly reads = new Map<string, Read>();
112  /** The task whose Detail the overview last asked the pane to read. */
113  private asked: string | undefined;
114  /**
115   * The figures row's done tasks (T-2C.11): by store, the done count their close times were
116   * read at and the times, for the line the figures draw. A store is read while its count is
117   * above zero, and again only when the count moves.
118   */
119  private readonly doneTimes = new Map<string, { done: number; times: number[] }>();
120  private readonly doneReading = new Set<string>();
121  /** The figures Raster the last redraw drew: where a blit repaints it, and the cells it holds. */
122  private drawnRaster: { requestId: string; cells: string; key: string } | undefined;
123  /** A blit was sent since that redraw: the next one waits for the next redraw. */
124  private blitted = false;
125  /** The band's columns at the last redraw that drew the figures Raster. */
126  private drawnColumns = 0;
127
128  constructor(
129    private readonly tally: BandTally,
130    private readonly pane: BeadsPane,
131    private readonly session: () => string | undefined,
132  ) {
133    tally.onRecount = (wrote) => this.recount(wrote);
134    pane.onBandOpen = (id) => this.press({ type: "open", id });
135    pane.onBandView = (view) => this.press({ type: "view", view });
136  }
137
138  async bind(host: Host): Promise<void> {
139    this.host = host;
140    const held = await host.storeGet(BAND_LEVEL_KEY).catch(() => undefined);
141    const mine = isRecord(held) ? held[this.session() ?? ""] : undefined;
142    this.kept = disclosureOf(mine);
143    this.loaded = true;
144    this.recount(false);
145  }
146
147  private get settings(): BandSettings {
148    return this.tally.drawnSettings ?? DEFAULT_BAND_SETTINGS;
149  }
150
151  /** The line the expand control and a rows-level start open: the session's own. */
152  private ownLine(): string {
153    const lines = this.tally.lines;
154    return lines.find((line) => line.key === "own")?.key ?? lines[0]?.key ?? "own";
155  }
156
157  /** The state drawn now: the kept one, settled against band.level and band.rows. */
158  state(): Disclosure {
159    const settled = settleDisclosure(this.kept, this.settings, this.ownLine());
160    if (this.loaded && settled !== this.kept) {
161      this.kept = settled;
162      this.save();
163    }
164    return settled;
165  }
166
167  /** A press on the band: the new state, kept for the session, and its rows read. */
168  press(event: DisclosureEvent): void {
169    this.kept = reduceDisclosure(this.state(), event, this.settings);
170    // A task's Detail draws its review items (T-2C.5): it is read, with the host's snapshot, as
171    // it opens.
172    if (event.type === "open") {
173      this.asked = undefined;
174      this.askDetail(event.id);
175      void this.pane.review.load().then(() => this.host?.invalidate());
176    }
177    // Leaving the overview closes the menu and the help it drew.
178    if (event.type === "summary" || event.type === "overview" || event.type === "view") {
179      if (this.pane.menuOn === "band") this.pane.menuOpen = false;
180      this.pane.bandHelp = false;
181    }
182    this.save();
183    this.recount(false);
184    this.host?.invalidate();
185  }
186
187  private save(): void {
188    const host = this.host;
189    const state = this.kept;
190    if (host === undefined || state === undefined) return;
191    const name = this.session() ?? "";
192    void host
193      .storeGet(BAND_LEVEL_KEY)
194      .then((held) =>
195        host.storeSet(BAND_LEVEL_KEY, { ...(isRecord(held) ? held : {}), [name]: state }),
196      )
197      .catch(() => undefined);
198  }
199
200  /** The row groups a state's rows draw: one status, every status for total, none for a store count. */
201  private groupsOf(part: BandPart): RowGroup[] {
202    if (part === "total") return [...ROW_GROUPS];
203    return isRowGroup(part) ? [part] : [];
204  }
205
206  /**
207   * Reads the open count's rows when it has none yet, when its count moved since they were read,
208   * or (`wrote`) after this session's write. Each read redraws the band when it lands.
209   */
210  private recount(wrote: boolean): void {
211    // After a write every read is stale, one under way too, but its tasks stay drawn (and a
212    // detail stays readable) until the next read lands: NaN matches no count.
213    if (wrote) {
214      for (const [key, read] of this.reads) {
215        this.reads.set(key, { ...read, count: Number.NaN, reading: false });
216      }
217    }
218    if (!this.loaded) return;
219    this.readDone();
220    const state = this.state();
221    const wanted = state.level === "rows" ? this.groupsOf(state.part) : [];
222    for (const group of wanted) {
223      const key = `${state.line}:${group}`;
224      const count = this.tally.countOf(state.line, group);
225      const held = this.reads.get(key);
226      if (held !== undefined && (held.reading || held.count === count)) continue;
227      const pending: Read = {
228        tasks: held?.tasks ?? [],
229        failed: held?.failed ?? [],
230        count,
231        reading: true,
232      };
233      this.reads.set(key, pending);
234      void this.tally
235        .readTasks(state.line, group)
236        .then((read) => {
237          // A write since cleared this read, and a newer one is under way: it draws instead.
238          if (this.reads.get(key) !== pending) return;
239          this.reads.set(key, {
240            tasks: read?.tasks ?? [],
241            failed: read?.failed ?? [],
242            count,
243            reading: false,
244          });
245          this.host?.invalidate();
246        })
247        .catch(() => this.reads.delete(key));
248    }
249  }
250
251  /** The line the figures row draws: the band's first, the one its controls follow. */
252  private figuresLine(): string | undefined {
253    return this.tally.lines[0]?.key;
254  }
255
256  /**
257   * Reads the close times of the figures line's done tasks in each store whose done count is
258   * above zero and moved since its times were read (T-2C.11). A store whose count fell to zero
259   * keeps no times. Each read that lands repaints the figures.
260   */
261  private readDone(): void {
262    const key = this.figuresLine();
263    if (key === undefined) return;
264    for (const { store, done } of this.tally.doneByStore(key)) {
265      const id = `${key}:${store.path}`;
266      const held = this.doneTimes.get(id);
267      if (done === 0) {
268        if (held !== undefined) {
269          this.doneTimes.delete(id);
270          this.repaint();
271        }
272        continue;
273      }
274      if (held?.done === done || this.doneReading.has(id)) continue;
275      this.doneReading.add(id);
276      void this.tally
277        .readDoneTimes(key, store)
278        .then((times) => {
279          if (times !== undefined) this.doneTimes.set(id, { done, times });
280          this.repaint();
281        })
282        .catch(() => undefined)
283        .finally(() => this.doneReading.delete(id));
284    }
285  }
286
287  /** The figures of the figures line in `columns`, at the time its counts were read. */
288  private figures(columns: number): BandFigures | undefined {
289    const key = this.figuresLine();
290    const total = key === undefined ? undefined : this.tally.totalOf(key);
291    if (key === undefined || total === undefined) return undefined;
292    const settings = this.settings;
293    const now = this.tally.last?.readAt ?? 0;
294    const times = [...this.doneTimes.entries()]
295      .filter(([id]) => id.startsWith(`${key}:`))
296      .flatMap(([, held]) => held.times);
297    const buckets = sparkBuckets(times, now, windowMs(settings.doneWindow) ?? 0);
298    return bandFigures(total.counts, settings.parts, buckets, {
299      columns,
300      doneWindow: settings.doneWindow,
301    });
302  }
303
304  /** The figures Raster's cells now, in the columns the last redraw drew it at. */
305  private cellsOf(figures: BandFigures): string {
306    return figureCells(figures, rasterColorOf, isAscii());
307  }
308
309  /**
310   * New figures after a read landed: the Raster the last redraw drew is repainted in place
311   * through `$.ui.blit`, at most once until the band redraws (the minute beat, a press or a
312   * write redraws it); with no Raster drawn, or a blit refused, the band redraws.
313   */
314  private repaint(): void {
315    const host = this.host;
316    if (host === undefined) return;
317    const drawn = this.drawnRaster;
318    if (drawn === undefined) {
319      host.invalidate();
320      return;
321    }
322    if (this.blitted) return;
323    const figures = this.figures(this.drawnColumns);
324    const cells = figures === undefined ? undefined : this.cellsOf(figures);
325    if (cells === undefined || cells === drawn.cells) return;
326    this.blitted = true;
327    drawn.cells = cells;
328    void host.blit(drawn.requestId, drawn.key, cells).then((painted) => {
329      if (!painted) host.invalidate();
330    });
331  }
332
333  /**
334   * What a task row's indicators read (D-41): the pane's board when the task is in the pane's
335   * store, which holds every issue of it not closed, and every task the band read in the store.
336   */
337  private readFor(store: string): IndicatorRead {
338    const trim = (path: string) => path.replace(/\/+$/, "");
339    const issues = new Map(
340      trim(store) === trim(this.pane.storePath())
341        ? this.pane.board.issues.map((issue) => [issue.id, issue])
342        : [],
343    );
344    for (const read of this.reads.values()) {
345      for (const task of read.tasks) {
346        if (trim(task.store) === trim(store)) issues.set(task.issue.id, task.issue);
347      }
348    }
349    return indicatorRead([...issues.values()]);
350  }
351
352  /** The rows a state draws under its line, in the Tasks view's order. */
353  private rowsOf(
354    state: Disclosure,
355    width: number,
356    columns: number,
357  ): { items: BandRowView[]; status?: string } {
358    const part = state.part;
359    if (part === "drift") {
360      const read = this.tally.readOf(state.line);
361      const items = (read?.stores ?? [])
362        .filter((store) => !store.synced)
363        .map((store) => ({
364          key: `band-store:${store.path}`,
365          token: "warning" as const,
366          label: truncate(`${store.name}: not in step with the pair (${store.path})`, width),
367        }));
368      return { items };
369    }
370    if (part === "unpublished") {
371      const items = (this.tally.sync?.pendingStores ?? []).map((one) => ({
372        key: `band-store:${one.store}`,
373        token: one.failed ? ("error" as const) : ("accent" as const),
374        label: truncate(
375          `${storeName(one.store)}: ${one.unpublished} unpublished${one.failed ? ", publish failed" : ""}`,
376          width,
377        ),
378      }));
379      return { items };
380    }
381    const groups = this.groupsOf(part);
382    const held = groups.map((group) => this.reads.get(`${state.line}:${group}`));
383    const byGroup: Partial<Record<RowGroup, BandTask[]>> = {};
384    groups.forEach((group, i) => {
385      byGroup[group] = held[i]?.tasks ?? [];
386    });
387    const storeReads = new Map<string, IndicatorRead>();
388    const readOf = (store: string) => {
389      const read = storeReads.get(store) ?? this.readFor(store);
390      storeReads.set(store, read);
391      return read;
392    };
393    const labelWidth = Math.max(1, width - gutterColumns(enabledIndicators(), columns));
394    const items = orderTasks(byGroup).map((task) => ({
395      ...taskRowView(task, labelWidth),
396      gutter: gutterFor(task.issue, readOf(task.store), columns),
397    }));
398    const failed = [...new Set(held.flatMap((read) => read?.failed ?? []))];
399    const reading = held.some((read) => read === undefined || read.reading);
400    const status =
401      reading && items.length === 0
402        ? `reading the tasks${glyphOf("text.more")}`
403        : failed.length > 0
404          ? `not read from ${failed.join(", ")}`
405          : items.length === 0
406            ? "no tasks"
407            : undefined;
408    return status === undefined ? { items } : { items, status: truncate(status, width) };
409  }
410
411  /** The issue a detail is about: from the rows read, else from the pane's board. */
412  private issueOf(id: string): BandTask | undefined {
413    for (const read of this.reads.values()) {
414      const task = read.tasks.find((one) => one.issue.id === id);
415      if (task !== undefined) return task;
416    }
417    const issue = this.pane.board.issues.find((one) => one.id === id);
418    return issue === undefined ? undefined : { issue, store: this.pane.storePath() };
419  }
420
421  /**
422   * The band, at the level the session chose, within `columns` and `maxRows`. Nothing is drawn
423   * until the tally has lines, so a session with no store shows no band.
424   */
425  draw(
426    elements: SurfaceUi,
427    size: { columns: number; maxRows?: number },
428    toggle: { open: boolean; onOpen: () => void; onClose: () => void },
429    raster?: BandRaster,
430  ): RenderElement | undefined {
431    this.drawnRaster = undefined;
432    this.blitted = false;
433    const lines = this.tally.lines;
434    if (lines.length === 0) return undefined;
435    const columns = Math.max(1, size.columns);
436    const maxRows = Math.max(1, size.maxRows ?? DEFAULT_MAX_ROWS);
437    const state = this.state();
438    const surface = bandSurface(elements, { columns, maxRows });
439    const { Box, Button } = surface.ui;
440    const controls = [
441      Button({
442        key: EXPAND_KEY,
443        label: glyphOf("band.expand"),
444        hotkey: CONTROL_HOTKEYS.expand,
445        plain: true,
446        onPress: () => this.press({ type: "expand", line: this.ownLine() }),
447      }),
448      Button({
449        key: OVERVIEW_KEY,
450        label: glyphOf("band.overview"),
451        hotkey: CONTROL_HOTKEYS.overview,
452        plain: true,
453        onPress: () => this.press({ type: "overview" }),
454      }),
455      Button({ key: BEADS_BUTTON_KEY, label: glyphOf("pane.open"), onPress: toggle.onOpen }),
456      ...(toggle.open
457        ? [Button({ key: PANE_CLOSE_KEY, label: glyphOf("pane.close"), onPress: toggle.onClose })]
458        : []),
459    ];
460    const controlColumns =
461      buttonWidth(glyphOf("band.expand"), CONTROL_HOTKEYS.expand) +
462      buttonWidth(glyphOf("band.overview"), CONTROL_HOTKEYS.overview) +
463      buttonWidth(glyphOf("pane.open")) * (toggle.open ? 2 : 1) +
464      controls.length;
465    const drawLine = (line: BandLine, i: number) => {
466      const room = i === 0 ? columns - controlColumns : columns;
467      const pieces = summaryPieces(line, Math.max(0, room));
468      const text = summaryLine(surface, line, pieces, {
469        color: colorOf,
470        press: (part) => this.press({ type: "count", line: line.key, part }),
471      });
472      if (i > 0) return text;
473      return Box({
474        key: "beads-band:first",
475        flexDirection: "row",
476        columnGap: 1,
477        children: [text, ...controls],
478      });
479    };
480    const drawnLines = lines.slice(0, maxRows);
481    const left = maxRows - drawnLines.length;
482    const blocks: RenderNode[] = drawnLines.map(drawLine);
483    if (state.level === "rows" && left >= 1) {
484      const at = Math.max(
485        0,
486        drawnLines.findIndex((line) => line.key === state.line),
487      );
488      blocks.splice(at + 1, 0, this.drawRows(surface, state, columns, left));
489    } else if (state.level === "overview" && left >= 1) {
490      blocks.push(this.drawOverview(elements, state, columns, left));
491    } else if (state.level === "summary" && left >= 1) {
492      const row = this.drawFigures(surface, columns, raster);
493      if (row !== undefined) blocks.push(row);
494    }
495    return Box({ key: "beads-band", flexDirection: "column", children: blocks });
496  }
497
498  /**
499   * The summary level's figures row (D-43, T-2C.11): one Raster row where the band has a Raster,
500   * else the same figures as Text. The Raster drawn is recorded, so a read landing before the
501   * next redraw repaints it through `$.ui.blit`.
502   */
503  private drawFigures(
504    surface: Surface,
505    columns: number,
506    raster: BandRaster | undefined,
507  ): RenderElement | undefined {
508    const figures = this.figures(columns);
509    if (figures === undefined) return undefined;
510    const doneWindow = this.settings.doneWindow;
511    if (raster === undefined) return figuresRow(surface, figures, { color: colorOf, doneWindow });
512    const cells = this.cellsOf(figures);
513    this.drawnColumns = columns;
514    this.drawnRaster = { requestId: raster.requestId, key: FIGURES_RASTER_KEY, cells };
515    return figuresRow(surface, figures, {
516      color: colorOf,
517      doneWindow,
518      raster: { Raster: raster.Raster, cells },
519    });
520  }
521
522  /** The rows level: the open count's rows under its line and their controls, in `budget` rows. */
523  private drawRows(
524    surface: Surface,
525    state: Disclosure,
526    columns: number,
527    budget: number,
528  ): RenderElement {
529    const settings = this.settings;
530    const width = rowLabelColumns(columns, ROW_INDENT);
531    const { items, status } = this.rowsOf(state, width, columns);
532    const rowBudget = budget - 1 - (status === undefined ? 0 : 1);
533    const view = bandRowsView(items, {
534      shown: state.shown,
535      rows: settings.rows,
536      budget: rowBudget,
537    });
538    const controls: LevelControls = {
539      ...(view.more > 0
540        ? { more: { label: moreLabel(view.more), press: () => this.press({ type: "more" }) } }
541        : {}),
542      ...(view.collapsible ? { collapse: () => this.press({ type: "collapse" }) } : {}),
543      summary: () => this.press({ type: "summary" }),
544      ...(view.above > 0 ? { note: aboveNote(view.above) } : {}),
545    };
546    if (rowBudget < 0) return controlsRow(surface, "band-rows:controls", controls);
547    return countRows(surface, view, {
548      color: colorOf,
549      open: (id) => this.press({ type: "open", id }),
550      ...(status === undefined || rowBudget < 1 ? {} : { status }),
551      controls,
552    });
553  }
554
555  /**
556   * The overview (D-36, T-2C.8): the nav the view registry gives the band, the chosen view
557   * drawn through the band adapter from its registry entry, its controls (the New and Filter
558   * fields) over it, the pane's last notice, and the controls row with the view's options
559   * menu, all in `budget` rows. A pressed task opens the Detail view on it. With the menu or the
560   * help open, it draws in place of the view.
561   */
562  private drawOverview(
563    elements: SurfaceUi,
564    state: Disclosure,
565    columns: number,
566    budget: number,
567  ): RenderElement {
568    const pane = this.pane;
569    const entries = pane.viewsOn("band");
570    const active = state.detail !== undefined ? "detail" : state.view;
571    if (state.detail !== undefined) this.askDetail(state.detail);
572    const nav = bandNav(columns, entries, active);
573    const plain = bandSurface(elements, { columns, maxRows: budget });
574    const menuOpen = pane.menuOpen && pane.menuOn === "band";
575    const controls = controlsRow(plain, "band-overview:controls", {
576      summary: () => this.press({ type: "summary" }),
577      ...(state.detail === undefined ? {} : { back: () => this.press({ type: "back" }) }),
578      menu: {
579        open: menuOpen,
580        press: () => void pane.toggleMenu(undefined, undefined, "band", active),
581      },
582    });
583    const { Box, Text, Button } = plain.ui;
584    const entry = entries.find((one) => one.view === active);
585    const sized = (rows: number) =>
586      bandSurface(
587        elements,
588        { columns, maxRows: rows },
589        { start: state.start, to: (start) => this.press({ type: "scroll", start }) },
590      );
591    const room = budget - nav.rows.length - 1;
592    const notice = pane.notice;
593    const above: RenderNode[] = present<RenderNode>([
594      notice === undefined
595        ? undefined
596        : textLine(plain, "band-overview:notice", notice.text, { token: notice.token }),
597      ...(menuOpen || pane.bandHelp ? [] : (entry?.controls?.(sized(room)) ?? [])),
598    ]);
599    // The view's own rows come first: at a height that leaves the view too few, the notice and
600    // the fields give theirs up.
601    const kept = room - above.length >= MIN_VIEW_ROWS ? above : [];
602    const bodyRows = room - kept.length;
603    if (bodyRows < 1) {
604      return Box({ key: "beads-band:overview", flexDirection: "column", children: [controls] });
605    }
606    const surface = sized(bodyRows);
607    const note = (text: string, action?: { key: string; label: string; press: () => void }) =>
608      Box({
609        key: "band-overview:note",
610        flexDirection: "column",
611        children: present<RenderNode>([
612          Text({ dimColor: true, wrap: "truncate-end", children: truncate(text, columns) }),
613          action === undefined || bodyRows < 2
614            ? undefined
615            : Button({ key: action.key, label: action.label, plain: true, onPress: action.press }),
616        ]),
617      });
618    const inPane = (view: string, label: string) => ({
619      key: `band-pane:${view}`,
620      label: `${glyphOf("pane.open")} open ${label} in the pane`,
621      press: () => {
622        pane.setTab(view);
623        void pane.openOrFocus();
624      },
625    });
626    let body: RenderNode;
627    if (entry === undefined) {
628      body = note("This view is not drawn on the band: Settings' surfaces keep it to the pane.");
629    } else if (entry.planned || entry.draw === undefined) {
630      body = note(plannedText(entry.view as "sprint"));
631    } else if (bodyRows < MIN_VIEW_ROWS) {
632      body = note(
633        `${entry.labels.wide} needs ${MIN_VIEW_ROWS} of the band's rows and has ${bodyRows} here.`,
634        inPane(entry.view, entry.labels.wide),
635      );
636    } else if (menuOpen) {
637      body = pane.drawBandMenu(surface, entry.view);
638    } else if (pane.bandHelp) {
639      body = pane.drawHelp(surface);
640    } else {
641      body = entry.draw(surface);
642    }
643    return Box({
644      key: "beads-band:overview",
645      flexDirection: "column",
646      children: [
647        navBar(plain, nav, (item) => this.press({ type: "view", view: item.view })),
648        ...kept,
649        body,
650        controls,
651      ],
652    });
653  }
654
655  /**
656   * Has the pane read the Detail of the task the overview details: a kept state names one the
657   * pane has not read yet after a restart, and a press on a row reads it at once.
658   */
659  private askDetail(id: string): void {
660    if (this.pane.detailId === id || this.asked === id) return;
661    const task = this.issueOf(id);
662    if (task === undefined) return;
663    this.asked = id;
664    void this.pane.showDetail(task.issue, task.store);
665  }
666}
667
hooks/pane.ts 3109 lines
1import type { Elements, RenderElement, RenderNode, Timer } from "claude-code";
2
3import {
4  type ActionContext,
5  asOf,
6  BACKLOG_PAGE_SIZE,
7  type BacklogView,
8  BOARD_PAGE_LIMIT,
9  type BoardEntry,
10  type BoardEvent,
11  type BoardState,
12  type BoardView,
13  backlogView,
14  boardFileName,
15  boardPath,
16  boardReadArgvs,
17  boardView,
18  CARD_FRAME_COLUMNS,
19  COMMANDS,
20  type Comment,
21  clockStamp,
22  commentAddArgv,
23  commentsArgv,
24  commentsView,
25  createArgv,
26  DEFAULT_BAND_SETTINGS,
27  DEFAULT_CONFIG,
28  DEFAULT_REVIEW_FILTER,
29  DEFAULT_SORT,
30  type DetailState,
31  DIRECT_GLYPH,
32  describeFilterError,
33  detailView,
34  type EditableField,
35  EMPTY_FILTER_STATE,
36  editArgv,
37  FILTER_KEYS,
38  type FilterState,
39  footerHints,
40  glyphOf,
41  groupLayout,
42  gutterColumns,
43  historyArgv,
44  type ImageVerdict,
45  INDICATOR_ROW_PREFIX,
46  INDICATORS,
47  INITIAL_BOARD_STATE,
48  type IndicatorRead,
49  type Issue,
50  indicatorRead,
51  initialReviewFilter,
52  isCollapseRule,
53  isFirstmate,
54  isPanelMode,
55  isRefresherToken,
56  isShort,
57  isSurfaceSetting,
58  LEASE_FILE,
59  legendOf,
60  MAX_SHOWN,
61  MEDIUM_COLUMNS,
62  type MenuView,
63  menuView,
64  type NavLayout,
65  navLayout,
66  nextOf,
67  nextSort,
68  nextSwimlaneMode,
69  openDetail,
70  openReviewIndex,
71  PARTIAL_GLYPH,
72  type PanelView,
73  type PendingDisplayId,
74  type PlannedView,
75  panelActions,
76  parseBdComments,
77  parseBdCreatedId,
78  parseBdHistoryEvents,
79  parseBdList,
80  parseBdReady,
81  parseBdShow,
82  parseBdStatuses,
83  parseBoardSnapshot,
84  parseCommentDraft,
85  parseFieldEdit,
86  parseFileLease,
87  parseIssueRows,
88  parseQuickAdd,
89  parseShownCount,
90  parseSnapshot,
91  parseSort,
92  parseStatusesJson,
93  parseSwimlaneMode,
94  parseTaskMessage,
95  pendingDisplayIdStep,
96  planDisplayId,
97  planMove,
98  plannedText,
99  REVIEW_FILTER_KEYS,
100  REVIEW_GROUPS,
101  REVIEW_LIST_PAGE_ITEMS,
102  REVIEW_MENU_COMMAND,
103  REVIEW_SORTS,
104  type ResolvedConfig,
105  type ReviewFilterState,
106  type ReviewGroup,
107  type ReviewListView,
108  type ReviewScope,
109  type ReviewSort,
110  readPendingDisplayIds,
111  readyArgv,
112  reduceBoard,
113  reduceDetail,
114  refresherRunning,
115  resolveConfig,
116  resolveGlyphs,
117  resolveTheme,
118  reviewListView,
119  reviewSection,
120  SECTION_COLUMNS,
121  SECTION_ROWS,
122  SHORT_ROWS,
123  SNAPSHOT_FILE,
124  type SortSpec,
125  SPACE,
126  STALE_MS,
127  STATUS_CATEGORIES,
128  type StoreResolution,
129  SURFACE_ROW_PREFIX,
130  type SurfaceId,
131  type SurfaceRow,
132  type SwimlaneMode,
133  scopedItems,
134  settingsView,
135  showArgv,
136  showsTasks,
137  slugBudgets,
138  snapshotFileName,
139  sortLabel,
140  stateDirOf,
141  statusesArgv,
142  stepPage,
143  storeLabel,
144  submitFilter,
145  submitReviewFilter,
146  surfaceOf,
147  type TaskClientProps,
148  type TaskListView,
149  TIMELINE_TYPES,
150  type Token,
151  taskClientProps,
152  taskListView,
153  textWidth,
154  timelineLayout,
155  timelinePicture,
156  timelinePlacement,
157  timelineReadArgvs,
158  timelineView,
159  truncate,
160  type UndoClose,
161  undoArgv,
162  undoForMove,
163  undoView,
164  withIndicatorsTable,
165  withPanelTable,
166  withSurfacesTable,
167} from "../core/src/index.ts";
168import type { BandSettingsPanel } from "./bandSettings.ts";
169import { actionBar } from "./components/actions.ts";
170import {
171  paneSurface,
172  type RasterElement,
173  type Surface,
174  type SurfacePicture,
175  type TasksRegion,
176} from "./components/adapters.ts";
177import { type BacklogColumns, backlog } from "./components/backlog.ts";
178import { board } from "./components/board.ts";
179import { detail } from "./components/detail.ts";
180import { field } from "./components/elements.ts";
181import { emptyState, errorState, headerBar, readingState, section } from "./components/frame.ts";
182import { type HelpPart, help } from "./components/help.ts";
183import {
184  enabledIndicators,
185  gutterFor,
186  gutterSpans,
187  indicatorOn,
188  LEGEND_TITLE,
189  legendLines,
190  reviewItemsOf,
191  setIndicatorSwitches,
192  setReviewIndex,
193} from "./components/indicators.ts";
194import {
195  colorOf,
196  glyphText,
197  setAscii,
198  setTheme,
199  styledUi,
200  themeOverrides,
201  weight,
202} from "./components/look.ts";
203import { drawMenu, menuColumns, menuRows } from "./components/menu.ts";
204import { navFrame } from "./components/nav.ts";
205import { type ViewEntry, ViewRegistry } from "./components/registry.ts";
206import { ANSWER_KEY, reviewList, reviewReader } from "./components/review.ts";
207import { drawSettings, settingsOptionRoom } from "./components/settings.ts";
208import {
209  rowControlColumns,
210  TASKS_CLIENT_KEY,
211  type TaskListClient,
212  taskList,
213} from "./components/tasks.ts";
214import { timeline } from "./components/timeline.ts";
215import { fitted, windowed } from "./components/window.ts";
216import type { Host } from "./host.ts";
217import { PanelSettingsState } from "./panel.ts";
218import { ReviewFeed, ReviewReader } from "./review.ts";
219import type { StoreResolver } from "./store.ts";
220
221export const PANE_ID = "beads";
222export const PANE_TITLE = "Beads";
223
224/**
225 * Claude Code places a pane nobody asked for only from UNASKED_COLUMNS terminal columns, and from
226 * NARROW_COLUMNS once the person has opened it; below that it holds the open unplaced.
227 */
228export const UNASKED_COLUMNS = 144;
229export const NARROW_COLUMNS = 110;
230/** The toast when Claude Code holds the session-start open unplaced (T-2B.7, D-32). */
231export const NARROW_TEXT = `beads-ui did not open its task panel: Claude Code opens a pane nobody asked for from ${UNASKED_COLUMNS} terminal columns, or ${NARROW_COLUMNS} once you have opened it. Widen the terminal or run /bu.`;
232/** The one toast when Claude Code's task panel is retired and panel.mode is native (D-32 point 3). */
233export const NO_PANEL_TEXT =
234  "No task panel is enabled: Claude Code's task panel is retired here and beads-ui's panel.mode is native. Set Panel mode to beads in the pane's Settings (/bu, then 6), or mode = \"beads\" under [panel] in beads-ui.toml.";
235
236/** Where the view preferences live in `$.store`. Issue data is never stored. */
237export const PREFS_KEY = "view";
238/**
239 * Where display ids minted for a created issue wait in `$.store` until their write succeeds, so
240 * a pane reopen or a new session retries the same id instead of leaving the issue without one.
241 */
242export const PENDING_IDS_KEY = "pending-display-ids";
243/**
244 * Where each session's Mine or All choice lives in `$.store` (D-29), by session name, so one
245 * session's choice never changes another's default.
246 */
247export const SCOPE_KEY = "view-scope";
248
249/** Mine draws the issues assigned to the session; All draws every issue (D-29). */
250export type Scope = "mine" | "all";
251
252/** The body the pane asks for, docked and inline; the person's own size wins. */
253const WANTED_COLUMNS = 80;
254const WANTED_ROWS = 24;
255
256/** At short height, the rows the nav, the header, the toolbar and the list's summary take. */
257const SHORT_CHROME_ROWS = 6;
258/** In the band's overview, the rows the list's count, its two edges and its "+M more" take. */
259const BAND_LIST_CHROME_ROWS = 4;
260/**
261 * The most task rows the Tasks view draws at once: Claude Code keeps only a tree's first 100,000
262 * characters, so a longer list draws a window of rows that follows the focus. 80, not the 100 it
263 * was before each row's two buttons drew in the clickable affordance (T-2C.14).
264 */
265export const TASK_WINDOW_ROWS = 80;
266/**
267 * The rows the Timeline's chrome takes besides the nav: the header, the toolbar, the footer, and
268 * the view's count, axis, above, undated heading and below lines.
269 */
270const TIMELINE_CHROME_ROWS = 9;
271
272const READ_TIMEOUT_MS = 8_000;
273/** What `$.process.run` keeps of each pipe: 4194304 bytes. */
274const RUN_OUTPUT_LIMIT = "4 MiB";
275const WRITE_TIMEOUT_MS = 15_000;
276
277/** The built-in views the nav opens (T-2B.1); Sprint is planned and never opens. */
278export type Tab = Exclude<PanelView, PlannedView>;
279/** D-28: tab 3 is the Sprint view Phase 2 builds; until then pressing it says so and stays put. */
280export const SPRINT_PLANNED_TEXT = plannedText("sprint");
281/** The view the pane opens on when no view was kept (T-2B.2): the task panel. */
282export const DEFAULT_TAB: Tab = "tasks";
283/** The issue types a Detail's Type list offers, with every type the store's issues carry. */
284export const ISSUE_TYPES: readonly string[] = ["task", "bug", "feature", "epic", "chore"];
285
286/** What the pane is drawn into: its body's width and rows, and where it sits (T-1B.9). */
287export interface PaneFrame {
288  width: number;
289  placement: string;
290  bodyRows?: number;
291  /** The props' own bodyColumns, which pick the nav's tier (T-2B.1); `width` when not given. */
292  bodyColumns?: number;
293}
294
295/** The elements the pane draws with: every surface but mobile has them all. */
296export type PaneUi = Pick<
297  Elements["terminal"],
298  "Box" | "Text" | "Button" | "Input" | "Select" | "Markdown" | "Code"
299>;
300
301/**
302 * The task list's Client (T-2B.5) where the surface has one, and the terminal's Raster (T-2B.9):
303 * register.ts names the Client's module, since Claude Code reads it off the hooks module's source.
304 */
305export type { RasterElement, TasksRegion };
306
307export interface Prefs {
308  /** The open view's id; `tab`, which held 0.4.0's board-first tab, is no longer read. */
309  view: string;
310  filter: string;
311  swimlane: SwimlaneMode;
312  sort: string;
313}
314
315type Ran = (
316  | { ok: true; exitCode: 0; stdout: string; stderr: string }
317  | { ok: false; exitCode: number | null; stdout: string; stderr: string; message: string }
318) & {
319  /** The directory bd ran in: the store a write landed in, whatever the pane reads by now. */
320  dir: string;
321};
322
323/** A `hint` notice names what to do next, so the next action that succeeds clears it. */
324type Notice = { text: string; token: Token; hint?: true };
325
326/** The Review view's lines besides its items on the band: heading, controls, filter, pager. */
327const REVIEW_CHROME_ROWS = 4;
328const REVIEW_SCOPE_HINT =
329  "the Review view lists the items this session holds and those about issues it owns; the firstmate lists its pair's";
330const FOCUS_HINT = "focus a card first: Tab moves between cards";
331const TASK_FOCUS_HINT = "focus a task first: Tab or the arrows move between tasks";
332const BAND_TASK_HINT = "press a card's detail button, or a task's row, to open it here";
333const BAND_NEW_HINT = "type a title in the New field above";
334/**
335 * How focus reaches the pane and leaves it, and how to close and reopen it (D-27, T-1B.6): the
336 * help draws these lines, and README.md and docs/procedures/mod-install.md carry the same.
337 */
338export const USAGE_LINES: readonly string[] = [
339  `Focus enters the pane: Ctrl+X then Tab (again for the next pane, then back to the prompt), a click on it (fullscreen rendering), or the band's pane button, [${glyphOf("pane.open")}] right after the band's text (click it, or Ctrl+X then Tab to the band and Enter).`,
340  "Focus leaves: Esc returns it to the prompt and closes the pane.",
341  `Close: Esc, Ctrl+X then X, the band's [${glyphOf("pane.close")}] button (drawn beside [${glyphOf("pane.open")}] while the pane is open), or /bu (also /show-beads-ui).`,
342  `Reopen: /bu, the band's [${glyphOf("pane.open")}] button, or a key chord bound to command:bu in ~/.claude/keybindings.json, such as this one for Meta+B:`,
343];
344export const KEYBINDINGS_BLOCK = `{
345  "bindings": [
346    {
347      "context": "Chat",
348      "bindings": {
349        "meta+b": "command:bu"
350      }
351    }
352  ]
353}`;
354/** Short height drops the help (T-1B.9); pressing h then says how to get room for it. */
355export const SHORT_HELP_TEXT =
356  "Help needs a docked pane 12 rows or taller: Ctrl+X then an arrow resizes the pane.";
357export const NO_NAME_TEXT =
358  "Mine needs the session's name, which ai c sets in CLAUDE_CODE_TASK_LIST_ID; this session has none, so the board shows all issues";
359
360const assigneeLabel = (assignee: string): string => (assignee === "" ? "" : `@${assignee}`);
361
362const MORE = glyphOf("text.more");
363
364const messageOf = (reason: unknown): string =>
365  reason instanceof Error ? reason.message : String(reason);
366
367/** The cards a board page draws, in its lanes and among the statuses bd did not list. */
368const cardsOf = (view: BoardView): number =>
369  view.unplaced.length +
370  view.lanes.reduce((sum, lane) => sum + lane.cells.reduce((n, cell) => n + cell.length, 0), 0);
371
372const isRecord = (value: unknown): value is Record<string, unknown> =>
373  typeof value === "object" && value !== null;
374
375const present = <T>(items: readonly (T | undefined | false)[]): T[] =>
376  items.filter((item): item is T => item !== undefined && item !== false);
377
378/**
379 * The `/show-beads-ui` pane: its state, the bd reads and writes behind each action, and its drawing. It
380 * holds no `$`: register.ts binds it to a Host at session start. Every domain rule is core's.
381 */
382export class BeadsPane {
383  host: Host | undefined;
384  isOpen = false;
385  board: BoardState = INITIAL_BOARD_STATE;
386  readyIds: ReadonlySet<string> | undefined;
387  filter: FilterState = EMPTY_FILTER_STATE;
388  /** The open view's id, a Tab or a view registered later (D-35). */
389  tab: string = DEFAULT_TAB;
390  /** Every view the pane and the band draw (D-35): their navs and drawing come from it. */
391  readonly views = new ViewRegistry();
392  /** The board's issues as the row indicators read them (D-41), kept while the issues are. */
393  private indicatorMemo: { issues: readonly Issue[]; read: IndicatorRead } | undefined;
394  /** The task panel's settings (T-2B.2, T-2B.8). */
395  readonly panel = new PanelSettingsState();
396  /** The Tasks view shows its collapsed tasks (T-2B.2). */
397  expanded = false;
398  /** The options menu is open (T-2B.4), about `menuFor` when a task was focused, on `menuOn`. */
399  menuOpen = false;
400  menuOn: SurfaceId = "pane";
401  /** The band's overview draws the help in place of its view (T-2C.8). */
402  bandHelp = false;
403  menuFor: string | undefined;
404  /** The column a right click opened the menu at (T-2B.5); undefined when a key or button did. */
405  menuX: number | undefined;
406  /** The task list's Client failed (T-2B.5): the element tree draws the list for the session. */
407  clientFaulted = false;
408  /** Whether the terminal draws an Image's pixels (T-2C.13), read at session start. */
409  images: ImageVerdict = { images: false, reason: "the terminal is not read yet" };
410  /** The props the task list's Client was last handed, which its posts are read against. */
411  private clientProps: TaskClientProps | undefined;
412  /** The Settings view's last notice. */
413  settingsNotice: Notice | undefined;
414  /** The body size the pane last drew at, for the actions that run between draws. */
415  private frame: PaneFrame = { width: WANTED_COLUMNS, placement: "dock" };
416  swimlane: SwimlaneMode = "none";
417  sort: SortSpec = DEFAULT_SORT;
418  config: ResolvedConfig = { config: DEFAULT_CONFIG, problems: [] };
419  detailId: string | undefined;
420  /** The store the Detail's issue was read from, when it is not the pane's: a band row's. */
421  private detailStore: string | undefined;
422  detail: DetailState | undefined;
423  detailError: string | undefined;
424  /** The open issue's comments (BU-mg2.3.15), read with its detail; undefined until read. */
425  comments: Comment[] | undefined;
426  commentsError: string | undefined;
427  /** What the comment control holds: kept when its write fails, so no typed text is lost. */
428  commentDraft = "";
429  commentSaving = false;
430  commentError: string | undefined;
431  /** The host's held items (D-37), read with each Detail. */
432  readonly review: ReviewFeed;
433  /** The held item open in the reader, over the Detail (T-2C.5). */
434  readonly reader: ReviewReader;
435  /** The Review section's page of held items. */
436  reviewPage = 0;
437  /** The view the reader was opened from, which draws it: the Detail or the Review view. */
438  readerOn: "detail" | "review" = "detail";
439  /**
440   * The Review view (T-2C.6): the firstmate lists its pair (`pair`) or its own (`mine`), any
441   * other session its own; its filter, sort, group and page.
442   */
443  reviewScope: ReviewScope = "mine";
444  reviewFilter: ReviewFilterState = initialReviewFilter();
445  reviewSort: ReviewSort = "updated";
446  reviewGroup: ReviewGroup = "none";
447  reviewPages: Record<SurfaceId, number> = { pane: 0, band: 0 };
448  undo: UndoClose | undefined;
449  notice: Notice | undefined;
450  focusedId: string | undefined;
451  help = false;
452  quickAddText = "";
453  /** The session-start open (T-2B.7) has run: a session never opens the panel unasked twice. */
454  private started = false;
455  /** The person has opened the pane this session. */
456  private asked = false;
457  /** The delete confirmation, wired by register.ts. */
458  onDelete: ((issue: Issue, issues: readonly Issue[]) => void) | undefined;
459  /**
460   * A write succeeded in the store at the directory given: the band redraws its counts once more
461   * (T-1B.5) and the store publishes (T-2B.6); wired by register.ts.
462   */
463  onWrite: ((store: string) => void) | undefined;
464  /**
465   * A task row pressed in the band's overview (D-36): the band opens its detail there; wired by
466   * register.ts. Unset, a band press opens it in the pane, as the pane's own rows do.
467   */
468  onBandOpen: ((id: string) => void) | undefined;
469  /** A view a press on the band asked for (a menu's Go to): the band's overview opens it. */
470  onBandView: ((view: string) => void) | undefined;
471  /** The inventory's stores out of step with the pair (D-34), named in the header. */
472  unsynced: string[] = [];
473  /** Display ids minted after a create whose write has not succeeded, retried on each refresh. */
474  pendingIds: PendingDisplayId[] = [];
475  /** The last failed write of each pending display id, by issue id. */
476  private pendingErrors: Record<string, string> = {};
477  /** The page each board cell shows, by core's cellKey. */
478  cellPages: Record<string, number> = {};
479  lanePage = 0;
480  unplacedPage = 0;
481  backlogPage = 0;
482  /** The body width the pane last drew at, for the page steps that run between draws. */
483  private width = WANTED_COLUMNS;
484  /** Mine or All (D-29): a firstmate, or a session with no name, starts on All. */
485  scope: Scope = "all";
486  /** The Dolt head of the board snapshot drawn; undefined when bd was read directly. */
487  private boardHead: string | undefined;
488  /** When the pane last wrote: a snapshot read before it is not drawn, bd is read instead. */
489  private wroteAt = 0;
490  /** Why the store's board snapshot cannot be drawn, as the refresher named it. */
491  boardSkipped: string | undefined;
492  /**
493   * The Timeline's own read (T-2B.9): the store's closed epics and milestones, read when the
494   * Timeline opens, and kept while the board draws the same snapshot head of the same store.
495   */
496  private timelineRead: { store: string; head: string | undefined; issues: Issue[] } | undefined;
497  private timelineReading = false;
498
499  /** The store the pane reads (D-24), from the last refresh; undefined before the first. */
500  store: StoreResolution | undefined;
501  /** True while the pane has stepped aside for the delete confirmation. */
502  private suspended = false;
503  private refreshing = false;
504  private queued = false;
505  private queuedDirect = false;
506  private timer: Timer | undefined;
507  private timerMs = 0;
508
509  /**
510   * @param stores the session's store resolution, shared with the band (D-24)
511   * @param bandSettings the band's settings, drawn in the pane's band settings section (T-1B.11)
512   */
513  constructor(
514    private readonly stores: StoreResolver,
515    private readonly bandSettings?: BandSettingsPanel,
516  ) {
517    this.views.drawWith(
518      "tasks",
519      (surface) => this.drawTasks(surface),
520      (surface) => this.drawInputs(surface, false),
521    );
522    this.views.drawWith(
523      "board",
524      (surface) => this.drawBoard(surface),
525      (surface) => this.drawInputs(surface, true),
526    );
527    this.views.drawWith(
528      "backlog",
529      (surface) => this.drawBacklog(surface),
530      (surface) => this.drawInputs(surface, true),
531    );
532    this.views.drawWith("detail", (surface) => this.drawDetail(surface));
533    this.views.drawWith("timeline", (surface) => this.drawTimeline(surface));
534    this.views.drawWith("settings", (surface) => this.drawSettingsView(surface));
535    this.views.drawWith("review", (surface) => this.drawReviewView(surface));
536    this.review = new ReviewFeed(stores);
537    this.reader = new ReviewReader(stores, this.review);
538    this.reader.onChange = () => this.host?.invalidate();
539  }
540
541  async bind(host: Host): Promise<void> {
542    this.host = host;
543    this.review.bind(host);
544    this.reader.bind(host);
545    await this.panel.bind(host);
546    await this.loadPrefs();
547    void this.refresh();
548  }
549
550  /** A write the pane made landed: a snapshot read before now is not drawn. */
551  private async markWrite(): Promise<void> {
552    const now = await this.host?.now().catch(() => undefined);
553    if (now !== undefined) this.wroteAt = now;
554  }
555
556  /** As markWrite, and the band and the publish hear of the store written, `store` (T-1B.5). */
557  private async wrote(store: string): Promise<void> {
558    await this.markWrite();
559    this.onWrite?.(store);
560  }
561
562  // ---- view preferences -------------------------------------------------------------------
563
564  private async loadPrefs(): Promise<void> {
565    const pending = await this.host?.storeGet(PENDING_IDS_KEY).catch(() => undefined);
566    for (const entry of readPendingDisplayIds(pending)) {
567      if (!this.pendingIds.some((known) => known.id === entry.id)) this.pendingIds.push(entry);
568    }
569    const session = this.stores.sessionName;
570    this.scope = session !== undefined && !isFirstmate(session) ? "mine" : "all";
571    this.reviewScope = this.firstmate ? "pair" : "mine";
572    const scopes = await this.host?.storeGet(SCOPE_KEY).catch(() => undefined);
573    const chosen = isRecord(scopes) ? scopes[session ?? ""] : undefined;
574    if (chosen === "mine" || chosen === "all") this.scope = chosen;
575    const stored = await this.host?.storeGet(PREFS_KEY).catch(() => undefined);
576    if (!isRecord(stored)) return;
577    if (typeof stored.view === "string" && this.opens(stored.view)) this.tab = stored.view;
578    if (typeof stored.filter === "string") {
579      this.filter = submitFilter(EMPTY_FILTER_STATE, stored.filter);
580    }
581    if (typeof stored.swimlane === "string") {
582      const mode = parseSwimlaneMode(stored.swimlane);
583      if (mode.ok) this.swimlane = mode.value;
584    }
585    if (typeof stored.sort === "string") {
586      const sort = parseSort(stored.sort);
587      if (sort.ok) this.sort = sort.value;
588    }
589  }
590
591  private savePrefs(): void {
592    const prefs: Prefs = {
593      view: this.tab,
594      filter: this.filter.text,
595      swimlane: this.swimlane,
596      sort: sortLabel(this.sort),
597    };
598    void this.host?.storeSet(PREFS_KEY, prefs).catch(() => undefined);
599  }
600
601  private savePending(): void {
602    const pending = this.pendingIds.map(({ id, displayId }) => ({ id, displayId }));
603    void this.host?.storeSet(PENDING_IDS_KEY, pending).catch(() => undefined);
604  }
605
606  /**
607   * Retries each pending display id against the store as just read: the same id again, never a
608   * second one; an issue that now carries an id, or is gone, drops it (D-9). bd has no
609   * compare-and-set on metadata, so the write runs only when a `bd show` of that one issue, read
610   * just before it, still finds no id; an unreadable show writes nothing and keeps it pending.
611   */
612  private async retryPendingIds(): Promise<void> {
613    const done = new Set<string>();
614    let wrote: string | undefined;
615    for (const pending of [...this.pendingIds]) {
616      let step = pendingDisplayIdStep(pending, this.board.issues);
617      if (step.kind === "write") {
618        const shown = await this.run(showArgv(pending.id), READ_TIMEOUT_MS);
619        const parsed = shown.ok ? parseBdShow(shown.stdout) : undefined;
620        const issue =
621          parsed?.ok === true ? parsed.value.find((one) => one.id === pending.id) : undefined;
622        if (issue === undefined) {
623          this.pendingErrors[pending.id] = shown.ok
624            ? `re-reading it failed: bd show ${pending.id} returned nothing beads-ui can read`
625            : this.failure("re-reading it", shown);
626          continue;
627        }
628        step = pendingDisplayIdStep(pending, [issue]);
629      }
630      if (step.kind === "kept") {
631        this.note(
632          `${pending.id} already carries ${step.existing}; ${pending.displayId} was not written`,
633          "warning",
634        );
635      } else if (step.kind === "write") {
636        const ran = await this.run(step.argv, WRITE_TIMEOUT_MS);
637        if (!ran.ok) {
638          this.pendingErrors[pending.id] = this.failure("writing its display id", ran);
639          continue;
640        }
641        wrote = ran.dir;
642        this.note(`wrote ${pending.displayId} for ${pending.id}`, "success");
643      }
644      delete this.pendingErrors[pending.id];
645      done.add(pending.id);
646    }
647    if (done.size > 0) {
648      this.pendingIds = this.pendingIds.filter((pending) => !done.has(pending.id));
649      this.savePending();
650    }
651    if (wrote !== undefined) {
652      this.queued = true;
653      // A display id written late is a write like any other: the band and the publish hear of it.
654      this.onWrite?.(wrote);
655    }
656  }
657
658  // ---- bd ---------------------------------------------------------------------------------
659
660  /**
661   * One bd call. `streamed` reads it through `$.process.spawn`, for a read whose output can pass
662   * the 4 MiB `$.process.run` keeps; a `$.process.run` read cut at that limit is a failure.
663   */
664  private async run(
665    argv: readonly string[],
666    timeoutMs: number,
667    { streamed = false }: { streamed?: boolean } = {},
668  ): Promise<Ran> {
669    const dir = this.storePath();
670    return { ...(await this.runAt(dir, argv, timeoutMs, streamed)), dir };
671  }
672
673  private async runAt(
674    dir: string,
675    argv: readonly string[],
676    timeoutMs: number,
677    streamed: boolean,
678  ): Promise<Omit<Ran, "dir">> {
679    const host = this.host;
680    if (host === undefined) {
681      return { ok: false, exitCode: null, stdout: "", stderr: "", message: "no session" };
682    }
683    try {
684      const result = streamed
685        ? await host.stream(dir, argv, timeoutMs)
686        : await host.runIn(dir, argv, timeoutMs);
687      if ("isStdoutTruncated" in result && result.isStdoutTruncated === true) {
688        return {
689          ok: false,
690          exitCode: result.exitCode,
691          stdout: result.stdout,
692          stderr: result.stderr,
693          message: `its output passed the ${RUN_OUTPUT_LIMIT} $.process.run keeps and was cut short`,
694        };
695      }
696      if (result.exitCode === 0) {
697        return { ok: true, exitCode: 0, stdout: result.stdout, stderr: result.stderr };
698      }
699      return {
700        ok: false,
701        exitCode: result.exitCode,
702        stdout: result.stdout,
703        stderr: result.stderr,
704        message: result.stderr.trim() || result.stdout.trim(),
705      };
706    } catch (reason) {
707      return { ok: false, exitCode: null, stdout: "", stderr: "", message: messageOf(reason) };
708    }
709  }
710
711  private failure(what: string, ran: Ran): string {
712    if (ran.ok) return "";
713    const detail = ran.message === "" ? "" : `: ${ran.message}`;
714    if (ran.exitCode === 0) return `${what} failed${detail}`;
715    const how = ran.exitCode === null ? "timed out or could not start" : `exit ${ran.exitCode}`;
716    return `${what} failed (${how})${detail}`;
717  }
718
719  /** The directory bd runs in: the resolved store's, else the session's own. */
720  storePath(): string {
721    return this.store?.ok === true ? this.store.path : (this.host?.cwd ?? "");
722  }
723
724  /** Resolves the session's store (D-24) and reads its settings, both at call time. */
725  private async readConfig(): Promise<ResolvedConfig> {
726    if (this.host === undefined) return this.config;
727    const { resolution, config, problems, inventory } = await this.stores.resolve();
728    this.store = resolution;
729    this.unsynced = inventory.ok
730      ? inventory.value.filter((store) => !store.synced).map((store) => store.name)
731      : [];
732    await this.panel.read(config.text);
733    const resolved = resolveConfig(config.text, this.storePath());
734    const theme = resolveTheme(config.text);
735    const glyphs = resolveGlyphs(config.text);
736    const restyled = setTheme(theme.overrides);
737    const switched = setIndicatorSwitches(this.panel.indicators);
738    if (setAscii(glyphs.ascii) || restyled || switched) this.host.invalidate();
739    return {
740      ...resolved,
741      problems: [
742        ...resolved.problems,
743        ...problems,
744        ...this.panel.problems,
745        ...theme.problems,
746        ...glyphs.problems,
747      ],
748    };
749  }
750
751  private retime(): void {
752    const host = this.host;
753    const ms = this.config.config.refreshSeconds * 1000;
754    if (host === undefined || ms === this.timerMs) return;
755    this.timer?.cancel();
756    this.timerMs = ms;
757    this.timer = host.every(ms, () => {
758      void this.refresh();
759    });
760  }
761
762  private dispatch(event: BoardEvent): void {
763    this.board = reduceBoard(this.board, event);
764  }
765
766  private async stamp(): Promise<string> {
767    const ms = (await this.host?.now().catch(() => undefined)) ?? 0;
768    return clockStamp(ms, new Date(ms).getTimezoneOffset());
769  }
770
771  /**
772   * One read set. The board comes from the host refresher's snapshot of the store while one is
773   * running and current (D-26, D-29), else from bd: `bd statuses`, the open issues and the newest
774   * closed page, never every closed issue (T-1B.8). `direct` always reads bd, as after a write
775   * and when the pane opens. While the pane is open it also reads `bd ready` and the open
776   * Detail's `bd show` and `bd history --events`. A failed or timed-out read keeps the last good
777   * board, and the next refresh tries again.
778   */
779  async refresh(options: { direct?: boolean } = {}): Promise<void> {
780    if (this.host === undefined) return;
781    if (this.refreshing) {
782      this.queued = true;
783      if (options.direct === true) this.queuedDirect = true;
784      return;
785    }
786    this.refreshing = true;
787    try {
788      this.config = await this.readConfig();
789      this.retime();
790      // No store resolved: the pane says what it tried, and no bd read runs until one does.
791      if (this.store?.ok !== true) return;
792      const full = this.isOpen;
793      const snapshot = options.direct === true ? false : await this.fromSnapshot(true);
794      let loaded = snapshot;
795      const ready = full ? this.run(readyArgv(), READ_TIMEOUT_MS) : undefined;
796      if (!snapshot) {
797        const [open, closed] = boardReadArgvs();
798        const [statuses, opened, closedPage] = await Promise.all([
799          this.run(statusesArgv(), READ_TIMEOUT_MS),
800          this.run(open, READ_TIMEOUT_MS, { streamed: true }),
801          this.run(closed, READ_TIMEOUT_MS),
802        ]);
803        loaded = this.applyReads(statuses, [opened, closedPage], await this.stamp());
804        if (loaded) this.boardHead = undefined;
805      }
806      if (loaded) await this.retryPendingIds();
807      // The pane opened on the Timeline before its store was resolved: its own read runs now.
808      if (full && this.tab === "timeline" && this.timelineRead?.store !== this.store.path) {
809        void this.readTimeline();
810      }
811      const readied = await ready;
812      if (readied?.ok === true) {
813        const parsed = parseBdReady(readied.stdout);
814        if (parsed.ok) this.readyIds = new Set(parsed.value.map((issue) => issue.id));
815      }
816      for (const id of this.board.refresh) {
817        const shown = await this.run(showArgv(id), READ_TIMEOUT_MS);
818        const parsed = shown.ok ? parseBdShow(shown.stdout) : undefined;
819        const issue = parsed?.ok === true ? parsed.value.find((one) => one.id === id) : undefined;
820        if (issue !== undefined) this.dispatch({ type: "issue-refreshed", issue });
821      }
822      if (full && this.detailId !== undefined) await this.loadDetail(this.detailId);
823      else await this.loadReview();
824      if (this.undo !== undefined) {
825        const undone = this.board.issues.find((issue) => issue.id === this.undo?.id);
826        if (undone !== undefined && undone.status !== this.undo.closed) this.undo = undefined;
827      }
828    } finally {
829      this.refreshing = false;
830      this.host?.invalidate();
831      if (this.queued) {
832        const direct = this.queuedDirect;
833        this.queued = false;
834        this.queuedDirect = false;
835        void this.refresh({ direct });
836      }
837    }
838  }
839
840  /** The host refresher's state directory, where the board snapshots live. */
841  private async stateDir(): Promise<string | undefined> {
842    const xdgStateHome = await this.host?.stateHome().catch(() => undefined);
843    return stateDirOf({ xdgStateHome, home: this.stores.home });
844  }
845
846  /**
847   * Draws the board from the refresher's snapshot of the store (D-29); true when the board now
848   * shows it, or already shows that snapshot. With `current`, only while a refresher runs and has
849   * confirmed the snapshot within STALE_MS, and never over a read of bd made after it: so a
850   * periodic refresh costs no bd call. Without it, any snapshot of the store will do, as when the
851   * pane opens and the board has nothing yet to draw (T-1B.8). A store the refresher skipped is
852   * named on the pane and read directly.
853   */
854  private async fromSnapshot(current: boolean): Promise<boolean> {
855    const host = this.host;
856    const dir = await this.stateDir();
857    if (host === undefined || dir === undefined || this.store?.ok !== true) return false;
858    const path = this.store.path.replace(/\/+$/, "");
859    const [leaseText, now] = await Promise.all([
860      host.readFile(`${dir}/${LEASE_FILE}`).catch(() => undefined),
861      host.now().catch(() => 0),
862    ]);
863    const lease = leaseText === undefined ? undefined : parseFileLease(leaseText);
864    // The snapshot of the token the lease names; with no lease, the last one published.
865    const token = lease === undefined ? undefined : lease.token;
866    if (token !== undefined && !isRefresherToken(token)) return false;
867    const countsFile = token === undefined ? SNAPSHOT_FILE : snapshotFileName(token);
868    const countsText = await host.readFile(`${dir}/${countsFile}`).catch(() => undefined);
869    const parsed = countsText === undefined ? undefined : parseSnapshot(countsText);
870    const counts =
871      parsed?.ok === true &&
872      isRefresherToken(parsed.value.token) &&
873      (token === undefined || parsed.value.token === token)
874        ? parsed.value
875        : undefined;
876    const entry: BoardEntry | undefined = counts?.boards?.find(
877      (one) => one.path.replace(/\/+$/, "") === path,
878    );
879    this.boardSkipped = entry?.ok === false ? entry.error : undefined;
880    if (counts === undefined) return false;
881    if (current) {
882      if (entry?.ok !== true || !refresherRunning(lease, now)) return false;
883      if (now - entry.checkedAt > STALE_MS || entry.readAt < this.wroteAt) return false;
884      // The board already draws this head: nothing to read.
885      if (entry.head === this.boardHead && this.board.loaded) return true;
886    }
887    if (entry?.ok === false) return false;
888    const file = entry?.ok === true ? entry.file : boardFileName(path);
889    const text = await host
890      .readFile(`${dir}/${boardPath(counts.token, file)}`)
891      .catch(() => undefined);
892    const board = text === undefined ? undefined : parseBoardSnapshot(text);
893    if (board?.ok !== true || board.value.path.replace(/\/+$/, "") !== path) return false;
894    const issues = parseIssueRows(board.value.issues);
895    const statuses = parseStatusesJson(board.value.statuses);
896    if (!issues.ok || !statuses.ok || statuses.value.length === 0) return false;
897    const confirmed =
898      entry?.ok === true && entry.head === board.value.head ? entry.checkedAt : board.value.readAt;
899    this.dispatch({
900      type: "load-succeeded",
901      issues: issues.value,
902      statuses: statuses.value,
903      at: this.stampOf(confirmed),
904    });
905    this.boardHead = board.value.head;
906    return true;
907  }
908
909  private stampOf(ms: number): string {
910    return clockStamp(ms, new Date(ms).getTimezoneOffset());
911  }
912
913  /**
914   * Applies one read set, `bd statuses` and the board's lists (the open issues and the closed
915   * page); true when the store's issues loaded.
916   */
917  private applyReads(statuses: Ran, lists: readonly Ran[], at: string): boolean {
918    const issues: Issue[] = [];
919    const seen = new Set<string>();
920    for (const list of lists) {
921      if (!list.ok) {
922        this.dispatch({ type: "load-failed", exitCode: list.exitCode, message: list.message });
923        return false;
924      }
925      const parsed = parseBdList(list.stdout);
926      if (!parsed.ok) {
927        this.dispatch({
928          type: "load-failed",
929          exitCode: list.exitCode,
930          message: `${parsed.error.message} at ${parsed.error.path}`,
931        });
932        return false;
933      }
934      // An issue closed between the two reads is in both: the first read's copy is kept.
935      for (const issue of parsed.value) {
936        if (seen.has(issue.id)) continue;
937        seen.add(issue.id);
938        issues.push(issue);
939      }
940    }
941    const parsedStatuses = statuses.ok ? parseBdStatuses(statuses.stdout) : undefined;
942    const known = parsedStatuses?.ok === true ? parsedStatuses.value : this.board.statuses;
943    if (known.length === 0) {
944      this.dispatch({
945        type: "load-failed",
946        command: "bd statuses",
947        exitCode: statuses.ok ? 0 : statuses.exitCode,
948        message: !statuses.ok
949          ? statuses.message
950          : parsedStatuses?.ok === false
951            ? `${parsedStatuses.error.message} at ${parsedStatuses.error.path}`
952            : "it listed no statuses",
953      });
954      return false;
955    }
956    this.dispatch({ type: "load-succeeded", issues, statuses: known, at });
957    return true;
958  }
959
960  /**
961   * The Timeline opened (T-2B.9): reads the store's closed epics and milestones, one bd call a
962   * type, unless the last read was of this store at the snapshot head the board draws. A failed
963   * read keeps the last good one and says so; the Timeline still draws the board's own.
964   */
965  private async readTimeline(): Promise<void> {
966    if (this.store?.ok !== true || this.timelineReading) return;
967    const store = this.store.path;
968    const head = this.boardHead;
969    const kept = this.timelineRead;
970    if (kept?.store === store && head !== undefined && kept.head === head) return;
971    this.timelineReading = true;
972    try {
973      await this.readTimelineOf(store, head);
974    } finally {
975      this.timelineReading = false;
976    }
977  }
978
979  private async readTimelineOf(store: string, head: string | undefined): Promise<void> {
980    const argvs = timelineReadArgvs(TIMELINE_TYPES);
981    const ran = await Promise.all(
982      argvs.map((argv) => this.run(argv, READ_TIMEOUT_MS, { streamed: true })),
983    );
984    const issues: Issue[] = [];
985    for (const [i, read] of ran.entries()) {
986      const what = `bd list --type ${TIMELINE_TYPES[i]}`;
987      const parsed = read.ok ? parseBdList(read.stdout) : undefined;
988      if (parsed?.ok !== true) {
989        const failure = !read.ok
990          ? this.failure(what, read)
991          : `${what} failed: ${parsed?.ok === false ? `${parsed.error.message} at ${parsed.error.path}` : "unreadable"}`;
992        this.note(
993          `The Timeline shows only the newest closed epics and milestones: ${failure}`,
994          "warning",
995        );
996        // Kept with no head, so the next open reads again and no refresh retries meanwhile.
997        const kept = this.timelineRead?.store === store ? this.timelineRead.issues : [];
998        this.timelineRead = { store, head: undefined, issues: kept };
999        this.host?.invalidate();
1000        return;
1001      }
1002      issues.push(...parsed.value);
1003    }
1004    this.timelineRead = { store, head, issues };
1005    this.host?.invalidate();
1006  }
1007
1008  /** What the Timeline places: the board's issues, then the closed ones only its own read has. */
1009  private timelineIssues(): Issue[] {
1010    const read = this.timelineRead;
1011    if (read === undefined || this.store?.ok !== true || read.store !== this.store.path) {
1012      return [...this.board.issues];
1013    }
1014    const known = new Set(this.board.issues.map((issue) => issue.id));
1015    return [...this.board.issues, ...read.issues.filter((issue) => !known.has(issue.id))];
1016  }
1017
1018  // ---- opening and closing ----------------------------------------------------------------
1019
1020  /** `/show-beads-ui` and `/bu`: close the open pane, else open it, as the person asked. */
1021  async toggle(): Promise<void> {
1022    if (this.isOpen) {
1023      await this.close();
1024      return;
1025    }
1026    await this.open(true);
1027  }
1028
1029  /**
1030   * The band's pane button (D-27, T-1B.6, T-2C.14): opens the pane focused when it is closed, and moves
1031   * the focus ring into it, onto its open tab, when it is open.
1032   */
1033  async openOrFocus(): Promise<void> {
1034    if (!this.isOpen) {
1035      await this.open(true);
1036      return;
1037    }
1038    this.settleTab();
1039    await this.host?.focus(PANE_ID, `tab:${this.tab}`).catch(() => undefined);
1040  }
1041
1042  /** The panel mode draws the Tasks view: every mode but native (T-2B.7). */
1043  get tasksShown(): boolean {
1044    return showsTasks(this.panel.settings.mode);
1045  }
1046
1047  /**
1048   * The views `surface` draws (D-35): the registry's, under `[surfaces]` and Settings, with no
1049   * Tasks under native (T-2B.7).
1050   */
1051  viewsOn(surface: SurfaceId): ViewEntry[] {
1052    return this.views
1053      .on(surface, this.panel.surfaces)
1054      .filter((entry) => entry.view !== "tasks" || this.tasksShown);
1055  }
1056
1057  /** True when the pane opens `view`: a built view it draws. */
1058  private opens(view: string): boolean {
1059    return this.viewsOn("pane").some((entry) => entry.view === view && !entry.planned);
1060  }
1061
1062  /**
1063   * Under native the Tasks view is never drawn: a kept or default Tasks view opens the Board. A
1064   * view `[surfaces]` or Settings keeps off the pane opens the first view the pane draws.
1065   */
1066  private settleTab(): void {
1067    if (this.tab === "tasks" && !this.tasksShown) this.tab = "board";
1068    if (!this.opens(this.tab)) {
1069      this.tab = this.viewsOn("pane").find((entry) => !entry.planned)?.view ?? DEFAULT_TAB;
1070    }
1071  }
1072
1073  /**
1074   * The session's start (T-2B.7, D-32), once a session; a plugin reload starts it again. The pane
1075   * opens only when asked (`/bu`, the band's button) unless `panel.open_at_start` is on
1076   * (BU-ylx.10, off by default). With it on and `panel.mode` both or beads, read from
1077   * beads-ui.toml and the held Settings change now, the pane opens unasked on the Tasks view,
1078   * docked where Claude Code places it and leaving the keyboard on the prompt. Under native
1079   * nothing opens, and when `retired` (Claude Code's task panel is retired on this machine, D-21)
1080   * one toast says no task panel is enabled.
1081   */
1082  async start(retired: boolean): Promise<void> {
1083    if (this.started || this.host === undefined) return;
1084    this.started = true;
1085    this.config = await this.readConfig();
1086    if (!this.tasksShown) {
1087      if (retired) this.host.toast(NO_PANEL_TEXT);
1088      return;
1089    }
1090    if (!this.panel.settings.openAtStart) return;
1091    // The person opened the pane while the settings were read: theirs is the open, even if closed.
1092    if (this.isOpen || this.asked) return;
1093    const kept = this.tab;
1094    this.tab = "tasks";
1095    await this.open(false).catch(() => undefined);
1096    // Held unplaced: `/bu` later opens on the view the person kept.
1097    if (!this.isOpen) this.tab = kept;
1098  }
1099
1100  /**
1101   * Opens the pane. One the person asked for follows Claude Code's placement; one the mod opens
1102   * on its own (`asked` false) is withdrawn with a toast when the engine holds it unplaced.
1103   */
1104  async open(asked: boolean): Promise<void> {
1105    const host = this.host;
1106    if (host === undefined) throw new Error("beads-ui has no session yet");
1107    if (asked) this.asked = true;
1108    this.settleTab();
1109    // T-1B.8: a board with nothing to draw yet takes the store's snapshot before the pane opens,
1110    // so the first render draws cards; the bd read below then replaces it.
1111    if (!this.board.loaded && this.store?.ok === true) {
1112      await this.fromSnapshot(false).catch(() => false);
1113    }
1114    const opened = await host.openPane({
1115      id: PANE_ID,
1116      title: PANE_TITLE,
1117      closeOnEscape: true,
1118      columns: WANTED_COLUMNS,
1119      rows: WANTED_ROWS,
1120      ...(asked ? { focus: true as const } : {}),
1121    });
1122    if (!asked && isRecord(opened) && opened.isPlaced === false) {
1123      await host.closePane(PANE_ID).catch(() => undefined);
1124      host.toast(NARROW_TEXT);
1125      return;
1126    }
1127    this.isOpen = true;
1128    this.savePrefs();
1129    host.invalidate();
1130    void this.refresh({ direct: true });
1131    if (this.tab === "timeline") void this.readTimeline();
1132  }
1133
1134  async close(): Promise<void> {
1135    await this.host?.closePane(PANE_ID);
1136    this.closed();
1137  }
1138
1139  /** The pane closed, by `/show-beads-ui` or by the person (Esc, the close mark). */
1140  closed(): void {
1141    this.isOpen = false;
1142    this.savePrefs();
1143    // The band's toggle draws the pane's state.
1144    this.host?.invalidate();
1145  }
1146
1147  // ---- actions ----------------------------------------------------------------------------
1148
1149  private note(text: string, token: Token = "text.default"): void {
1150    this.notice = { text, token };
1151  }
1152
1153  private hint(text: string): void {
1154    this.notice = { text, token: "warning", hint: true };
1155  }
1156
1157  /** An action succeeded or the tab changed: a hint it answered no longer applies. */
1158  private clearHint(): void {
1159    if (this.notice?.hint === true) this.notice = undefined;
1160  }
1161
1162  /** A card's Move to: the guarded write, shown at once and rolled back on exit 13 (T-1.1). */
1163  async move(id: string, to: string): Promise<void> {
1164    const plan = planMove(this.board, id, to);
1165    if (!plan.ok) {
1166      if (plan.error.kind !== "same-status") this.note(plan.error.message, "error");
1167      this.host?.invalidate();
1168      return;
1169    }
1170    const issue = this.board.issues.find((candidate) => candidate.id === id);
1171    const from = issue?.status ?? "";
1172    this.notice = undefined;
1173    this.undo = undefined;
1174    this.dispatch(plan.value.event);
1175    this.host?.invalidate();
1176    const ran = await this.run(plan.value.argv, WRITE_TIMEOUT_MS);
1177    if (ran.ok) {
1178      await this.wrote(ran.dir);
1179      this.dispatch({ type: "move-succeeded", id });
1180      this.undo = undoForMove(this.board.statuses, {
1181        id,
1182        label: issue?.displayId ?? id,
1183        from,
1184        to,
1185      });
1186    } else {
1187      this.dispatch({
1188        type: "move-failed",
1189        id,
1190        exitCode: ran.exitCode ?? -1,
1191        message: ran.message,
1192      });
1193    }
1194    await this.refresh({ direct: true });
1195  }
1196
1197  /** Quick-add (T-1.4): `bd create`, then the display id when the store uses them. */
1198  async quickAdd(line: string): Promise<void> {
1199    const parsed = parseQuickAdd(line);
1200    if (!parsed.ok) {
hooks/panel.ts 156 lines
1import {
2  DEFAULT_PANEL_SETTINGS,
3  type PanelOverrides,
4  type PanelSettings,
5  panelOverridesOf,
6  resolveGlyphs,
7  resolvePanel,
8  resolveSurfaces,
9  type SurfaceOverrides,
10  type SurfaceSetting,
11  surfaceOverridesOf,
12  withPanelOverrides,
13} from "../core/src/index.ts";
14import type { Host } from "./host.ts";
15
16// The task panel's settings state (T-2B.2, T-2B.8, D-35). Every view the panel draws is a view
17// registry entry drawn through the components in ./components on the band and the pane (T-2C.8);
18// the names re-exported here are the task list's.
19
20export {
21  ROW_MENU_LABEL,
22  ROW_OPEN_LABEL,
23  rowControlColumns,
24  TASKS_CLIENT_KEY,
25  type TaskListClient,
26  type TaskListHandlers,
27} from "./components/tasks.ts";
28
29/** Where the pane keeps panel settings changed in it until Save to file writes them. */
30export const PANEL_SETTINGS_KEY = "panel-settings";
31
32/** Where the pane keeps the views' surfaces changed in Settings until Save to file (D-35). */
33export const SURFACES_KEY = "view-surfaces";
34
35/** Where the pane keeps the indicator switches changed in Settings until Save to file (D-41). */
36export const INDICATORS_KEY = "indicator-switches";
37
38/** Reads the stored switch changes, dropping any value that is not true or false. */
39function switchesOf(value: unknown): Record<string, boolean> {
40  if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
41  const out: Record<string, boolean> = {};
42  for (const [id, on] of Object.entries(value as Record<string, unknown>)) {
43    if (typeof on === "boolean") out[id] = on;
44  }
45  return out;
46}
47
48/**
49 * The task panel's settings (T-2B.2, T-2B.8): `beads-ui.toml`'s `[panel]` table, and the views'
50 * surfaces from its `[surfaces]` table (D-35), read at call time, with the changes made in
51 * Settings over them, kept in `$.store` until Save to file.
52 */
53export class PanelSettingsState {
54  host: Host | undefined;
55  overrides: PanelOverrides = {};
56  surfaceOverrides: Record<string, SurfaceSetting> = {};
57  indicatorOverrides: Record<string, boolean> = {};
58  problems: string[] = [];
59  /** Whether the last read found a beads-ui.toml; Settings shows the defaults when not. */
60  fromFile = false;
61  private file: PanelSettings = { ...DEFAULT_PANEL_SETTINGS };
62  private fileSurfaces: Record<string, SurfaceSetting> = {};
63  private fileIndicators: Record<string, boolean> = {};
64  /** `[indicators] ascii` as the file has it, kept when Save to file writes the table. */
65  private fileAscii = false;
66
67  async bind(host: Host): Promise<void> {
68    this.host = host;
69    await this.reload();
70  }
71
72  /** The settings the panel draws with: the file's under the ones changed in Settings. */
73  get settings(): PanelSettings {
74    return withPanelOverrides(this.file, this.overrides);
75  }
76
77  /** Each view's surfaces: `[surfaces]` under the ones changed in Settings. */
78  get surfaces(): SurfaceOverrides {
79    return { ...this.fileSurfaces, ...this.surfaceOverrides };
80  }
81
82  /** Each indicator's switch: `[indicators]` under the ones changed in Settings (D-41). */
83  get indicators(): Record<string, boolean> {
84    return { ...this.fileIndicators, ...this.indicatorOverrides };
85  }
86
87  /** `[indicators] ascii` as the file has it. */
88  get ascii(): boolean {
89    return this.fileAscii;
90  }
91
92  /**
93   * Reads the file's `[panel]`, `[surfaces]` and `[indicators]` tables and the held changes, as
94   * each refresh does. `[indicators]`' problems are the look's, which the pane names once.
95   */
96  async read(text: string | undefined): Promise<void> {
97    const resolved = resolvePanel(text);
98    const surfaces = resolveSurfaces(text);
99    const glyphs = resolveGlyphs(text);
100    this.file = resolved.settings;
101    this.fileSurfaces = surfaces.settings;
102    this.fileIndicators = glyphs.switches;
103    this.fileAscii = glyphs.ascii;
104    this.problems = [...resolved.problems, ...surfaces.problems];
105    this.fromFile = text !== undefined;
106    await this.reload();
107  }
108
109  private async reload(): Promise<void> {
110    this.overrides = panelOverridesOf(
111      await this.host?.storeGet(PANEL_SETTINGS_KEY).catch(() => undefined),
112    );
113    this.surfaceOverrides = surfaceOverridesOf(
114      await this.host?.storeGet(SURFACES_KEY).catch(() => undefined),
115    );
116    this.indicatorOverrides = switchesOf(
117      await this.host?.storeGet(INDICATORS_KEY).catch(() => undefined),
118    );
119  }
120
121  async change(next: PanelOverrides): Promise<void> {
122    this.overrides = { ...this.overrides, ...next };
123    await this.host?.storeSet(PANEL_SETTINGS_KEY, this.overrides).catch(() => undefined);
124    this.host?.invalidate();
125  }
126
127  /** Settings set a view's surfaces: drawn so from the next redraw. */
128  async changeSurface(view: string, setting: SurfaceSetting): Promise<void> {
129    this.surfaceOverrides = { ...this.surfaceOverrides, [view]: setting };
130    await this.host?.storeSet(SURFACES_KEY, this.surfaceOverrides).catch(() => undefined);
131    this.host?.invalidate();
132  }
133
134  /** Settings switched an indicator on or off (D-41): drawn so from the next redraw. */
135  async changeIndicator(id: string, on: boolean): Promise<void> {
136    this.indicatorOverrides = { ...this.indicatorOverrides, [id]: on };
137    await this.host?.storeSet(INDICATORS_KEY, this.indicatorOverrides).catch(() => undefined);
138    this.host?.invalidate();
139  }
140
141  /** Save to file wrote the settings: the file holds them now, so nothing is held. */
142  async saved(): Promise<void> {
143    const held = Object.keys(this.surfaceOverrides).length > 0;
144    const switched = Object.keys(this.indicatorOverrides).length > 0;
145    this.file = this.settings;
146    this.fileSurfaces = { ...this.surfaces };
147    this.fileIndicators = this.indicators;
148    this.overrides = {};
149    this.surfaceOverrides = {};
150    this.indicatorOverrides = {};
151    await this.host?.storeSet(PANEL_SETTINGS_KEY, {}).catch(() => undefined);
152    if (held) await this.host?.storeSet(SURFACES_KEY, {}).catch(() => undefined);
153    if (switched) await this.host?.storeSet(INDICATORS_KEY, {}).catch(() => undefined);
154  }
155}
156
hooks/store.ts 169 lines
1import {
2  type BandStore,
3  type BdWhere,
4  configCandidates,
5  directoryStores,
6  parseBdWhere,
7  parseStoreInventory,
8  type Result,
9  resolveStore,
10  STORE_INVENTORY_FILE,
11  type StoreResolution,
12  userConfigPath,
13  whereArgv,
14} from "../core/src/index.ts";
15import type { Host, SessionEnv } from "./host.ts";
16
17const WHERE_TIMEOUT_MS = 8_000;
18
19/** One resolution and the files it read, so the pane and the band read each once per refresh. */
20export interface Resolved {
21  resolution: StoreResolution;
22  /** The config file found first, as the pane's settings read it. */
23  config: { path: string | undefined; text: string | undefined };
24  inventory: Result<BandStore[], string>;
25  /** What the config file's `[directory_stores]` table got wrong. */
26  problems: string[];
27}
28
29const blank = (value: string | undefined) =>
30  value === undefined || value.trim() === "" ? undefined : value.trim();
31
32/**
33 * The session's name, the assignee of the beads it owns (D-17). `ai c` pins it in
34 * `CLAUDE_CODE_TASK_LIST_ID`. A session Claude Code's daemon hosts (moved to the background, or
35 * reached by `claude attach`) runs in a worker whose environment is the daemon's, so that
36 * variable there names whichever session started the daemon, often the firstmate; the worker's
37 * own session is the name Claude Code sets in `CLAUDE_CODE_SESSION_NAME`, less the ` (N)` Claude
38 * Code adds when an older copy already holds the bare name. A worker with no such name has none
39 * this session can prove, so it counts as unnamed and never takes the firstmate's form.
40 */
41function sessionNameOf(env: SessionEnv): string | undefined {
42  const hosted = env.kind === "bg" || env.backend === "daemon";
43  return blank(hosted ? env.name?.replace(/ \(\d+\)$/, "") : env.taskListId);
44}
45
46/**
47 * The store the session's pane and band read (D-24), worked out by core's resolveStore from the
48 * config file and the store inventory, both read on every call, and from one `bd where` in the
49 * session's directory, asked once a session: a directory does not gain or lose a store while a
50 * session runs in it, and bd's own answer covers worktrees and `BEADS_DIR`.
51 */
52export class StoreResolver {
53  private host: Host | undefined;
54  private session: string | undefined;
55  private env: { xdgConfigHome?: string; home?: string } = {};
56  private where: Promise<BdWhere | undefined> | undefined;
57
58  async bind(host: Host): Promise<void> {
59    this.host = host;
60    this.where = undefined;
61    const env = await host.sessionEnv().catch(() => undefined);
62    this.session = env === undefined ? undefined : sessionNameOf(env);
63    const { xdgConfigHome, home } = await host
64      .configEnv()
65      .catch(() => ({ xdgConfigHome: undefined, home: undefined }));
66    const xdg = blank(xdgConfigHome);
67    const dir = blank(home);
68    this.env = {
69      ...(xdg === undefined ? {} : { xdgConfigHome: xdg }),
70      ...(dir === undefined ? {} : { home: dir }),
71    };
72  }
73
74  /** The session's name (sessionNameOf). */
75  get sessionName(): string | undefined {
76    return this.session;
77  }
78
79  /** `HOME`, where the store inventory and the user's config and state directories live. */
80  get home(): string | undefined {
81    return this.env.home;
82  }
83
84  get xdgConfigHome(): string | undefined {
85    return this.env.xdgConfigHome;
86  }
87
88  async resolve(): Promise<Resolved> {
89    const host = this.host;
90    if (host === undefined) {
91      return {
92        resolution: { ok: false, tried: ["the session has not started"], fix: [] },
93        config: { path: undefined, text: undefined },
94        inventory: { ok: false, error: "the session has not started" },
95        problems: [],
96      };
97    }
98    const checked = configCandidates(host.cwd, this.env);
99    let config: Resolved["config"] = { path: undefined, text: undefined };
100    for (const path of checked) {
101      const text = await host.readFile(path).catch(() => undefined);
102      if (text !== undefined) {
103        config = { path, text };
104        break;
105      }
106    }
107    const inventoryPath =
108      this.env.home === undefined
109        ? undefined
110        : `${this.env.home.replace(/\/+$/, "")}/${STORE_INVENTORY_FILE}`;
111    const inventory = await this.inventory(inventoryPath);
112    const where = await this.whereCwd();
113    const resolution = resolveStore({
114      cwd: host.cwd,
115      config: { ...config, checked },
116      fixFile: checked[checked.length - 1] ?? checked[0] ?? "",
117      home: this.env.home,
118      cwdHasStore: where?.hasStore,
119      cwdPrefix: where?.prefix,
120      session: this.session,
121      inventory,
122      inventoryPath,
123    });
124    const { problems } = directoryStores(config.text, this.env.home);
125    return { resolution, config, inventory, problems };
126  }
127
128  /**
129   * The user's own `beads-ui.toml` (userConfigPath), never the repository's: the file `[sync]`
130   * is read from, so a checkout cannot name a command the mod runs.
131   */
132  async userConfigText(): Promise<string | undefined> {
133    const host = this.host;
134    const path = userConfigPath(this.env);
135    if (host === undefined || path === undefined) return undefined;
136    return host.readFile(path).catch(() => undefined);
137  }
138
139  private async inventory(path: string | undefined): Promise<Result<BandStore[], string>> {
140    const host = this.host;
141    if (host === undefined || path === undefined) {
142      return { ok: false, error: "no home directory to find the store inventory in" };
143    }
144    const text = await host.readFile(path).catch(() => undefined);
145    if (text === undefined) return { ok: false, error: `no store inventory at ${path}` };
146    return parseStoreInventory(text);
147  }
148
149  private whereCwd(): Promise<BdWhere | undefined> {
150    const host = this.host;
151    if (host === undefined) return Promise.resolve(undefined);
152    if (this.where === undefined) {
153      const asked = host
154        .run(whereArgv(), WHERE_TIMEOUT_MS)
155        .then(
156          (ran) => parseBdWhere(ran.exitCode, ran.stdout),
157          () => undefined,
158        )
159        .then((answer) => {
160          // bd did not say: ask again on the next refresh rather than keep a non-answer.
161          if (answer === undefined && this.where === asked) this.where = undefined;
162          return answer;
163        });
164      this.where = asked;
165    }
166    return this.where;
167  }
168}
169
hooks/stream.ts 54 lines
1import type { HookStream, ProcessSpawnChunk, ProcessSpawnResult, Timer } from "claude-code";
2
3/** What a whole streamed read came to once the child exited on its own. */
4export interface Streamed {
5  exitCode: number;
6  stdout: string;
7  stderr: string;
8}
9
10/**
11 * Reads a `$.process.spawn` stream to its end. Unlike `$.process.run`, which keeps only the first
12 * 4 MiB of each pipe, a spawned child's output arrives whole, so a large store's `bd list` is
13 * never cut. Rejects, naming why, when the child cannot start, is ended by a signal or is still
14 * running after `timeoutMs`; the deadline ends the loop, which kills the child.
15 *
16 * @param stream the spawned child's output, as `$.process.spawn` returns it
17 * @param timeoutMs how long the child may run
18 * @param after `$.clock.after`, for the deadline
19 */
20export async function readStream(
21  stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>,
22  timeoutMs: number,
23  after: (ms: number, fn: () => void) => Timer,
24): Promise<Streamed> {
25  let timedOut = false;
26  let expire: (reason: Error) => void = () => undefined;
27  const deadline = new Promise<never>((_, reject) => {
28    expire = reject;
29  });
30  const timer = after(timeoutMs, () => {
31    timedOut = true;
32    expire(new Error(`timed out after ${timeoutMs} ms`));
33    void stream.return(undefined as never).catch(() => undefined);
34  });
35  const read = (async () => {
36    const stdout: string[] = [];
37    const stderr: string[] = [];
38    for (;;) {
39      const step = await stream.next();
40      if (timedOut) throw new Error(`timed out after ${timeoutMs} ms`);
41      if (step.done === true) return { end: step.value, stdout, stderr };
42      (step.value.stream === "stdout" ? stdout : stderr).push(step.value.text);
43    }
44  })();
45  try {
46    const { end, stdout, stderr } = await Promise.race([read, deadline]);
47    if (end.code === null) throw new Error(`stopped by ${end.signal ?? "a signal"}`);
48    return { exitCode: end.code, stdout: stdout.join(""), stderr: stderr.join("") };
49  } finally {
50    timer.cancel();
51    read.catch(() => undefined);
52  }
53}
54