@a-t-h-i/bot-lobby 0.6.13 → 0.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -79
- package/package.json +4 -2
- package/prompts/master.md +3 -2
- package/prompts/qa.md +33 -11
- package/prompts/reviewer.md +5 -2
- package/prompts/worker.md +5 -2
- package/src/ask/relay.ts +1 -2
- package/src/ask/tool.ts +4 -6
- package/src/ask/types.ts +16 -0
- package/src/ask/web.ts +63 -0
- package/src/index.ts +7 -1
- package/src/lobby/ask.ts +4 -5
- package/src/lobby/blocks.ts +57 -0
- package/src/lobby/feed.ts +1 -1
- package/src/lobby/host.ts +178 -0
- package/src/lobby/keys.ts +10 -41
- package/src/lobby/prompt-hub.ts +30 -77
- package/src/lobby/prompts.ts +8 -12
- package/src/lobby/runtime.ts +49 -310
- package/src/lobby/service.ts +54 -134
- package/src/lobby/task-rows.ts +28 -0
- package/src/pi/commands.ts +14 -20
- package/src/pi/model-settings.ts +112 -0
- package/src/pi/start-task.ts +1 -1
- package/src/pi/tools.ts +6 -7
- package/src/pi/ui.ts +0 -9
- package/src/schemas/configuration.ts +9 -33
- package/src/state/attachments.ts +82 -0
- package/src/webui/api/index.ts +17 -15
- package/src/webui/api/lobby.ts +4 -3
- package/src/webui/api/planner.ts +6 -4
- package/src/webui/api/quickfix.ts +4 -3
- package/src/webui/api/sessions.ts +9 -7
- package/src/webui/api/settings.ts +20 -8
- package/src/webui/api/status.ts +4 -15
- package/src/webui/api/tasks.ts +11 -15
- package/src/webui/command.ts +35 -19
- package/src/webui/dev/fake-service.ts +1 -2
- package/src/webui/dev/fixtures.ts +2 -5
- package/src/webui/events.ts +1 -1
- package/src/webui/protocol.ts +22 -11
- package/src/webui/server.ts +42 -1
- package/src/webui/uploads.ts +68 -0
- package/webui/dist/assets/index-B1PnCyl2.css +2 -0
- package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
- package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/webui/dist/build.json +142 -110
- package/webui/dist/index.html +4 -3
- package/src/ask/dialog.ts +0 -193
- package/src/ask/image.ts +0 -202
- package/src/ask/png.ts +0 -179
- package/src/ask/state.ts +0 -175
- package/src/ask/view.ts +0 -163
- package/src/lobby/layout.ts +0 -496
- package/src/lobby/markdown.ts +0 -121
- package/src/lobby/mini.ts +0 -167
- package/src/lobby/tabs/excalidraw.ts +0 -95
- package/src/lobby/tabs/git.ts +0 -162
- package/src/lobby/tabs/home.ts +0 -544
- package/src/lobby/tabs/issues.ts +0 -73
- package/src/lobby/tabs/knowledge.ts +0 -135
- package/src/lobby/tabs/metrics.ts +0 -300
- package/src/lobby/tabs/plan.ts +0 -262
- package/src/lobby/tabs/quickfix.ts +0 -136
- package/src/lobby/tabs/tasks.ts +0 -412
- package/src/lobby/theme.ts +0 -30
- package/src/lobby/view.ts +0 -3018
- package/src/pi/settings-ui.ts +0 -708
- package/src/width.ts +0 -102
- package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
- package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/lobby/layout.ts
DELETED
|
@@ -1,496 +0,0 @@
|
|
|
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, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
-
import { textWidth } from "../width.ts";
|
|
9
|
-
import type { MarkdownStyle } from "./markdown.ts";
|
|
10
|
-
|
|
11
|
-
/** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
|
|
12
|
-
export type LobbyColor =
|
|
13
|
-
| "accent"
|
|
14
|
-
| "text"
|
|
15
|
-
| "muted"
|
|
16
|
-
| "dim"
|
|
17
|
-
| "success"
|
|
18
|
-
| "warning"
|
|
19
|
-
| "error"
|
|
20
|
-
| "border"
|
|
21
|
-
| "borderAccent"
|
|
22
|
-
| "borderMuted"
|
|
23
|
-
| "toolTitle"
|
|
24
|
-
| "mdHeading"
|
|
25
|
-
| "mdCode"
|
|
26
|
-
| "userMessageText";
|
|
27
|
-
|
|
28
|
-
export interface LobbyTheme {
|
|
29
|
-
fg(color: LobbyColor, text: string): string;
|
|
30
|
-
bold(text: string): string;
|
|
31
|
-
italic?(text: string): string;
|
|
32
|
-
bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
|
|
33
|
-
/** Render Markdown to styled lines, in the look of whose words they are; plain wrapping without it (tests). */
|
|
34
|
-
markdown?(text: string, width: number, keep?: boolean, style?: MarkdownStyle): string[];
|
|
35
|
-
/** Strike text through (an abandoned task's title); plain without it (tests). */
|
|
36
|
-
strike?(text: string): string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Paint only when a theme is present; tests without one get plain text. */
|
|
40
|
-
export function paint(theme: LobbyTheme | undefined, color: LobbyColor, text: string): string {
|
|
41
|
-
return theme && text ? theme.fg(color, text) : text;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function bold(theme: LobbyTheme | undefined, text: string): string {
|
|
45
|
-
return theme && text ? theme.bold(text) : text;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function italic(theme: LobbyTheme | undefined, text: string): string {
|
|
49
|
-
return theme?.italic && text ? theme.italic(text) : text;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function strike(theme: LobbyTheme | undefined, text: string): string {
|
|
53
|
-
return theme?.strike && text ? theme.strike(text) : text;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** `left` and `right` on one line of exactly `width` columns: the left side gives way first. */
|
|
57
|
-
export function spread(left: string, right: string, width: number): string {
|
|
58
|
-
if (!right) return fit(left, width);
|
|
59
|
-
const room = width - textWidth(right) - 1;
|
|
60
|
-
if (room < 4) return fit(left, width);
|
|
61
|
-
return `${fit(left, room)} ${right}`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
|
|
65
|
-
export function fit(text: string, width: number): string {
|
|
66
|
-
if (width <= 0) return "";
|
|
67
|
-
const cut = textWidth(text) > width ? truncateToWidth(text, width, "…") : text;
|
|
68
|
-
const pad = width - textWidth(cut);
|
|
69
|
-
return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Word-wrap text (ANSI-safe) to `width`, keeping blank lines; tabs become spaces. */
|
|
73
|
-
export function wrap(text: string, width: number): string[] {
|
|
74
|
-
if (width <= 0) return [];
|
|
75
|
-
const lines: string[] = [];
|
|
76
|
-
for (const raw of text.replace(/\t/g, " ").split("\n")) {
|
|
77
|
-
if (!raw.trim()) {
|
|
78
|
-
lines.push("");
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
lines.push(...wrapTextWithAnsi(raw, width));
|
|
82
|
-
}
|
|
83
|
-
return lines;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Wrap with a hanging indent: the first line carries `lead`, the rest align under it. */
|
|
87
|
-
export function wrapHanging(lead: string, text: string, width: number): string[] {
|
|
88
|
-
const indent = textWidth(lead);
|
|
89
|
-
const body = wrap(text, Math.max(1, width - indent));
|
|
90
|
-
if (body.length === 0) return [lead];
|
|
91
|
-
return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** The mark before a git branch name, in the lobby's title and a task's details. */
|
|
95
|
-
export const BRANCH_GLYPH = "⎇";
|
|
96
|
-
|
|
97
|
-
/** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
|
|
98
|
-
export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
|
|
99
|
-
if (width <= 0) return "";
|
|
100
|
-
const head = title ? `── ${title} ` : "";
|
|
101
|
-
const tail = right ? ` ${right} ──` : "";
|
|
102
|
-
const fill = width - textWidth(head) - textWidth(tail);
|
|
103
|
-
if (fill < 1) return fit(paint(theme, color, head.trimEnd() || "─".repeat(width)), width);
|
|
104
|
-
const titled = title ? `${paint(theme, color, "── ")}${bold(theme, paint(theme, "accent", title))}${paint(theme, color, " ")}` : "";
|
|
105
|
-
const ending = right ? `${paint(theme, color, " ")}${paint(theme, "muted", right)}${paint(theme, color, " ──")}` : "";
|
|
106
|
-
return `${titled}${paint(theme, color, "─".repeat(fill))}${ending}`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Exactly `height` lines: extra lines dropped from the end, missing ones blank. */
|
|
110
|
-
export function fill(lines: readonly string[], height: number, width?: number): string[] {
|
|
111
|
-
const out = lines.slice(0, Math.max(0, height));
|
|
112
|
-
while (out.length < height) out.push("");
|
|
113
|
-
return width === undefined ? out : out.map((line) => fit(line, width));
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** The last `height` lines, shifted up by `offset` lines of scrollback. */
|
|
117
|
-
export function tail(lines: readonly string[], height: number, offset = 0): string[] {
|
|
118
|
-
if (height <= 0) return [];
|
|
119
|
-
const end = Math.max(Math.min(lines.length, height), lines.length - Math.max(0, offset));
|
|
120
|
-
return lines.slice(Math.max(0, end - height), end);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** The largest useful scroll-back offset for `lines` in a window of `height`. */
|
|
124
|
-
export function maxOffset(lineCount: number, height: number): number {
|
|
125
|
-
return Math.max(0, lineCount - height);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** First index to show so `selected` stays visible in a window of `height` rows. */
|
|
129
|
-
export function windowStart(selected: number, count: number, height: number): number {
|
|
130
|
-
if (height <= 0 || count <= height) return 0;
|
|
131
|
-
const half = Math.floor(height / 2);
|
|
132
|
-
return Math.max(0, Math.min(count - height, selected - half));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Two columns side by side, each cell fitted to its width, `gap` columns apart. */
|
|
136
|
-
export function columns(left: readonly string[], right: readonly string[], leftWidth: number, rightWidth: number, gap = " │ ", theme?: LobbyTheme): string[] {
|
|
137
|
-
const rows = Math.max(left.length, right.length);
|
|
138
|
-
const separator = paint(theme, "borderMuted", gap);
|
|
139
|
-
const out: string[] = [];
|
|
140
|
-
for (let i = 0; i < rows; i++) out.push(`${fit(left[i] ?? "", leftWidth)}${separator}${fit(right[i] ?? "", rightWidth)}`);
|
|
141
|
-
return out;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Split `width` into two columns plus a gap; the left gets `share` of the space. */
|
|
145
|
-
export function split(width: number, share: number, gap = 3, minRight = 24): [number, number] {
|
|
146
|
-
const usable = Math.max(0, width - gap);
|
|
147
|
-
const left = Math.max(10, Math.min(usable - minRight, Math.round(usable * share)));
|
|
148
|
-
return [left, Math.max(0, usable - left)];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** `12:04` in local time. */
|
|
152
|
-
export function clock(at: number): string {
|
|
153
|
-
const date = new Date(at);
|
|
154
|
-
if (!Number.isFinite(date.getTime())) return "--:--";
|
|
155
|
-
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Compact age: `now`, `45s`, `12m`, `3h`, `2d`. */
|
|
159
|
-
export function ago(ms: number): string {
|
|
160
|
-
if (!Number.isFinite(ms) || ms < 5_000) return "now";
|
|
161
|
-
const seconds = Math.floor(ms / 1000);
|
|
162
|
-
if (seconds < 60) return `${seconds}s`;
|
|
163
|
-
const minutes = Math.floor(seconds / 60);
|
|
164
|
-
if (minutes < 60) return `${minutes}m`;
|
|
165
|
-
const hours = Math.floor(minutes / 60);
|
|
166
|
-
if (hours < 48) return `${hours}h`;
|
|
167
|
-
return `${Math.floor(hours / 24)}d`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** `just now` or `12m ago`. */
|
|
171
|
-
export function since(ms: number): string {
|
|
172
|
-
const age = ago(ms);
|
|
173
|
-
return age === "now" ? "just now" : `${age} ago`;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Highlight a selected row: the theme's selection background, or a leading marker without one. */
|
|
177
|
-
export function selectRow(theme: LobbyTheme | undefined, text: string, width: number, selected: boolean, focused = true): string {
|
|
178
|
-
const cell = fit(text, width);
|
|
179
|
-
if (!selected) return cell;
|
|
180
|
-
if (theme?.bg && focused) return theme.bg("selectedBg", cell);
|
|
181
|
-
return cell;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
185
|
-
|
|
186
|
-
export function spinner(tick: number): string {
|
|
187
|
-
return SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length]!;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
|
|
191
|
-
export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true, style: MarkdownStyle = "reply"): string[] {
|
|
192
|
-
if (theme?.markdown) return theme.markdown(text, width, keep, style);
|
|
193
|
-
return text.split("\n").flatMap((line) => {
|
|
194
|
-
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
195
|
-
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
|
|
200
|
-
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme, style: MarkdownStyle = "reply"): string[] {
|
|
201
|
-
const indent = textWidth(lead);
|
|
202
|
-
const body = markdownLines(text, Math.max(1, width - indent), theme, true, style);
|
|
203
|
-
if (body.length === 0) return [lead];
|
|
204
|
-
return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export interface BoxOptions {
|
|
208
|
-
title?: string;
|
|
209
|
-
/** Muted text at the right end of the top border. */
|
|
210
|
-
right?: string;
|
|
211
|
-
/** The box that has the keyboard: its border takes the accent colour. */
|
|
212
|
-
focused?: boolean;
|
|
213
|
-
theme?: LobbyTheme;
|
|
214
|
-
/** Content longer than the box: `total` lines, the first shown at `start`; drawn as a thumb on the right border. */
|
|
215
|
-
scroll?: { total: number; start: number };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** An entry of a pane (a message, a thought, an activity line) and how many lines run from its first line to the pane's end. */
|
|
219
|
-
export interface PaneMark {
|
|
220
|
-
entry: object;
|
|
221
|
-
below: number;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** A pane that measures what arrives below: its newest entry now, and the lines that arrived below the one marked the frame before. */
|
|
225
|
-
export interface PaneFollow {
|
|
226
|
-
mark?: PaneMark;
|
|
227
|
-
grew: number;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
|
|
231
|
-
export interface PaneBox {
|
|
232
|
-
top: number;
|
|
233
|
-
left: number;
|
|
234
|
-
width: number;
|
|
235
|
-
height: number;
|
|
236
|
-
/** Lines of content, and rows showing them at once. */
|
|
237
|
-
total: number;
|
|
238
|
-
rows: number;
|
|
239
|
-
/**
|
|
240
|
-
* Set by panes whose total is estimated: lines that arrived below since the
|
|
241
|
-
* frame before, measured from an entry, not from the total (which moves as
|
|
242
|
-
* scrolling draws more or fewer of the entries it estimates).
|
|
243
|
-
*/
|
|
244
|
-
follow?: PaneFollow;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
|
|
248
|
-
export type PaneLayout = Map<string, PaneBox>;
|
|
249
|
-
|
|
250
|
-
/** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
|
|
251
|
-
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number, follow?: PaneFollow): void {
|
|
252
|
-
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2), ...(follow ? { follow } : {}) });
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
|
|
256
|
-
export function detailWindow(total: number, rows: number, offset: number): number {
|
|
257
|
-
return Math.max(0, Math.min(offset, total - Math.max(1, rows)));
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** `12–40/96`: the lines a pane shows out of all it holds. */
|
|
261
|
-
export function position(start: number, rows: number, total: number): string {
|
|
262
|
-
return `${start + 1}–${Math.min(total, start + rows)}/${total}`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** The rows of a `rows`-tall track that the thumb covers, or undefined when everything fits. */
|
|
266
|
-
export function scrollThumb(total: number, rows: number, start: number): { from: number; to: number } | undefined {
|
|
267
|
-
if (rows <= 0 || total <= rows) return undefined;
|
|
268
|
-
const size = Math.max(1, Math.round((rows * rows) / total));
|
|
269
|
-
const max = total - rows;
|
|
270
|
-
const from = Math.round((Math.min(max, Math.max(0, start)) / max) * (rows - size));
|
|
271
|
-
return { from, to: from + size };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Lines a page moves a pane: its rows less one, so a line of context carries over. */
|
|
275
|
-
export function pageStep(rows: number): number {
|
|
276
|
-
return Math.max(1, rows - 1);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** How many pages a pane showing `rows` lines at a time holds out of `total`. */
|
|
280
|
-
export function pageCount(total: number, rows: number): number {
|
|
281
|
-
return total <= rows ? 1 : 1 + Math.ceil((total - rows) / pageStep(rows));
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* The page a pane showing lines from `start` is on, counted so that the top is
|
|
286
|
-
* page 1 and the bottom the last, and a page's worth of scrolling from either
|
|
287
|
-
* end lands exactly on the next number: a log that opens at its newest line
|
|
288
|
-
* and a detail that opens at its first both step through the same numbers.
|
|
289
|
-
*/
|
|
290
|
-
export function pageOf(total: number, rows: number, start: number): number {
|
|
291
|
-
const end = Math.max(0, total - rows);
|
|
292
|
-
const behind = Math.ceil(Math.max(0, end - Math.max(0, start)) / pageStep(rows));
|
|
293
|
-
return Math.max(1, pageCount(total, rows) - behind);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** One piece of the pager: a button, where the pane is, or what sits between them. */
|
|
297
|
-
export interface PagerPart {
|
|
298
|
-
text: string;
|
|
299
|
-
role: "up" | "down" | "where" | "between";
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/** Fewest columns of border kept either side of the pager, beside its two spaces. */
|
|
303
|
-
const PAGER_MARGIN = 3;
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* The pager in a scrolling pane's bottom border, as words a newcomer can read:
|
|
307
|
-
* `▲ prev · page 2/5 · next ▼`, shortening to `▲ prev · 2/5 · next ▼` and
|
|
308
|
-
* `▲ 2/5 ▼` as the pane narrows, and to nothing when even that will not fit
|
|
309
|
-
* in `room` columns. The page number is padded to the width of the page count,
|
|
310
|
-
* so the buttons stay where they are as it changes and a click can be traced
|
|
311
|
-
* from the pane's size alone.
|
|
312
|
-
*/
|
|
313
|
-
export function pager(total: number, rows: number, start: number, room: number): PagerPart[] | undefined {
|
|
314
|
-
const pages = pageCount(total, rows);
|
|
315
|
-
const count = String(pages);
|
|
316
|
-
const of = `${String(pageOf(total, rows, start)).padStart(count.length)}/${pages}`;
|
|
317
|
-
const between = (text: string): PagerPart => ({ text, role: "between" });
|
|
318
|
-
const up = (text: string): PagerPart => ({ text, role: "up" });
|
|
319
|
-
const down = (text: string): PagerPart => ({ text, role: "down" });
|
|
320
|
-
const where = (text: string): PagerPart => ({ text, role: "where" });
|
|
321
|
-
const variants: PagerPart[][] = [
|
|
322
|
-
[up("▲ prev"), between(" · "), where(`page ${of}`), between(" · "), down("next ▼")],
|
|
323
|
-
[up("▲ prev"), between(" · "), where(of), between(" · "), down("next ▼")],
|
|
324
|
-
[up("▲"), between(" "), where(of), between(" "), down("▼")],
|
|
325
|
-
];
|
|
326
|
-
return variants.find((parts) => parts.reduce((sum, part) => sum + textWidth(part.text), 0) <= room);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Columns a box of `width` leaves for the pager's text: two spaces around it and a margin of border on each side. */
|
|
330
|
-
function pagerRoom(width: number): number {
|
|
331
|
-
return width - 2 - 2 - PAGER_MARGIN;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Which button a column of a scrollable pane's bottom border is: -1 (a page
|
|
336
|
-
* up) or 1 (a page down), or undefined. The buttons take their words and the
|
|
337
|
-
* space beside them, so a click a little off still lands.
|
|
338
|
-
*/
|
|
339
|
-
export function pagerButton(width: number, column: number, total: number, rows: number): -1 | 1 | undefined {
|
|
340
|
-
const parts = pager(total, rows, 0, pagerRoom(width));
|
|
341
|
-
if (!parts) return undefined;
|
|
342
|
-
const size = parts.reduce((sum, part) => sum + textWidth(part.text), 0);
|
|
343
|
-
// The text ends one space before the corner: `… next ▼ ╯`.
|
|
344
|
-
const origin = width - 2 - size;
|
|
345
|
-
let at = origin;
|
|
346
|
-
for (const part of parts) {
|
|
347
|
-
const from = part.role === "up" ? origin - 1 : at;
|
|
348
|
-
const to = part.role === "down" ? width - 1 : at + textWidth(part.text);
|
|
349
|
-
if (column >= from && column < to) return part.role === "up" ? -1 : part.role === "down" ? 1 : undefined;
|
|
350
|
-
at += textWidth(part.text);
|
|
351
|
-
}
|
|
352
|
-
return undefined;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* A rounded panel exactly `width` × `height`: the title set into the top
|
|
357
|
-
* border, `content` inside with one column of padding, extra lines cut and
|
|
358
|
-
* missing ones blank. Below 4 columns or 2 rows it degrades to plain lines.
|
|
359
|
-
*/
|
|
360
|
-
export function box(width: number, height: number, content: readonly string[], options: BoxOptions = {}): string[] {
|
|
361
|
-
if (height <= 0) return [];
|
|
362
|
-
if (width < 4 || height < 2) return fill(content, height, Math.max(0, width));
|
|
363
|
-
const { theme, focused } = options;
|
|
364
|
-
const edge = (text: string) => paint(theme, focused ? "borderAccent" : "borderMuted", text);
|
|
365
|
-
const inner = width - 4;
|
|
366
|
-
const title = options.title ? ` ${options.title} ` : "";
|
|
367
|
-
const right = options.right ? ` ${options.right} ` : "";
|
|
368
|
-
let room = width - 2 - textWidth(title) - textWidth(right);
|
|
369
|
-
const shownRight = room >= 1 ? right : "";
|
|
370
|
-
room = width - 2 - textWidth(title) - textWidth(shownRight);
|
|
371
|
-
const titled = room >= 1 ? title : fit(title, Math.max(0, width - 3));
|
|
372
|
-
const fillWidth = Math.max(0, width - 2 - textWidth(titled) - textWidth(shownRight));
|
|
373
|
-
const paintedTitle = titled ? bold(theme, paint(theme, focused ? "accent" : "text", titled)) : "";
|
|
374
|
-
const top = `${edge("╭")}${paintedTitle}${edge("─".repeat(fillWidth))}${shownRight ? paint(theme, "dim", shownRight) : ""}${edge("╮")}`;
|
|
375
|
-
const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
|
|
376
|
-
const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
|
|
377
|
-
const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
|
|
378
|
-
// A pane with more lines than rows carries a pager in its bottom border; `pagerButton` finds its buttons again for clicks.
|
|
379
|
-
const parts = thumb && options.scroll ? pager(options.scroll.total, height - 2, options.scroll.start, pagerRoom(width)) : undefined;
|
|
380
|
-
const atTop = (options.scroll?.start ?? 0) <= 0;
|
|
381
|
-
const atEnd = options.scroll ? options.scroll.start >= options.scroll.total - (height - 2) : true;
|
|
382
|
-
const paintPart = (part: PagerPart): string => {
|
|
383
|
-
if (part.role === "between") return paint(theme, "dim", part.text);
|
|
384
|
-
if (part.role === "where") return paint(theme, focused ? "text" : "muted", part.text);
|
|
385
|
-
// A button that cannot go further is dimmed: the pane is at that end.
|
|
386
|
-
if (part.role === "up" ? atTop : atEnd) return paint(theme, "dim", part.text);
|
|
387
|
-
return bold(theme, paint(theme, focused ? "accent" : "text", part.text));
|
|
388
|
-
};
|
|
389
|
-
const pagerText = parts ? ` ${parts.map(paintPart).join("")} ` : "";
|
|
390
|
-
const pagerWidth = parts ? parts.reduce((sum, part) => sum + textWidth(part.text), 0) + 2 : 0;
|
|
391
|
-
const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - pagerWidth))}${pagerText}${edge("╯")}`;
|
|
392
|
-
return [top, ...rows, bottom];
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/** Lay boxes out side by side, each already exactly its width and the same height. */
|
|
396
|
-
export function beside(panes: ReadonlyArray<readonly string[]>, gap = " "): string[] {
|
|
397
|
-
const height = Math.max(0, ...panes.map((pane) => pane.length));
|
|
398
|
-
return Array.from({ length: height }, (_, row) => panes.map((pane) => pane[row] ?? "").join(gap));
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Escape sequences (CSI, OSC, APC) that take no columns. */
|
|
402
|
-
const ESCAPE = /\x1b(?:\[[0-9;?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|_[^\x07\x1b]*(?:\x07|\x1b\\))/y;
|
|
403
|
-
|
|
404
|
-
/**
|
|
405
|
-
* Mark every case-insensitive occurrence of `query` in a styled line with
|
|
406
|
-
* reverse video. Escape sequences are skipped when matching and kept intact,
|
|
407
|
-
* and reverse video is switched off (not reset) so the line's own colours
|
|
408
|
-
* carry on after each match.
|
|
409
|
-
*/
|
|
410
|
-
export function highlight(line: string, query: string): string {
|
|
411
|
-
const needle = query.trim().toLowerCase();
|
|
412
|
-
if (!needle) return line;
|
|
413
|
-
// Visible characters with their raw offsets.
|
|
414
|
-
const chars: Array<{ ch: string; at: number }> = [];
|
|
415
|
-
for (let i = 0; i < line.length; ) {
|
|
416
|
-
ESCAPE.lastIndex = i;
|
|
417
|
-
const escape = ESCAPE.exec(line);
|
|
418
|
-
if (escape) {
|
|
419
|
-
i += escape[0].length;
|
|
420
|
-
continue;
|
|
421
|
-
}
|
|
422
|
-
const point = line.codePointAt(i)!;
|
|
423
|
-
const ch = String.fromCodePoint(point);
|
|
424
|
-
chars.push({ ch, at: i });
|
|
425
|
-
i += ch.length;
|
|
426
|
-
}
|
|
427
|
-
const visible = chars.map((entry) => entry.ch).join("").toLowerCase();
|
|
428
|
-
const starts: Array<[number, number]> = [];
|
|
429
|
-
for (let from = visible.indexOf(needle); from >= 0; from = visible.indexOf(needle, from + needle.length)) starts.push([from, from + needle.length]);
|
|
430
|
-
if (starts.length === 0) return line;
|
|
431
|
-
// Map visible string offsets back to char indexes (they differ only for astral characters).
|
|
432
|
-
const charAt: number[] = [];
|
|
433
|
-
chars.forEach((entry, index) => {
|
|
434
|
-
for (let k = 0; k < entry.ch.length; k++) charAt.push(index);
|
|
435
|
-
});
|
|
436
|
-
let out = "";
|
|
437
|
-
let cursor = 0;
|
|
438
|
-
for (const [start, end] of starts) {
|
|
439
|
-
const from = chars[charAt[start]!]!.at;
|
|
440
|
-
const lastChar = chars[charAt[end - 1]!]!;
|
|
441
|
-
const to = lastChar.at + lastChar.ch.length;
|
|
442
|
-
out += `${line.slice(cursor, from)}\x1b[7m${line.slice(from, to)}\x1b[27m`;
|
|
443
|
-
cursor = to;
|
|
444
|
-
}
|
|
445
|
-
return out + line.slice(cursor);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
449
|
-
|
|
450
|
-
/** A horizontal bar `value / max` of `width` cells, with eighth-cell precision at its end. */
|
|
451
|
-
export function bar(value: number, max: number, width: number): string {
|
|
452
|
-
if (width <= 0 || !Number.isFinite(value) || !Number.isFinite(max) || max <= 0 || value <= 0) return "";
|
|
453
|
-
const cells = Math.min(width, (value / max) * width);
|
|
454
|
-
const whole = Math.floor(cells);
|
|
455
|
-
const part = EIGHTHS[Math.round((cells - whole) * 8)] ?? "";
|
|
456
|
-
// A non-zero value always shows at least a sliver.
|
|
457
|
-
return `${"█".repeat(whole)}${part}` || "▏";
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/** A meter `fraction` full: filled cells over a dim track, exactly `width` wide. */
|
|
461
|
-
export function meter(fraction: number, width: number, fillPaint: (text: string) => string, trackPaint: (text: string) => string): string {
|
|
462
|
-
if (width <= 0) return "";
|
|
463
|
-
const safe = Number.isFinite(fraction) ? Math.min(1, Math.max(0, fraction)) : 0;
|
|
464
|
-
const filled = Math.round(safe * width);
|
|
465
|
-
return `${fillPaint("━".repeat(filled))}${trackPaint("─".repeat(width - filled))}`;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
469
|
-
|
|
470
|
-
/** A sparkline of the last `width` values, scaled between their min and max. */
|
|
471
|
-
export function sparkline(values: readonly number[], width: number): string {
|
|
472
|
-
const shown = values.filter((value) => Number.isFinite(value)).slice(-Math.max(0, width));
|
|
473
|
-
if (shown.length === 0) return "";
|
|
474
|
-
const low = Math.min(...shown);
|
|
475
|
-
const high = Math.max(...shown);
|
|
476
|
-
const span = high - low;
|
|
477
|
-
return shown.map((value) => SPARKS[span === 0 ? 3 : Math.min(7, Math.floor(((value - low) / span) * 8))]).join("");
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* A part-to-whole bar: each segment's share of `width` cells in its own
|
|
482
|
-
* colour, rounded so the segments always fill the bar exactly.
|
|
483
|
-
*/
|
|
484
|
-
export function stackedBar(segments: ReadonlyArray<{ value: number; paint: (text: string) => string }>, width: number): string {
|
|
485
|
-
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0);
|
|
486
|
-
if (width <= 0 || total <= 0) return "";
|
|
487
|
-
let used = 0;
|
|
488
|
-
let acc = 0;
|
|
489
|
-
return segments.map((segment) => {
|
|
490
|
-
acc += Math.max(0, segment.value);
|
|
491
|
-
const end = Math.round((acc / total) * width);
|
|
492
|
-
const cells = end - used;
|
|
493
|
-
used = end;
|
|
494
|
-
return cells > 0 ? segment.paint("█".repeat(cells)) : "";
|
|
495
|
-
}).join("");
|
|
496
|
-
}
|
package/src/lobby/markdown.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Markdown for the lobby: pi's own renderer (headings, emphasis, lists, code
|
|
3
|
-
* with syntax highlighting, quotes, tables) under the session's theme, with
|
|
4
|
-
* heading hashes dropped for a cleaner read. Renders are cached per width and
|
|
5
|
-
* text, since the lobby repaints many times a second.
|
|
6
|
-
*/
|
|
7
|
-
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Markdown, stripTerminalSequences, type DefaultTextStyle, type MarkdownOptions, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Whose words a piece of Markdown is, which sets how its plain text reads, as
|
|
12
|
-
* pi draws the same things: an agent's reply in the theme's text colour, your
|
|
13
|
-
* own words in yours (list markers and backslash escapes kept as typed), a
|
|
14
|
-
* thought dimmed and in italics.
|
|
15
|
-
*/
|
|
16
|
-
export type MarkdownStyle = "reply" | "you" | "thought";
|
|
17
|
-
|
|
18
|
-
/** The plain-text look of each style but a reply; the lobby takes them from the session's theme. */
|
|
19
|
-
export type MarkdownStyles = Partial<Record<Exclude<MarkdownStyle, "reply">, DefaultTextStyle>>;
|
|
20
|
-
|
|
21
|
-
/** Markdown rendered to lines at a width; `keep: false` for text seen once (a reply still streaming), so it does not crowd the cache. */
|
|
22
|
-
export type MarkdownRenderer = (text: string, width: number, keep?: boolean, style?: MarkdownStyle) => string[];
|
|
23
|
-
|
|
24
|
-
/** How pi renders what you write: your ordered-list markers and backslash escapes stay as you typed them. */
|
|
25
|
-
const YOUR_WORDS: MarkdownOptions = { preserveOrderedListMarkers: true, preserveBackslashEscapes: true };
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Line breaks a model wrote out as `\n` (text escaped twice on its way here,
|
|
29
|
-
* so a whole plan arrives as one line): made real when the text has no real
|
|
30
|
-
* line break at all, outside inline code, where `\n` is usually meant.
|
|
31
|
-
*/
|
|
32
|
-
export function unescapeBreaks(text: string): string {
|
|
33
|
-
if (text.includes("\n") || !text.includes("\\n")) return text;
|
|
34
|
-
return text
|
|
35
|
-
.split(/(`[^`\n]*`)/)
|
|
36
|
-
.map((part, index) => (index % 2 === 1 ? part : part.replace(/(?:\\r)?\\n/g, "\n")))
|
|
37
|
-
.join("");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
|
|
41
|
-
const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
|
|
42
|
-
|
|
43
|
-
/** Drop the `### ` pi keeps in front of headings, and make them bold. */
|
|
44
|
-
export function tidyHeading(line: string, bold: (text: string) => string): string {
|
|
45
|
-
if (!/^#{1,6} /.test(stripTerminalSequences(line))) return line;
|
|
46
|
-
const body = line.replace(HEADING_HASHES, "$1$2");
|
|
47
|
-
return bold(body.trimEnd());
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Rendered Markdown kept per renderer: the most recently used survive. */
|
|
51
|
-
export const CACHE_LIMIT = 512;
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* A renderer for one Markdown theme. Renders are kept per width and text, the
|
|
55
|
-
* least recently used dropped first. pi's `getMarkdownTheme()` builds a new
|
|
56
|
-
* theme object on every call, so the theme is taken once, here: a theme
|
|
57
|
-
* switch makes a new renderer (the lobby keeps one per pi theme).
|
|
58
|
-
*/
|
|
59
|
-
export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme(), styles: MarkdownStyles = {}): MarkdownRenderer {
|
|
60
|
-
const cache = new Map<string, string[]>();
|
|
61
|
-
return (text, width, keep = true, style = "reply") => {
|
|
62
|
-
const key = `${style}\0${width}\0${text}`;
|
|
63
|
-
const hit = cache.get(key);
|
|
64
|
-
if (hit) {
|
|
65
|
-
// Most recently used last, so the oldest is the one dropped.
|
|
66
|
-
cache.delete(key);
|
|
67
|
-
cache.set(key, hit);
|
|
68
|
-
return hit;
|
|
69
|
-
}
|
|
70
|
-
const look = style === "reply" ? undefined : styles[style];
|
|
71
|
-
const lines = new Markdown(unescapeBreaks(text), 0, 0, theme, look, style === "you" ? YOUR_WORDS : undefined)
|
|
72
|
-
.render(Math.max(1, width))
|
|
73
|
-
.map((line) => tidyHeading(line.trimEnd(), theme.bold));
|
|
74
|
-
// Drop the blank lines pi pads the render with at either end.
|
|
75
|
-
while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
|
|
76
|
-
while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
|
|
77
|
-
if (!keep) return lines;
|
|
78
|
-
cache.set(key, lines);
|
|
79
|
-
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
|
|
80
|
-
return lines;
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** A fenced code block's opening or closing line. */
|
|
85
|
-
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
86
|
-
/** A list item's first line: after a blank line it continues the list above, so blocks never split there. */
|
|
87
|
-
const LIST_ITEM = /^(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Markdown split into blocks that render, one by one, into the same lines as
|
|
91
|
-
* the whole (each block's lines, a blank line between blocks, blocks that
|
|
92
|
-
* render to nothing left out). A split falls on a blank line outside code
|
|
93
|
-
* fences that is followed by an unindented line that does not start a list
|
|
94
|
-
* item — the only places where one block can never run into the next. A
|
|
95
|
-
* streaming reply can then keep its finished blocks rendered and render only
|
|
96
|
-
* the one still being written.
|
|
97
|
-
*/
|
|
98
|
-
export function markdownBlocks(text: string): string[] {
|
|
99
|
-
const lines = text.split("\n");
|
|
100
|
-
const blocks: string[] = [];
|
|
101
|
-
let fence: string | undefined;
|
|
102
|
-
let start = 0;
|
|
103
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
104
|
-
const line = lines[index]!;
|
|
105
|
-
const marker = FENCE.exec(line)?.[1];
|
|
106
|
-
if (marker) {
|
|
107
|
-
if (!fence) fence = marker;
|
|
108
|
-
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
if (fence || line.trim() || index === 0) continue;
|
|
112
|
-
const next = lines[index + 1];
|
|
113
|
-
if (next === undefined || !/^\S/.test(next) || LIST_ITEM.test(next)) continue;
|
|
114
|
-
const block = lines.slice(start, index).join("\n");
|
|
115
|
-
if (block.trim()) blocks.push(block);
|
|
116
|
-
start = index + 1;
|
|
117
|
-
}
|
|
118
|
-
const last = lines.slice(start).join("\n");
|
|
119
|
-
if (last.trim()) blocks.push(last);
|
|
120
|
-
return blocks;
|
|
121
|
-
}
|