@a-t-h-i/bot-lobby 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +238 -10
  2. package/package.json +5 -2
  3. package/prompts/master.md +12 -0
  4. package/prompts/panel.md +44 -0
  5. package/prompts/planner.md +69 -0
  6. package/prompts/quickfix.md +41 -0
  7. package/src/execution/agent-runner.ts +7 -2
  8. package/src/execution/pi-runner.ts +25 -0
  9. package/src/index.ts +3 -0
  10. package/src/lobby/ask.ts +226 -0
  11. package/src/lobby/feed.ts +253 -0
  12. package/src/lobby/issues.ts +227 -0
  13. package/src/lobby/keys.ts +51 -0
  14. package/src/lobby/layout.ts +363 -0
  15. package/src/lobby/markdown.ts +48 -0
  16. package/src/lobby/planner.ts +581 -0
  17. package/src/lobby/quickfix.ts +227 -0
  18. package/src/lobby/runtime.ts +591 -0
  19. package/src/lobby/tabs/home.ts +242 -0
  20. package/src/lobby/tabs/issues.ts +72 -0
  21. package/src/lobby/tabs/metrics.ts +275 -0
  22. package/src/lobby/tabs/plan.ts +242 -0
  23. package/src/lobby/tabs/quickfix.ts +129 -0
  24. package/src/lobby/tabs/tasks.ts +243 -0
  25. package/src/lobby/view.ts +1610 -0
  26. package/src/pi/activity.ts +120 -0
  27. package/src/pi/commands.ts +19 -57
  28. package/src/pi/events.ts +5 -2
  29. package/src/pi/model-support.ts +34 -0
  30. package/src/pi/run-summary.ts +2 -0
  31. package/src/pi/settings-ui.ts +69 -1
  32. package/src/pi/start-task.ts +63 -0
  33. package/src/pi/tools.ts +2 -2
  34. package/src/pi/ui.ts +116 -55
  35. package/src/schemas/configuration.ts +102 -3
  36. package/src/schemas/findings.ts +6 -0
  37. package/src/schemas/task.ts +1 -0
  38. package/src/state/backlog.ts +106 -0
  39. package/src/state/comments.ts +136 -0
  40. package/src/state/metrics.ts +305 -0
  41. package/src/workflow/workflow.ts +39 -7
@@ -0,0 +1,227 @@
1
+ /**
2
+ * GitHub issues through the `gh` CLI: list the repository's open issues, read
3
+ * one with its comments, and file a new one. `gh` owns authentication and repo
4
+ * detection, so bot-lobby holds no token; every failure becomes one readable
5
+ * line (not installed, not signed in, not a GitHub repository).
6
+ */
7
+ import { execFile } from "node:child_process";
8
+
9
+ export interface ExecResult {
10
+ stdout: string;
11
+ stderr: string;
12
+ code: number;
13
+ }
14
+
15
+ /** Runs a command without a shell; `execCommand` in the extension, a fake in tests. */
16
+ export type Exec = (command: string, args: string[], options?: { cwd?: string; timeout?: number }) => Promise<ExecResult>;
17
+
18
+ /**
19
+ * `execFile` as an `Exec`: a non-zero exit resolves with its code and output,
20
+ * while a missing binary rejects with the spawn error (`ENOENT`), so the tab
21
+ * can say "gh is not installed" rather than "exited with code 1".
22
+ */
23
+ export const execCommand: Exec = (command, args, options) =>
24
+ new Promise((resolve, reject) => {
25
+ execFile(command, args, { cwd: options?.cwd, timeout: options?.timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
26
+ if (error && typeof (error as NodeJS.ErrnoException).code === "string") return reject(error);
27
+ const code = error ? (typeof (error as { code?: unknown }).code === "number" ? (error as { code: number }).code : 1) : 0;
28
+ resolve({ stdout: String(stdout), stderr: String(stderr), code });
29
+ });
30
+ });
31
+
32
+ export interface IssueSummary {
33
+ number: number;
34
+ title: string;
35
+ labels: string[];
36
+ author?: string;
37
+ updatedAt?: string;
38
+ url?: string;
39
+ }
40
+
41
+ export interface IssueComment {
42
+ author?: string;
43
+ body: string;
44
+ createdAt?: string;
45
+ }
46
+
47
+ export interface IssueDetail extends IssueSummary {
48
+ body: string;
49
+ state?: string;
50
+ comments: IssueComment[];
51
+ }
52
+
53
+ const TIMEOUT_MS = 20_000;
54
+ export const LIST_LIMIT = 50;
55
+
56
+ /** One readable line for a failed `gh` call. */
57
+ export function ghError(result: Partial<ExecResult> & { error?: string }): string {
58
+ const text = `${result.error ?? ""}\n${result.stderr ?? ""}`.trim();
59
+ if (result.code === 127 || /ENOENT|not found|command not found/i.test(text)) return "GitHub CLI (gh) is not installed — install it from https://cli.github.com and run `gh auth login`.";
60
+ if (/auth login|not logged|authentication|GH_TOKEN/i.test(text)) return "gh is not signed in — run `gh auth login` in a terminal.";
61
+ if (/not a git repository|no git remotes|could not determine|none of the git remotes/i.test(text)) return "This project has no GitHub remote gh can use.";
62
+ const first = text.split("\n").find((line) => line.trim()) ?? `gh exited with code ${result.code ?? "?"}`;
63
+ return first.length > 160 ? `${first.slice(0, 159)}…` : first;
64
+ }
65
+
66
+ async function gh(exec: Exec, cwd: string, args: string[]): Promise<string> {
67
+ let result: ExecResult;
68
+ try {
69
+ result = await exec("gh", args, { cwd, timeout: TIMEOUT_MS });
70
+ } catch (error) {
71
+ throw new Error(ghError({ error: (error as Error).message }));
72
+ }
73
+ if (result.code !== 0) throw new Error(ghError(result));
74
+ return result.stdout;
75
+ }
76
+
77
+ interface RawIssue {
78
+ number?: unknown;
79
+ title?: unknown;
80
+ body?: unknown;
81
+ state?: unknown;
82
+ url?: unknown;
83
+ updatedAt?: unknown;
84
+ author?: { login?: unknown } | null;
85
+ labels?: Array<{ name?: unknown }> | null;
86
+ comments?: Array<{ author?: { login?: unknown } | null; body?: unknown; createdAt?: unknown }> | null;
87
+ }
88
+
89
+ function str(value: unknown): string | undefined {
90
+ return typeof value === "string" && value.length > 0 ? value : undefined;
91
+ }
92
+
93
+ function summary(raw: RawIssue): IssueSummary | undefined {
94
+ if (typeof raw.number !== "number" || typeof raw.title !== "string") return undefined;
95
+ const author = str(raw.author?.login);
96
+ const updatedAt = str(raw.updatedAt);
97
+ const url = str(raw.url);
98
+ return {
99
+ number: raw.number,
100
+ title: raw.title,
101
+ labels: (raw.labels ?? []).map((label) => str(label?.name)).filter((name): name is string => Boolean(name)),
102
+ ...(author ? { author } : {}),
103
+ ...(updatedAt ? { updatedAt } : {}),
104
+ ...(url ? { url } : {}),
105
+ };
106
+ }
107
+
108
+ function parseJson<T>(stdout: string): T {
109
+ try {
110
+ return JSON.parse(stdout) as T;
111
+ } catch {
112
+ throw new Error("gh returned output bot-lobby could not read");
113
+ }
114
+ }
115
+
116
+ export async function listIssues(exec: Exec, cwd: string, limit = LIST_LIMIT): Promise<IssueSummary[]> {
117
+ const stdout = await gh(exec, cwd, ["issue", "list", "--state", "open", "--limit", String(limit), "--json", "number,title,labels,author,updatedAt,url"]);
118
+ const raw = parseJson<RawIssue[]>(stdout);
119
+ return (Array.isArray(raw) ? raw : []).map(summary).filter((issue): issue is IssueSummary => Boolean(issue));
120
+ }
121
+
122
+ export async function viewIssue(exec: Exec, cwd: string, number: number): Promise<IssueDetail> {
123
+ const stdout = await gh(exec, cwd, ["issue", "view", String(number), "--json", "number,title,body,state,labels,author,url,updatedAt,comments"]);
124
+ const raw = parseJson<RawIssue>(stdout);
125
+ const base = summary(raw);
126
+ if (!base) throw new Error(`gh returned no issue #${number}`);
127
+ const state = str(raw.state);
128
+ return {
129
+ ...base,
130
+ body: typeof raw.body === "string" ? raw.body : "",
131
+ ...(state ? { state } : {}),
132
+ comments: (raw.comments ?? []).map((comment) => {
133
+ const author = str(comment?.author?.login);
134
+ const createdAt = str(comment?.createdAt);
135
+ return { body: typeof comment?.body === "string" ? comment.body : "", ...(author ? { author } : {}), ...(createdAt ? { createdAt } : {}) };
136
+ }),
137
+ };
138
+ }
139
+
140
+ /** First non-empty line is the title, the rest is the body. */
141
+ export function splitIssueText(text: string): { title: string; body: string } {
142
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
143
+ const first = lines.findIndex((line) => line.trim());
144
+ if (first < 0) return { title: "", body: "" };
145
+ return { title: lines[first]!.trim().replace(/^#+\s*/, ""), body: lines.slice(first + 1).join("\n").trim() };
146
+ }
147
+
148
+ /** File an issue; returns its number and URL as `gh` prints it. */
149
+ export async function createIssue(exec: Exec, cwd: string, title: string, body: string): Promise<{ number?: number; url: string }> {
150
+ if (!title.trim()) throw new Error("an issue needs a title (the first line)");
151
+ const stdout = await gh(exec, cwd, ["issue", "create", "--title", title.trim(), "--body", body.trim() || " "]);
152
+ const url = stdout.trim().split("\n").reverse().find((line) => /^https?:\/\//.test(line.trim()))?.trim() ?? stdout.trim();
153
+ const number = Number(/\/issues\/(\d+)/.exec(url)?.[1]);
154
+ return { url, ...(Number.isFinite(number) ? { number } : {}) };
155
+ }
156
+
157
+ /** The issue as text for the planner: body plus comments. */
158
+ export function issueText(issue: IssueDetail): string {
159
+ const comments = issue.comments
160
+ .filter((comment) => comment.body.trim())
161
+ .map((comment) => `**${comment.author ?? "someone"}** commented:\n${comment.body.trim()}`);
162
+ return [issue.body.trim() || "(no description)", ...comments].join("\n\n");
163
+ }
164
+
165
+ /** Lobby state for the Issues tab: the list, the open issue, and what is loading. */
166
+ export class IssuesState {
167
+ issues: IssueSummary[] = [];
168
+ details = new Map<number, IssueDetail>();
169
+ loading = false;
170
+ loaded = false;
171
+ error?: string;
172
+ notice?: string;
173
+ private readonly exec: Exec;
174
+ private readonly cwd: string;
175
+ private readonly onChange: () => void;
176
+
177
+ constructor(exec: Exec, cwd: string, onChange: () => void = () => {}) {
178
+ this.exec = exec;
179
+ this.cwd = cwd;
180
+ this.onChange = onChange;
181
+ }
182
+
183
+ async refresh(): Promise<void> {
184
+ if (this.loading) return;
185
+ this.loading = true;
186
+ this.error = undefined;
187
+ this.onChange();
188
+ try {
189
+ this.issues = await listIssues(this.exec, this.cwd);
190
+ this.loaded = true;
191
+ } catch (error) {
192
+ this.error = (error as Error).message;
193
+ } finally {
194
+ this.loading = false;
195
+ this.onChange();
196
+ }
197
+ }
198
+
199
+ async detail(number: number): Promise<IssueDetail | undefined> {
200
+ const cached = this.details.get(number);
201
+ if (cached) return cached;
202
+ try {
203
+ const detail = await viewIssue(this.exec, this.cwd, number);
204
+ this.details.set(number, detail);
205
+ this.onChange();
206
+ return detail;
207
+ } catch (error) {
208
+ this.error = (error as Error).message;
209
+ this.onChange();
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ async create(text: string): Promise<void> {
215
+ const { title, body } = splitIssueText(text);
216
+ try {
217
+ const created = await createIssue(this.exec, this.cwd, title, body);
218
+ this.notice = `created ${created.number ? `#${created.number}` : "issue"} — ${created.url}`;
219
+ this.error = undefined;
220
+ this.onChange();
221
+ await this.refresh();
222
+ } catch (error) {
223
+ this.error = (error as Error).message;
224
+ this.onChange();
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The lobby's shortcuts in one table: every action has a default key, a line
3
+ * for the help overlay, and can be rebound under `lobby.keys` in the config
4
+ * (`{ "toggleThinking": "alt+t" }`). They work in typing and browsing mode
5
+ * alike, so each default is a key that never types a character.
6
+ */
7
+ import { matchesKey, type KeyId } from "@earendil-works/pi-tui";
8
+
9
+ export const LOBBY_ACTIONS = {
10
+ hide: { key: "alt+l", help: "hide the lobby (back to pi)" },
11
+ help: { key: "alt+h", help: "show or hide these keys" },
12
+ settings: { key: "alt+s", help: "bot-lobby settings: each agent's model and thinking, the lobby" },
13
+ search: { key: "ctrl+f", help: "search the current tab" },
14
+ nextTab: { key: "tab", help: "next tab" },
15
+ prevTab: { key: "shift+tab", help: "previous tab" },
16
+ toggleScene: { key: "alt+z", help: "show or hide the zen scene" },
17
+ toggleConversation: { key: "alt+c", help: "show or hide the conversation" },
18
+ toggleActivity: { key: "alt+a", help: "show or hide the activity log" },
19
+ toggleThinking: { key: "alt+k", help: "show or hide thinking" },
20
+ scrollUp: { key: "pageUp", help: "scroll up a page" },
21
+ scrollDown: { key: "pageDown", help: "scroll down a page" },
22
+ } as const;
23
+
24
+ export type LobbyAction = keyof typeof LOBBY_ACTIONS;
25
+
26
+ export type KeyMap = Record<LobbyAction, string>;
27
+
28
+ /** Defaults with the config's overrides on top; unknown action names are ignored. */
29
+ export function keyMap(overrides: Readonly<Record<string, string>> = {}): KeyMap {
30
+ const map = Object.fromEntries(Object.entries(LOBBY_ACTIONS).map(([action, entry]) => [action, entry.key])) as KeyMap;
31
+ for (const [action, key] of Object.entries(overrides)) {
32
+ if (action in map && key.trim()) map[action as LobbyAction] = key.trim().toLowerCase();
33
+ }
34
+ return map;
35
+ }
36
+
37
+ /** The action `data` triggers under `map`, if any. */
38
+ export function actionFor(data: string, map: KeyMap): LobbyAction | undefined {
39
+ for (const action of Object.keys(map) as LobbyAction[]) {
40
+ if (matchesKey(data, map[action] as KeyId)) return action;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ /** `alt+k` reads `Alt+K` in hints and help. */
46
+ export function keyLabel(key: string): string {
47
+ return key
48
+ .split("+")
49
+ .map((part) => (part.length === 1 ? part.toUpperCase() : part[0]!.toUpperCase() + part.slice(1)))
50
+ .join("+");
51
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Pure layout helpers for the lobby: exact-width cells, wrapped paragraphs,
3
+ * section rules, side-by-side columns and scroll windows. Every function takes
4
+ * its widths and heights explicitly and never reads a clock or the terminal,
5
+ * so each tab renders deterministically in tests.
6
+ */
7
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+
9
+ /** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
10
+ export type LobbyColor =
11
+ | "accent"
12
+ | "text"
13
+ | "muted"
14
+ | "dim"
15
+ | "success"
16
+ | "warning"
17
+ | "error"
18
+ | "border"
19
+ | "borderAccent"
20
+ | "borderMuted"
21
+ | "toolTitle"
22
+ | "mdHeading"
23
+ | "mdCode";
24
+
25
+ export interface LobbyTheme {
26
+ fg(color: LobbyColor, text: string): string;
27
+ bold(text: string): string;
28
+ italic?(text: string): string;
29
+ bg?(color: "selectedBg" | "searchMatchBg", text: string): string;
30
+ /** Render Markdown to styled lines; plain wrapping without it (tests). */
31
+ markdown?(text: string, width: number): string[];
32
+ }
33
+
34
+ /** Paint only when a theme is present; tests without one get plain text. */
35
+ export function paint(theme: LobbyTheme | undefined, color: LobbyColor, text: string): string {
36
+ return theme && text ? theme.fg(color, text) : text;
37
+ }
38
+
39
+ export function bold(theme: LobbyTheme | undefined, text: string): string {
40
+ return theme && text ? theme.bold(text) : text;
41
+ }
42
+
43
+ export function italic(theme: LobbyTheme | undefined, text: string): string {
44
+ return theme?.italic && text ? theme.italic(text) : text;
45
+ }
46
+
47
+ /** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
48
+ export function fit(text: string, width: number): string {
49
+ if (width <= 0) return "";
50
+ const cut = visibleWidth(text) > width ? truncateToWidth(text, width, "…") : text;
51
+ const pad = width - visibleWidth(cut);
52
+ return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
53
+ }
54
+
55
+ /** Word-wrap text (ANSI-safe) to `width`, keeping blank lines; tabs become spaces. */
56
+ export function wrap(text: string, width: number): string[] {
57
+ if (width <= 0) return [];
58
+ const lines: string[] = [];
59
+ for (const raw of text.replace(/\t/g, " ").split("\n")) {
60
+ if (!raw.trim()) {
61
+ lines.push("");
62
+ continue;
63
+ }
64
+ lines.push(...wrapTextWithAnsi(raw, width));
65
+ }
66
+ return lines;
67
+ }
68
+
69
+ /** Wrap with a hanging indent: the first line carries `lead`, the rest align under it. */
70
+ export function wrapHanging(lead: string, text: string, width: number): string[] {
71
+ const indent = visibleWidth(lead);
72
+ const body = wrap(text, Math.max(1, width - indent));
73
+ if (body.length === 0) return [lead];
74
+ return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
75
+ }
76
+
77
+ /** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
78
+ export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
79
+ if (width <= 0) return "";
80
+ const head = title ? `── ${title} ` : "";
81
+ const tail = right ? ` ${right} ──` : "";
82
+ const fill = width - visibleWidth(head) - visibleWidth(tail);
83
+ if (fill < 1) return fit(paint(theme, color, head.trimEnd() || "─".repeat(width)), width);
84
+ const titled = title ? `${paint(theme, color, "── ")}${bold(theme, paint(theme, "accent", title))}${paint(theme, color, " ")}` : "";
85
+ const ending = right ? `${paint(theme, color, " ")}${paint(theme, "muted", right)}${paint(theme, color, " ──")}` : "";
86
+ return `${titled}${paint(theme, color, "─".repeat(fill))}${ending}`;
87
+ }
88
+
89
+ /** Exactly `height` lines: extra lines dropped from the end, missing ones blank. */
90
+ export function fill(lines: readonly string[], height: number, width?: number): string[] {
91
+ const out = lines.slice(0, Math.max(0, height));
92
+ while (out.length < height) out.push("");
93
+ return width === undefined ? out : out.map((line) => fit(line, width));
94
+ }
95
+
96
+ /** The last `height` lines, shifted up by `offset` lines of scrollback. */
97
+ export function tail(lines: readonly string[], height: number, offset = 0): string[] {
98
+ if (height <= 0) return [];
99
+ const end = Math.max(Math.min(lines.length, height), lines.length - Math.max(0, offset));
100
+ return lines.slice(Math.max(0, end - height), end);
101
+ }
102
+
103
+ /** The largest useful scroll-back offset for `lines` in a window of `height`. */
104
+ export function maxOffset(lineCount: number, height: number): number {
105
+ return Math.max(0, lineCount - height);
106
+ }
107
+
108
+ /** First index to show so `selected` stays visible in a window of `height` rows. */
109
+ export function windowStart(selected: number, count: number, height: number): number {
110
+ if (height <= 0 || count <= height) return 0;
111
+ const half = Math.floor(height / 2);
112
+ return Math.max(0, Math.min(count - height, selected - half));
113
+ }
114
+
115
+ /** Two columns side by side, each cell fitted to its width, `gap` columns apart. */
116
+ export function columns(left: readonly string[], right: readonly string[], leftWidth: number, rightWidth: number, gap = " │ ", theme?: LobbyTheme): string[] {
117
+ const rows = Math.max(left.length, right.length);
118
+ const separator = paint(theme, "borderMuted", gap);
119
+ const out: string[] = [];
120
+ for (let i = 0; i < rows; i++) out.push(`${fit(left[i] ?? "", leftWidth)}${separator}${fit(right[i] ?? "", rightWidth)}`);
121
+ return out;
122
+ }
123
+
124
+ /** Split `width` into two columns plus a gap; the left gets `share` of the space. */
125
+ export function split(width: number, share: number, gap = 3, minRight = 24): [number, number] {
126
+ const usable = Math.max(0, width - gap);
127
+ const left = Math.max(10, Math.min(usable - minRight, Math.round(usable * share)));
128
+ return [left, Math.max(0, usable - left)];
129
+ }
130
+
131
+ /** `12:04` in local time. */
132
+ export function clock(at: number): string {
133
+ const date = new Date(at);
134
+ if (!Number.isFinite(date.getTime())) return "--:--";
135
+ return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
136
+ }
137
+
138
+ /** Compact age: `now`, `45s`, `12m`, `3h`, `2d`. */
139
+ export function ago(ms: number): string {
140
+ if (!Number.isFinite(ms) || ms < 5_000) return "now";
141
+ const seconds = Math.floor(ms / 1000);
142
+ if (seconds < 60) return `${seconds}s`;
143
+ const minutes = Math.floor(seconds / 60);
144
+ if (minutes < 60) return `${minutes}m`;
145
+ const hours = Math.floor(minutes / 60);
146
+ if (hours < 48) return `${hours}h`;
147
+ return `${Math.floor(hours / 24)}d`;
148
+ }
149
+
150
+ /** `just now` or `12m ago`. */
151
+ export function since(ms: number): string {
152
+ const age = ago(ms);
153
+ return age === "now" ? "just now" : `${age} ago`;
154
+ }
155
+
156
+ /** Highlight a selected row: the theme's selection background, or a leading marker without one. */
157
+ export function selectRow(theme: LobbyTheme | undefined, text: string, width: number, selected: boolean, focused = true): string {
158
+ const cell = fit(text, width);
159
+ if (!selected) return cell;
160
+ if (theme?.bg && focused) return theme.bg("selectedBg", cell);
161
+ return cell;
162
+ }
163
+
164
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
165
+
166
+ export function spinner(tick: number): string {
167
+ return SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length]!;
168
+ }
169
+
170
+ /** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
171
+ export function markdownLines(text: string, width: number, theme?: LobbyTheme): string[] {
172
+ if (theme?.markdown) return theme.markdown(text, width);
173
+ return text.split("\n").flatMap((line) => {
174
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
175
+ return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
176
+ });
177
+ }
178
+
179
+ /** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
180
+ export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
181
+ const indent = visibleWidth(lead);
182
+ const body = markdownLines(text, Math.max(1, width - indent), theme);
183
+ if (body.length === 0) return [lead];
184
+ return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
185
+ }
186
+
187
+ export interface BoxOptions {
188
+ title?: string;
189
+ /** Muted text at the right end of the top border. */
190
+ right?: string;
191
+ /** The box that has the keyboard: its border takes the accent colour. */
192
+ focused?: boolean;
193
+ theme?: LobbyTheme;
194
+ /** Content longer than the box: `total` lines, the first shown at `start`; drawn as a thumb on the right border. */
195
+ scroll?: { total: number; start: number };
196
+ }
197
+
198
+ /** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
199
+ export interface PaneBox {
200
+ top: number;
201
+ left: number;
202
+ width: number;
203
+ height: number;
204
+ /** Lines of content, and rows showing them at once. */
205
+ total: number;
206
+ rows: number;
207
+ }
208
+
209
+ /** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
210
+ export type PaneLayout = Map<string, PaneBox>;
211
+
212
+ /** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
213
+ export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number): void {
214
+ panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2) });
215
+ }
216
+
217
+ /** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
218
+ export function detailWindow(total: number, rows: number, offset: number): number {
219
+ return Math.max(0, Math.min(offset, total - Math.max(1, rows)));
220
+ }
221
+
222
+ /** `12–40/96`: the lines a pane shows out of all it holds. */
223
+ export function position(start: number, rows: number, total: number): string {
224
+ return `${start + 1}–${Math.min(total, start + rows)}/${total}`;
225
+ }
226
+
227
+ /** The rows of a `rows`-tall track that the thumb covers, or undefined when everything fits. */
228
+ export function scrollThumb(total: number, rows: number, start: number): { from: number; to: number } | undefined {
229
+ if (rows <= 0 || total <= rows) return undefined;
230
+ const size = Math.max(1, Math.round((rows * rows) / total));
231
+ const max = total - rows;
232
+ const from = Math.round((Math.min(max, Math.max(0, start)) / max) * (rows - size));
233
+ return { from, to: from + size };
234
+ }
235
+
236
+ /**
237
+ * A rounded panel exactly `width` × `height`: the title set into the top
238
+ * border, `content` inside with one column of padding, extra lines cut and
239
+ * missing ones blank. Below 4 columns or 2 rows it degrades to plain lines.
240
+ */
241
+ export function box(width: number, height: number, content: readonly string[], options: BoxOptions = {}): string[] {
242
+ if (height <= 0) return [];
243
+ if (width < 4 || height < 2) return fill(content, height, Math.max(0, width));
244
+ const { theme, focused } = options;
245
+ const edge = (text: string) => paint(theme, focused ? "borderAccent" : "borderMuted", text);
246
+ const inner = width - 4;
247
+ const title = options.title ? ` ${options.title} ` : "";
248
+ const right = options.right ? ` ${options.right} ` : "";
249
+ let room = width - 2 - visibleWidth(title) - visibleWidth(right);
250
+ const shownRight = room >= 1 ? right : "";
251
+ room = width - 2 - visibleWidth(title) - visibleWidth(shownRight);
252
+ const titled = room >= 1 ? title : fit(title, Math.max(0, width - 3));
253
+ const fillWidth = Math.max(0, width - 2 - visibleWidth(titled) - visibleWidth(shownRight));
254
+ const paintedTitle = titled ? bold(theme, paint(theme, focused ? "accent" : "text", titled)) : "";
255
+ const top = `${edge("╭")}${paintedTitle}${edge("─".repeat(fillWidth))}${shownRight ? paint(theme, "dim", shownRight) : ""}${edge("╮")}`;
256
+ const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
257
+ const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
258
+ const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
259
+ return [top, ...rows, `${edge("╰")}${edge("─".repeat(width - 2))}${edge("╯")}`];
260
+ }
261
+
262
+ /** Lay boxes out side by side, each already exactly its width and the same height. */
263
+ export function beside(panes: ReadonlyArray<readonly string[]>, gap = " "): string[] {
264
+ const height = Math.max(0, ...panes.map((pane) => pane.length));
265
+ return Array.from({ length: height }, (_, row) => panes.map((pane) => pane[row] ?? "").join(gap));
266
+ }
267
+
268
+ /** Escape sequences (CSI, OSC, APC) that take no columns. */
269
+ const ESCAPE = /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|_[^\x07\x1b]*(?:\x07|\x1b\\))/y;
270
+
271
+ /**
272
+ * Mark every case-insensitive occurrence of `query` in a styled line with
273
+ * reverse video. Escape sequences are skipped when matching and kept intact,
274
+ * and reverse video is switched off (not reset) so the line's own colours
275
+ * carry on after each match.
276
+ */
277
+ export function highlight(line: string, query: string): string {
278
+ const needle = query.trim().toLowerCase();
279
+ if (!needle) return line;
280
+ // Visible characters with their raw offsets.
281
+ const chars: Array<{ ch: string; at: number }> = [];
282
+ for (let i = 0; i < line.length; ) {
283
+ ESCAPE.lastIndex = i;
284
+ const escape = ESCAPE.exec(line);
285
+ if (escape) {
286
+ i += escape[0].length;
287
+ continue;
288
+ }
289
+ const point = line.codePointAt(i)!;
290
+ const ch = String.fromCodePoint(point);
291
+ chars.push({ ch, at: i });
292
+ i += ch.length;
293
+ }
294
+ const visible = chars.map((entry) => entry.ch).join("").toLowerCase();
295
+ const starts: Array<[number, number]> = [];
296
+ for (let from = visible.indexOf(needle); from >= 0; from = visible.indexOf(needle, from + needle.length)) starts.push([from, from + needle.length]);
297
+ if (starts.length === 0) return line;
298
+ // Map visible string offsets back to char indexes (they differ only for astral characters).
299
+ const charAt: number[] = [];
300
+ chars.forEach((entry, index) => {
301
+ for (let k = 0; k < entry.ch.length; k++) charAt.push(index);
302
+ });
303
+ let out = "";
304
+ let cursor = 0;
305
+ for (const [start, end] of starts) {
306
+ const from = chars[charAt[start]!]!.at;
307
+ const lastChar = chars[charAt[end - 1]!]!;
308
+ const to = lastChar.at + lastChar.ch.length;
309
+ out += `${line.slice(cursor, from)}\x1b[7m${line.slice(from, to)}\x1b[27m`;
310
+ cursor = to;
311
+ }
312
+ return out + line.slice(cursor);
313
+ }
314
+
315
+ const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
316
+
317
+ /** A horizontal bar `value / max` of `width` cells, with eighth-cell precision at its end. */
318
+ export function bar(value: number, max: number, width: number): string {
319
+ if (width <= 0 || !Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || value <= 0) return "";
320
+ const cells = Math.min(width, (value / max) * width);
321
+ const whole = Math.floor(cells);
322
+ const part = EIGHTHS[Math.round((cells - whole) * 8)] ?? "";
323
+ // A non-zero value always shows at least a sliver.
324
+ return `${"█".repeat(whole)}${part}` || "▏";
325
+ }
326
+
327
+ /** A meter `fraction` full: filled cells over a dim track, exactly `width` wide. */
328
+ export function meter(fraction: number, width: number, fillPaint: (text: string) => string, trackPaint: (text: string) => string): string {
329
+ if (width <= 0) return "";
330
+ const safe = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
331
+ const filled = Math.round(safe * width);
332
+ return `${fillPaint("━".repeat(filled))}${trackPaint("─".repeat(width - filled))}`;
333
+ }
334
+
335
+ const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
336
+
337
+ /** A sparkline of the last `width` values, scaled between their min and max. */
338
+ export function sparkline(values: readonly number[], width: number): string {
339
+ const shown = values.filter((value) => Number.isFinite(value)).slice(-Math.max(0, width));
340
+ if (shown.length === 0) return "";
341
+ const low = Math.min(...shown);
342
+ const high = Math.max(...shown);
343
+ const span = high - low;
344
+ return shown.map((value) => SPARKS[span === 0 ? 3 : Math.min(7, Math.floor(((value - low) / span) * 8))]).join("");
345
+ }
346
+
347
+ /**
348
+ * A part-to-whole bar: each segment's share of `width` cells in its own
349
+ * colour, rounded so the segments always fill the bar exactly.
350
+ */
351
+ export function stackedBar(segments: ReadonlyArray<{ value: number; paint: (text: string) => string }>, width: number): string {
352
+ const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
353
+ if (width <= 0 || total <= 0) return "";
354
+ let used = 0;
355
+ let acc = 0;
356
+ return segments.map((segment) => {
357
+ acc += Math.max(0, segment.value);
358
+ const end = Math.round((acc / total) * width);
359
+ const cells = end - used;
360
+ used = end;
361
+ return cells > 0 ? segment.paint("█".repeat(cells)) : "";
362
+ }).join("");
363
+ }