pi-btw-cc 0.1.0
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/LICENSE +21 -0
- package/README.md +67 -0
- package/docs/btw-browse.png +0 -0
- package/docs/btw-browse.svg +24 -0
- package/docs/btw-overlay.png +0 -0
- package/docs/btw-overlay.svg +24 -0
- package/docs/btw-promote.png +0 -0
- package/docs/btw-promote.svg +16 -0
- package/package.json +73 -0
- package/src/context.ts +136 -0
- package/src/index.ts +319 -0
- package/src/overlay.ts +623 -0
- package/src/thread.ts +178 -0
package/src/overlay.ts
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating overlay for the /btw side thread.
|
|
3
|
+
*
|
|
4
|
+
* Layout (fixed, top to bottom):
|
|
5
|
+
* ╭─ btw ─────────────────────────── provider/model ─╮
|
|
6
|
+
* │ 3 side questions │
|
|
7
|
+
* │ ❯ how does the retry budget work? │
|
|
8
|
+
* │ (answer rendered as Markdown) │
|
|
9
|
+
* │ ──────────────────────────────────────────────── │
|
|
10
|
+
* │ … 1 earlier │
|
|
11
|
+
* │ · what was that config file called again? │
|
|
12
|
+
* │ ❯ how does the retry budget work? │
|
|
13
|
+
* │ ↑/↓ to scroll · ⇧←/→ to browse · … 2/3 │
|
|
14
|
+
* ╰───────────────────────────────────────────────────╯
|
|
15
|
+
*
|
|
16
|
+
* Only the body scrolls; the title bar and footer stay in place. `⇧←/→` moves
|
|
17
|
+
* the selection: the selected exchange is the one expanded in the body and the
|
|
18
|
+
* one `c`, `f` and `x` act on, and the dimmed window below the separator lists
|
|
19
|
+
* the exchanges around it while marking the selected one, mirroring Claude
|
|
20
|
+
* Code's overlay.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import {
|
|
26
|
+
Container,
|
|
27
|
+
Key,
|
|
28
|
+
Markdown,
|
|
29
|
+
matchesKey,
|
|
30
|
+
Spacer,
|
|
31
|
+
Text,
|
|
32
|
+
truncateToWidth,
|
|
33
|
+
visibleWidth,
|
|
34
|
+
type Component,
|
|
35
|
+
type TUI,
|
|
36
|
+
type TuiMouseEvent,
|
|
37
|
+
type TuiMouseEventResult,
|
|
38
|
+
} from "@earendil-works/pi-tui";
|
|
39
|
+
import { appendExchange, removeExchange, type BtwExchange } from "./thread.ts";
|
|
40
|
+
|
|
41
|
+
/** What the overlay resolves to when it closes. */
|
|
42
|
+
export type BtwOverlayResult = { action: "close" } | { action: "fork"; exchange: BtwExchange };
|
|
43
|
+
|
|
44
|
+
/** Outcome of one side request. */
|
|
45
|
+
export type BtwQueryOutcome =
|
|
46
|
+
| { kind: "answer"; answer: string }
|
|
47
|
+
| { kind: "error"; message: string }
|
|
48
|
+
| { kind: "cancelled" };
|
|
49
|
+
|
|
50
|
+
/** Thread mutations the overlay performs; the command handler persists them. */
|
|
51
|
+
export interface BtwThreadSink {
|
|
52
|
+
/** A new exchange finished and must be persisted. */
|
|
53
|
+
add(exchange: BtwExchange): void;
|
|
54
|
+
/** The exchange with this id was deleted and must leave the persisted thread. */
|
|
55
|
+
remove(id: string): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BtwOverlayInit {
|
|
59
|
+
tui: TUI;
|
|
60
|
+
theme: Theme;
|
|
61
|
+
/** Side thread as restored from the session, oldest first. */
|
|
62
|
+
thread: readonly BtwExchange[];
|
|
63
|
+
/** New question to ask, or null to only browse the existing thread. */
|
|
64
|
+
question: string | null;
|
|
65
|
+
/** Model label shown in the title bar. */
|
|
66
|
+
modelLabel: string;
|
|
67
|
+
/** Number of earlier exchanges replayed into the request this overlay starts. */
|
|
68
|
+
replayedCount: number;
|
|
69
|
+
/** Runs the side request; present exactly when `question` is set. */
|
|
70
|
+
ask?: (signal: AbortSignal) => Promise<BtwQueryOutcome>;
|
|
71
|
+
sink: BtwThreadSink;
|
|
72
|
+
/** Copies text to the clipboard; throws when the host cannot copy. */
|
|
73
|
+
copy(text: string): Promise<void>;
|
|
74
|
+
done(result: BtwOverlayResult): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Exchanges listed around the selected one below the separator. */
|
|
78
|
+
const WINDOW_SIZE = 5;
|
|
79
|
+
/**
|
|
80
|
+
* Key hints in display order.
|
|
81
|
+
*
|
|
82
|
+
* Every hint must survive a narrow terminal except the one at `DROPPABLE_HINT`:
|
|
83
|
+
* the footer wraps onto as many rows as it needs, and `x to clear history` is
|
|
84
|
+
* given up only when keeping it would cost an extra row.
|
|
85
|
+
*/
|
|
86
|
+
const HINT_PIECES = ["↑/↓ to scroll", "⇧←/→ to browse", "c to copy", "f to fork", "x to clear history", "Esc to close"];
|
|
87
|
+
const DROPPABLE_HINT = 4;
|
|
88
|
+
/** Spinner frames for an in-flight side request. */
|
|
89
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
90
|
+
/** Spinner refresh interval; slow enough to stay cheap, fast enough to look alive. */
|
|
91
|
+
const SPINNER_INTERVAL_MS = 90;
|
|
92
|
+
/** How long a transient footer message stays visible. */
|
|
93
|
+
const FLASH_MS = 2000;
|
|
94
|
+
/** Share of terminal rows used by the scrolling body. */
|
|
95
|
+
const BODY_ROWS_RATIO = 0.6;
|
|
96
|
+
/**
|
|
97
|
+
* Height limit of the overlay as a percentage of the terminal.
|
|
98
|
+
*
|
|
99
|
+
* Exported because the command handler passes it to the host as the
|
|
100
|
+
* `maxHeight` overlay option and the body budget here must match that limit.
|
|
101
|
+
*/
|
|
102
|
+
export const OVERLAY_MAX_HEIGHT_PERCENT = 85;
|
|
103
|
+
/** Rows of terminal margin around the overlay, matching the host's `margin` option. */
|
|
104
|
+
export const OVERLAY_MARGIN = 1;
|
|
105
|
+
/**
|
|
106
|
+
* Overlay width as a percentage of the terminal, and the narrowest width it keeps.
|
|
107
|
+
*
|
|
108
|
+
* Exported because the command handler passes both to the host as the `width` and
|
|
109
|
+
* `minWidth` overlay options: the host resolves them to an exact column count, and
|
|
110
|
+
* tests that render the component directly have to reproduce that resolution
|
|
111
|
+
* instead of guessing a width.
|
|
112
|
+
*/
|
|
113
|
+
export const OVERLAY_WIDTH_PERCENT = 80;
|
|
114
|
+
export const OVERLAY_MIN_WIDTH = 60;
|
|
115
|
+
/** Minimum and maximum body height in rows. */
|
|
116
|
+
const MIN_BODY_ROWS = 4;
|
|
117
|
+
const MAX_BODY_ROWS = 30;
|
|
118
|
+
|
|
119
|
+
export class BtwOverlay implements Component {
|
|
120
|
+
private readonly tui: TUI;
|
|
121
|
+
private readonly theme: Theme;
|
|
122
|
+
private readonly modelLabel: string;
|
|
123
|
+
private readonly replayedCount: number;
|
|
124
|
+
private readonly sink: BtwThreadSink;
|
|
125
|
+
private readonly copyText: (text: string) => Promise<void>;
|
|
126
|
+
private readonly done: (result: BtwOverlayResult) => void;
|
|
127
|
+
|
|
128
|
+
private thread: BtwExchange[];
|
|
129
|
+
/** Index into `thread` of the exchange expanded in the body; always clamped. */
|
|
130
|
+
private selectedIndex = 0;
|
|
131
|
+
/** Footer rows the last render needed; the body shrinks when the footer wraps. */
|
|
132
|
+
private footerRows = 1;
|
|
133
|
+
private pending: { question: string; error?: string } | null = null;
|
|
134
|
+
private abort: AbortController | null = null;
|
|
135
|
+
private spinnerTimer: ReturnType<typeof setInterval> | null = null;
|
|
136
|
+
private spinnerFrame = 0;
|
|
137
|
+
private flashState: { text: string; tone: "success" | "warning" | "error" } | null = null;
|
|
138
|
+
private flashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
139
|
+
private disposed = false;
|
|
140
|
+
|
|
141
|
+
private scrollTop = 0;
|
|
142
|
+
private contentLines: string[] = [];
|
|
143
|
+
private cachedWidth = -1;
|
|
144
|
+
private dirty = true;
|
|
145
|
+
|
|
146
|
+
constructor(init: BtwOverlayInit) {
|
|
147
|
+
this.tui = init.tui;
|
|
148
|
+
this.theme = init.theme;
|
|
149
|
+
this.modelLabel = init.modelLabel;
|
|
150
|
+
this.replayedCount = init.replayedCount;
|
|
151
|
+
this.sink = init.sink;
|
|
152
|
+
this.copyText = init.copy;
|
|
153
|
+
this.done = init.done;
|
|
154
|
+
this.thread = [...init.thread];
|
|
155
|
+
this.selectNewest();
|
|
156
|
+
if (init.question !== null && init.ask !== undefined) {
|
|
157
|
+
this.startAsk(init.question, init.ask);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
handleInput(data: string): void {
|
|
162
|
+
if (matchesKey(data, Key.escape)) {
|
|
163
|
+
this.close();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (matchesKey(data, Key.up)) {
|
|
167
|
+
this.scrollBy(-1);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (matchesKey(data, Key.down)) {
|
|
171
|
+
this.scrollBy(1);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Shift+arrows browse the thread; plain arrows stay reserved for scrolling.
|
|
175
|
+
if (matchesKey(data, Key.shift("left"))) {
|
|
176
|
+
this.selectBy(-1);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (matchesKey(data, Key.shift("right"))) {
|
|
180
|
+
this.selectBy(1);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
184
|
+
this.scrollBy(-this.pageStep());
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
188
|
+
this.scrollBy(this.pageStep());
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (matchesKey(data, Key.home)) {
|
|
192
|
+
this.setScroll(0);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (matchesKey(data, Key.end)) {
|
|
196
|
+
this.setScroll(Number.MAX_SAFE_INTEGER);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// Enter closes as well, matching Space/Enter/Escape in Claude Code.
|
|
200
|
+
if (matchesKey(data, Key.enter)) {
|
|
201
|
+
this.close();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (data === "c") {
|
|
205
|
+
void this.copySelectedAnswer();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (data === "f") {
|
|
209
|
+
this.forkSelected();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (data === "x") {
|
|
213
|
+
this.deleteSelected();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Wheel scrolling is only routed in fullscreen mode; keyboard always works. */
|
|
218
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
219
|
+
if (event.type !== "wheel" || event.wheelDelta === undefined) return undefined;
|
|
220
|
+
this.scrollBy(event.wheelDelta);
|
|
221
|
+
return { handled: true };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
render(width: number): string[] {
|
|
225
|
+
const innerWidth = Math.max(24, width - 4);
|
|
226
|
+
if (this.dirty || this.cachedWidth !== innerWidth) {
|
|
227
|
+
this.contentLines = this.buildContent(innerWidth);
|
|
228
|
+
this.cachedWidth = innerWidth;
|
|
229
|
+
this.dirty = false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const footer = this.footerLines(innerWidth);
|
|
233
|
+
this.footerRows = footer.length;
|
|
234
|
+
const bodyHeight = this.bodyHeight();
|
|
235
|
+
const maxScroll = Math.max(0, this.contentLines.length - bodyHeight);
|
|
236
|
+
if (this.scrollTop > maxScroll) this.scrollTop = maxScroll;
|
|
237
|
+
const visible = this.contentLines.slice(this.scrollTop, this.scrollTop + bodyHeight);
|
|
238
|
+
|
|
239
|
+
const lines: string[] = [this.titleBar(width)];
|
|
240
|
+
for (let row = 0; row < bodyHeight; row += 1) {
|
|
241
|
+
lines.push(this.frame(visible[row] ?? "", innerWidth));
|
|
242
|
+
}
|
|
243
|
+
for (const row of footer) {
|
|
244
|
+
lines.push(this.frame(row, innerWidth));
|
|
245
|
+
}
|
|
246
|
+
lines.push(this.bottomBar(width));
|
|
247
|
+
return lines;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
invalidate(): void {
|
|
251
|
+
this.dirty = true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Called by the host when the overlay is removed. */
|
|
255
|
+
dispose(): void {
|
|
256
|
+
this.disposed = true;
|
|
257
|
+
this.abort?.abort();
|
|
258
|
+
this.stopSpinner();
|
|
259
|
+
if (this.flashTimer !== null) {
|
|
260
|
+
clearTimeout(this.flashTimer);
|
|
261
|
+
this.flashTimer = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private startAsk(question: string, ask: (signal: AbortSignal) => Promise<BtwQueryOutcome>): void {
|
|
266
|
+
this.pending = { question };
|
|
267
|
+
this.abort = new AbortController();
|
|
268
|
+
this.spinnerTimer = setInterval(() => {
|
|
269
|
+
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
270
|
+
this.dirty = true;
|
|
271
|
+
this.tui.requestRender();
|
|
272
|
+
}, SPINNER_INTERVAL_MS);
|
|
273
|
+
void ask(this.abort.signal).then((outcome) => this.finishAsk(question, outcome));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private finishAsk(question: string, outcome: BtwQueryOutcome): void {
|
|
277
|
+
// The overlay can be disposed (Esc) before the request resolves.
|
|
278
|
+
if (this.disposed) return;
|
|
279
|
+
this.stopSpinner();
|
|
280
|
+
this.abort = null;
|
|
281
|
+
switch (outcome.kind) {
|
|
282
|
+
case "answer": {
|
|
283
|
+
const exchange: BtwExchange = {
|
|
284
|
+
id: randomUUID(),
|
|
285
|
+
question,
|
|
286
|
+
answer: outcome.answer,
|
|
287
|
+
answeredAt: Date.now(),
|
|
288
|
+
model: this.modelLabel,
|
|
289
|
+
replayed: this.replayedCount,
|
|
290
|
+
};
|
|
291
|
+
this.thread = appendExchange(this.thread, exchange);
|
|
292
|
+
this.pending = null;
|
|
293
|
+
// A finished answer is what the reader wants to see next, so browsing
|
|
294
|
+
// ends here and the body scrolls back to the expanded newest exchange.
|
|
295
|
+
// The exchange appearing in the body is the whole signal; no footer
|
|
296
|
+
// flash is needed for it.
|
|
297
|
+
this.selectNewest();
|
|
298
|
+
this.sink.add(exchange);
|
|
299
|
+
this.setScroll(0);
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
case "error":
|
|
303
|
+
this.pending = { question, error: outcome.message };
|
|
304
|
+
break;
|
|
305
|
+
case "cancelled":
|
|
306
|
+
this.pending = { question, error: "The side request was cancelled." };
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
this.dirty = true;
|
|
310
|
+
this.tui.requestRender();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private close(): void {
|
|
314
|
+
this.abort?.abort();
|
|
315
|
+
this.done({ action: "close" });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async copySelectedAnswer(): Promise<void> {
|
|
319
|
+
const selected = this.selectedExchange();
|
|
320
|
+
if (selected === undefined) {
|
|
321
|
+
this.flash("Nothing to copy yet", "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
await this.copyText(selected.answer);
|
|
326
|
+
// Esc can dispose the overlay while the clipboard write is in flight;
|
|
327
|
+
// a disposed overlay must not schedule a flash timer anymore.
|
|
328
|
+
if (this.disposed) return;
|
|
329
|
+
this.flash("Copied answer to clipboard");
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (this.disposed) return;
|
|
332
|
+
this.flash(error instanceof Error ? error.message : String(error), "error");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private forkSelected(): void {
|
|
337
|
+
// Only a request that is still running blocks forking: closing the overlay
|
|
338
|
+
// would abort it. A finished failure keeps `pending` for display but has no
|
|
339
|
+
// request in flight anymore, so its exchange can be promoted as usual.
|
|
340
|
+
if (this.abort !== null) {
|
|
341
|
+
this.flash("Wait for the current side question to finish", "warning");
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const selected = this.selectedExchange();
|
|
345
|
+
if (selected === undefined) {
|
|
346
|
+
this.flash("Nothing to fork yet", "warning");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (selected.promoted === true) {
|
|
350
|
+
this.flash("This exchange is already in the main conversation", "warning");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
this.done({ action: "fork", exchange: selected });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private deleteSelected(): void {
|
|
357
|
+
const selected = this.selectedExchange();
|
|
358
|
+
if (selected === undefined) {
|
|
359
|
+
this.flash("Nothing to clear yet", "warning");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.thread = removeExchange(this.thread, selected.id);
|
|
363
|
+
this.sink.remove(selected.id);
|
|
364
|
+
// Deleting moves the reader back to the newest remaining exchange, so a
|
|
365
|
+
// deletion never leaves the body pointing at an evicted index.
|
|
366
|
+
this.selectNewest();
|
|
367
|
+
this.dirty = true;
|
|
368
|
+
this.setScroll(0);
|
|
369
|
+
this.flash("Deleted this side question");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Exchange the body expands and that `c`, `f` and `x` act on. */
|
|
373
|
+
private selectedExchange(): BtwExchange | undefined {
|
|
374
|
+
return this.thread[this.selectedIndex];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Put the selection back on the newest exchange. */
|
|
378
|
+
private selectNewest(): void {
|
|
379
|
+
this.selectedIndex = Math.max(0, this.thread.length - 1);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private selectBy(delta: number): void {
|
|
383
|
+
// `Math.max(0, ...)` on the upper bound keeps an empty thread clamped to 0.
|
|
384
|
+
const next = Math.min(Math.max(0, this.selectedIndex + delta), Math.max(0, this.thread.length - 1));
|
|
385
|
+
if (next === this.selectedIndex) return;
|
|
386
|
+
this.selectedIndex = next;
|
|
387
|
+
this.dirty = true;
|
|
388
|
+
// The expanded exchange is the first block of the body, so browsing
|
|
389
|
+
// always restarts at its top.
|
|
390
|
+
this.setScroll(0);
|
|
391
|
+
this.tui.requestRender();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private flash(text: string, tone: "success" | "warning" | "error" = "success"): void {
|
|
395
|
+
this.flashState = { text, tone };
|
|
396
|
+
if (this.flashTimer !== null) clearTimeout(this.flashTimer);
|
|
397
|
+
this.flashTimer = setTimeout(() => {
|
|
398
|
+
this.flashState = null;
|
|
399
|
+
this.flashTimer = null;
|
|
400
|
+
this.tui.requestRender();
|
|
401
|
+
}, FLASH_MS);
|
|
402
|
+
this.tui.requestRender();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private stopSpinner(): void {
|
|
406
|
+
if (this.spinnerTimer !== null) {
|
|
407
|
+
clearInterval(this.spinnerTimer);
|
|
408
|
+
this.spinnerTimer = null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private scrollBy(delta: number): void {
|
|
413
|
+
this.setScroll(this.scrollTop + delta);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private setScroll(top: number): void {
|
|
417
|
+
const max = Math.max(0, this.contentLines.length - this.bodyHeight());
|
|
418
|
+
const next = Math.min(Math.max(0, top), max);
|
|
419
|
+
if (next === this.scrollTop) return;
|
|
420
|
+
this.scrollTop = next;
|
|
421
|
+
this.tui.requestRender();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private pageStep(): number {
|
|
425
|
+
return Math.max(1, this.bodyHeight() - 1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Body rows for the current footer.
|
|
430
|
+
*
|
|
431
|
+
* The body absorbs the footer's extra rows so the overlay keeps its share of
|
|
432
|
+
* the terminal, and it never exceeds the host's height budget: the footer
|
|
433
|
+
* carries the key hints, so it wins over the content and the body gives up all
|
|
434
|
+
* of its rows if that is what the budget leaves. The hints need four rows at
|
|
435
|
+
* the narrowest width, so a terminal shorter than eight rows cannot always hold
|
|
436
|
+
* the title, the border and every hint; there the host clips the overlay bottom.
|
|
437
|
+
*/
|
|
438
|
+
private bodyHeight(): number {
|
|
439
|
+
const target = Math.floor(this.tui.terminal.rows * BODY_ROWS_RATIO) - (this.footerRows - 1);
|
|
440
|
+
const budget = Math.max(0, this.maxOverlayRows() - 2 - this.footerRows);
|
|
441
|
+
const bounded = Math.min(Math.max(target, MIN_BODY_ROWS), MAX_BODY_ROWS, budget);
|
|
442
|
+
return Math.min(bounded, Math.max(MIN_BODY_ROWS, this.contentLines.length));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Rows the host allows for the whole overlay: title + body + footer + border. */
|
|
446
|
+
private maxOverlayRows(): number {
|
|
447
|
+
const available = Math.max(1, this.tui.terminal.rows - 2 * OVERLAY_MARGIN);
|
|
448
|
+
return Math.max(1, Math.min(Math.floor((this.tui.terminal.rows * OVERLAY_MAX_HEIGHT_PERCENT) / 100), available));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private buildContent(innerWidth: number): string[] {
|
|
452
|
+
const container = new Container();
|
|
453
|
+
const pending = this.pending;
|
|
454
|
+
const selected = this.selectedExchange();
|
|
455
|
+
|
|
456
|
+
if (pending !== null) {
|
|
457
|
+
// The in-flight question takes the expanded slot: every finished
|
|
458
|
+
// exchange moves into the dimmed window below it.
|
|
459
|
+
container.addChild(new Text(this.questionLine(pending.question), 0, 0));
|
|
460
|
+
container.addChild(
|
|
461
|
+
new Text(
|
|
462
|
+
pending.error !== undefined
|
|
463
|
+
? this.theme.fg("error", pending.error)
|
|
464
|
+
: this.theme.fg("dim", `${SPINNER_FRAMES[this.spinnerFrame]} asking ${this.modelLabel}…`),
|
|
465
|
+
0,
|
|
466
|
+
1,
|
|
467
|
+
),
|
|
468
|
+
);
|
|
469
|
+
} else if (selected === undefined) {
|
|
470
|
+
container.addChild(
|
|
471
|
+
new Text(this.theme.fg("dim", "No side questions in this session yet. Run /btw <question> to ask one."), 0, 0),
|
|
472
|
+
);
|
|
473
|
+
} else {
|
|
474
|
+
const count = this.thread.length;
|
|
475
|
+
container.addChild(new Text(this.theme.fg("dim", `${count} side question${count === 1 ? "" : "s"}`), 0, 0));
|
|
476
|
+
container.addChild(new Text(this.questionLine(selected.question), 0, 1));
|
|
477
|
+
container.addChild(new Markdown(selected.answer, 0, 1, getMarkdownTheme()));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const listed = this.windowEntries();
|
|
481
|
+
if (listed.length > 0) {
|
|
482
|
+
container.addChild(new Spacer(1));
|
|
483
|
+
container.addChild(new Text(this.theme.fg("borderMuted", "─".repeat(innerWidth)), 0, 0));
|
|
484
|
+
container.addChild(new Spacer(1));
|
|
485
|
+
const { start, end } = this.windowRange();
|
|
486
|
+
if (start > 0) {
|
|
487
|
+
container.addChild(new Text(this.theme.fg("dim", `… ${start} earlier`), 0, 0));
|
|
488
|
+
}
|
|
489
|
+
for (const entry of listed) {
|
|
490
|
+
// The selected exchange is expanded above and listed here as well: the
|
|
491
|
+
// window doubles as the position marker, so it has to show where the
|
|
492
|
+
// reader currently is even while the expanded block is scrolled away.
|
|
493
|
+
const marker = entry.selected ? this.theme.fg("accent", "❯") : this.theme.fg("muted", "·");
|
|
494
|
+
const line = truncateToWidth(`${marker} ${this.theme.fg("muted", entry.exchange.question)}`, innerWidth, "…");
|
|
495
|
+
container.addChild(new Text(line, 0, 0));
|
|
496
|
+
}
|
|
497
|
+
if (end < this.thread.length) {
|
|
498
|
+
container.addChild(new Text(this.theme.fg("dim", `… ${this.thread.length - end} newer`), 0, 0));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return container.render(innerWidth);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The dimmed window of exchanges around the selection, oldest first.
|
|
507
|
+
*
|
|
508
|
+
* The window keeps the selected exchange centered so browsing stays local and
|
|
509
|
+
* reports how many exchanges fall outside it. The selected exchange is in the
|
|
510
|
+
* window even though it is expanded above the separator, because the window is
|
|
511
|
+
* what tells the reader which exchange the selection is on.
|
|
512
|
+
*/
|
|
513
|
+
private windowEntries(): { exchange: BtwExchange; selected: boolean }[] {
|
|
514
|
+
const { start, end } = this.windowRange();
|
|
515
|
+
const entries: { exchange: BtwExchange; selected: boolean }[] = [];
|
|
516
|
+
for (let index = start; index < end; index += 1) {
|
|
517
|
+
const exchange = this.thread[index];
|
|
518
|
+
if (exchange === undefined) continue;
|
|
519
|
+
entries.push({ exchange, selected: index === this.selectedIndex });
|
|
520
|
+
}
|
|
521
|
+
return entries;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private windowRange(): { start: number; end: number } {
|
|
525
|
+
const before = Math.floor((WINDOW_SIZE - 1) / 2);
|
|
526
|
+
const start = Math.min(Math.max(0, this.selectedIndex - before), Math.max(0, this.thread.length - WINDOW_SIZE));
|
|
527
|
+
return { start, end: Math.min(this.thread.length, start + WINDOW_SIZE) };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private questionLine(question: string): string {
|
|
531
|
+
return this.theme.fg("accent", "❯ ") + this.theme.fg("text", question);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private titleBar(width: number): string {
|
|
535
|
+
const left = "╭─" + this.theme.fg("accent", " btw ") + this.theme.fg("border", "─");
|
|
536
|
+
const labelWidth = Math.min(visibleWidth(this.modelLabel), Math.max(0, width - 14));
|
|
537
|
+
const right =
|
|
538
|
+
this.theme.fg("border", "─") +
|
|
539
|
+
" " +
|
|
540
|
+
this.theme.fg("dim", truncateToWidth(this.modelLabel, labelWidth, "…")) +
|
|
541
|
+
" " +
|
|
542
|
+
this.theme.fg("border", "╮");
|
|
543
|
+
const fill = Math.max(0, width - visibleWidth(left) - visibleWidth(right));
|
|
544
|
+
return left + this.theme.fg("border", "─".repeat(fill)) + right;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private bottomBar(width: number): string {
|
|
548
|
+
return this.theme.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private frame(content: string, innerWidth: number): string {
|
|
552
|
+
const clipped = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth, "") : content;
|
|
553
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
|
|
554
|
+
return this.theme.fg("border", "│") + " " + clipped + padding + " " + this.theme.fg("border", "│");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Key hints and the browse position, on one or more rows.
|
|
559
|
+
*
|
|
560
|
+
* The overlay is 80% of the terminal, so an 80-column terminal leaves a
|
|
561
|
+
* 60-column body and the hints do not fit next to each other. Hints stay
|
|
562
|
+
* complete: they wrap onto extra rows, and only `x to clear history` is given
|
|
563
|
+
* up, and only when keeping it would need a row of its own.
|
|
564
|
+
*/
|
|
565
|
+
private footerLines(innerWidth: number): string[] {
|
|
566
|
+
if (this.flashState !== null) {
|
|
567
|
+
return [truncateToWidth(this.theme.fg(this.flashState.tone, this.flashState.text), innerWidth, "…")];
|
|
568
|
+
}
|
|
569
|
+
const required = HINT_PIECES.filter((_piece, index) => index !== DROPPABLE_HINT);
|
|
570
|
+
const position = this.thread.length === 0 ? "" : `${this.selectedIndex + 1}/${this.thread.length}`;
|
|
571
|
+
// Complete hints come first, then the droppable hint, then the browse
|
|
572
|
+
// position. A row that would overflow wraps instead of losing a hint.
|
|
573
|
+
for (const pieces of [HINT_PIECES, required]) {
|
|
574
|
+
const row = pieces.join(" · ");
|
|
575
|
+
if (visibleWidth(row) + this.positionGap(position) <= innerWidth) {
|
|
576
|
+
return this.withPosition([row], position, innerWidth);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const wrappedAll = this.wrapHints(HINT_PIECES, innerWidth);
|
|
580
|
+
const wrappedRequired = this.wrapHints(required, innerWidth);
|
|
581
|
+
// Fewest rows win, so the droppable hint is the one that goes when it would
|
|
582
|
+
// occupy a row on its own; on a tie the wrapped list keeping it is used.
|
|
583
|
+
const chosen = wrappedAll.length <= wrappedRequired.length ? wrappedAll : wrappedRequired;
|
|
584
|
+
return this.withPosition(chosen, position, innerWidth);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Columns the right-aligned browse position needs, gap included. */
|
|
588
|
+
private positionGap(position: string): number {
|
|
589
|
+
return position === "" ? 0 : 2 + visibleWidth(position);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Greedy wrap of the hint pieces into rows that fit `innerWidth`. */
|
|
593
|
+
private wrapHints(pieces: readonly string[], innerWidth: number): string[] {
|
|
594
|
+
const rows: string[] = [];
|
|
595
|
+
let row = "";
|
|
596
|
+
for (const piece of pieces) {
|
|
597
|
+
const candidate = row === "" ? piece : `${row} · ${piece}`;
|
|
598
|
+
if (row !== "" && visibleWidth(candidate) > innerWidth) {
|
|
599
|
+
rows.push(row);
|
|
600
|
+
row = piece;
|
|
601
|
+
} else {
|
|
602
|
+
row = candidate;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (row !== "") rows.push(row);
|
|
606
|
+
return rows;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Right-align the browse position on the last hint row when a two-column gap
|
|
611
|
+
* still fits. `wrapHints` already keeps every row within `innerWidth`, so the
|
|
612
|
+
* rows themselves need no truncation.
|
|
613
|
+
*/
|
|
614
|
+
private withPosition(rows: readonly string[], position: string, innerWidth: number): string[] {
|
|
615
|
+
const last = rows[rows.length - 1] ?? "";
|
|
616
|
+
const lastWidth = visibleWidth(last);
|
|
617
|
+
const placement =
|
|
618
|
+
position !== "" && lastWidth + this.positionGap(position) <= innerWidth
|
|
619
|
+
? " ".repeat(innerWidth - lastWidth - visibleWidth(position)) + position
|
|
620
|
+
: "";
|
|
621
|
+
return rows.map((row, index) => this.theme.fg("dim", index === rows.length - 1 ? row + placement : row));
|
|
622
|
+
}
|
|
623
|
+
}
|