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…

A task manager for Beads issues, designed to run in three places from one shared core. Only the first exists today:
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.
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.
How focus reaches the pane and leaves it, and how to close and reopen it. The pane's help (h) shows the same.
{
"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.
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.
| Path | What 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.mjs | The purity gate npm run lint runs on the shared core |
.claude-plugin/marketplace.json | The marketplace that lists the mod, ./plugins/beads-ui |
scripts/check-marketplace.mjs | The pre-commit gate that runs claude plugin validate . and checks the marketplace lists the mod |
scripts/check-plugin-size.mjs | The 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.mjs | The 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.
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.
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.
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).
hooks/register.ts 265 lines1import 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}
265core/src/index.ts 723 lines1export {
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";
723hooks/bandSettings.ts 315 lines1import 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);
315hooks/bands.ts 36 lines1import 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}
36hooks/components/adapters.ts 126 lines1import 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;
126hooks/confirm.ts 225 lines1import 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}
225hooks/host.ts 96 lines1import 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}
96hooks/levels.ts 667 lines1import 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}
667hooks/pane.ts 3109 lines1import 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 lines1import {
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}
156hooks/store.ts 169 lines1import {
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}
169hooks/stream.ts 54 lines1import 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