@a-t-h-i/bot-lobby 0.6.3 → 0.6.4
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 +102 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +6 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/layout.ts +27 -8
- package/src/lobby/markdown.ts +36 -6
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +14 -36
- package/src/lobby/tabs/home.ts +112 -48
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +27 -5
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +47 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +8 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +6 -1
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +16 -9
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/workflow/workflow.ts +592 -42
package/src/lobby/layout.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
8
|
import { textWidth } from "../width.ts";
|
|
9
|
+
import type { MarkdownStyle } from "./markdown.ts";
|
|
9
10
|
|
|
10
11
|
/** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
|
|
11
12
|
export type LobbyColor =
|
|
@@ -29,8 +30,8 @@ export interface LobbyTheme {
|
|
|
29
30
|
bold(text: string): string;
|
|
30
31
|
italic?(text: string): string;
|
|
31
32
|
bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
|
|
32
|
-
/** Render Markdown to styled lines; plain wrapping without it (tests). */
|
|
33
|
-
markdown?(text: string, width: number, keep?: boolean): 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[];
|
|
34
35
|
/** Strike text through (an abandoned task's title); plain without it (tests). */
|
|
35
36
|
strike?(text: string): string;
|
|
36
37
|
}
|
|
@@ -184,8 +185,8 @@ export function spinner(tick: number): string {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
/** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
|
|
187
|
-
export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true): string[] {
|
|
188
|
-
if (theme?.markdown) return theme.markdown(text, width, keep);
|
|
188
|
+
export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true, style: MarkdownStyle = "reply"): string[] {
|
|
189
|
+
if (theme?.markdown) return theme.markdown(text, width, keep, style);
|
|
189
190
|
return text.split("\n").flatMap((line) => {
|
|
190
191
|
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
|
191
192
|
return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
|
|
@@ -193,9 +194,9 @@ export function markdownLines(text: string, width: number, theme?: LobbyTheme, k
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
/** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
|
|
196
|
-
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
|
|
197
|
+
export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme, style: MarkdownStyle = "reply"): string[] {
|
|
197
198
|
const indent = textWidth(lead);
|
|
198
|
-
const body = markdownLines(text, Math.max(1, width - indent), theme);
|
|
199
|
+
const body = markdownLines(text, Math.max(1, width - indent), theme, true, style);
|
|
199
200
|
if (body.length === 0) return [lead];
|
|
200
201
|
return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
|
|
201
202
|
}
|
|
@@ -211,6 +212,18 @@ export interface BoxOptions {
|
|
|
211
212
|
scroll?: { total: number; start: number };
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/** 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. */
|
|
216
|
+
export interface PaneMark {
|
|
217
|
+
entry: object;
|
|
218
|
+
below: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A pane that measures what arrives below: its newest entry now, and the lines that arrived below the one marked the frame before. */
|
|
222
|
+
export interface PaneFollow {
|
|
223
|
+
mark?: PaneMark;
|
|
224
|
+
grew: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
214
227
|
/** A scrollable pane as a render laid it out: its box in body cells, and its content against its rows. */
|
|
215
228
|
export interface PaneBox {
|
|
216
229
|
top: number;
|
|
@@ -220,14 +233,20 @@ export interface PaneBox {
|
|
|
220
233
|
/** Lines of content, and rows showing them at once. */
|
|
221
234
|
total: number;
|
|
222
235
|
rows: number;
|
|
236
|
+
/**
|
|
237
|
+
* Set by panes whose total is estimated: lines that arrived below since the
|
|
238
|
+
* frame before, measured from an entry, not from the total (which moves as
|
|
239
|
+
* scrolling draws more or fewer of the entries it estimates).
|
|
240
|
+
*/
|
|
241
|
+
follow?: PaneFollow;
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/** Scrollable panes by name, filled in by a tab's render so the lobby can clamp offsets and route the wheel. */
|
|
226
245
|
export type PaneLayout = Map<string, PaneBox>;
|
|
227
246
|
|
|
228
247
|
/** Record a pane that sits at `top`/`left` in the body, `width` × `height`, showing `total` lines. */
|
|
229
|
-
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number): void {
|
|
230
|
-
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2) });
|
|
248
|
+
export function notePane(panes: PaneLayout | undefined, name: string, top: number, left: number, width: number, height: number, total: number, follow?: PaneFollow): void {
|
|
249
|
+
panes?.set(name, { top, left, width, height, total, rows: Math.max(0, height - 2), ...(follow ? { follow } : {}) });
|
|
231
250
|
}
|
|
232
251
|
|
|
233
252
|
/** First line of a top-anchored pane scrolled `offset` lines down, stopping when its last line shows. */
|
package/src/lobby/markdown.ts
CHANGED
|
@@ -5,10 +5,37 @@
|
|
|
5
5
|
* text, since the lobby repaints many times a second.
|
|
6
6
|
*/
|
|
7
7
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Markdown, stripTerminalSequences, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
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>>;
|
|
9
20
|
|
|
10
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. */
|
|
11
|
-
export type MarkdownRenderer = (text: string, width: number, keep?: boolean) => string[];
|
|
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
|
+
}
|
|
12
39
|
|
|
13
40
|
/** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
|
|
14
41
|
const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
|
|
@@ -29,10 +56,10 @@ export const CACHE_LIMIT = 512;
|
|
|
29
56
|
* theme object on every call, so the theme is taken once, here: a theme
|
|
30
57
|
* switch makes a new renderer (the lobby keeps one per pi theme).
|
|
31
58
|
*/
|
|
32
|
-
export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme()): MarkdownRenderer {
|
|
59
|
+
export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme(), styles: MarkdownStyles = {}): MarkdownRenderer {
|
|
33
60
|
const cache = new Map<string, string[]>();
|
|
34
|
-
return (text, width, keep = true) => {
|
|
35
|
-
const key = `${width}\0${text}`;
|
|
61
|
+
return (text, width, keep = true, style = "reply") => {
|
|
62
|
+
const key = `${style}\0${width}\0${text}`;
|
|
36
63
|
const hit = cache.get(key);
|
|
37
64
|
if (hit) {
|
|
38
65
|
// Most recently used last, so the oldest is the one dropped.
|
|
@@ -40,7 +67,10 @@ export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme()
|
|
|
40
67
|
cache.set(key, hit);
|
|
41
68
|
return hit;
|
|
42
69
|
}
|
|
43
|
-
const
|
|
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));
|
|
44
74
|
// Drop the blank lines pi pads the render with at either end.
|
|
45
75
|
while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
|
|
46
76
|
while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
|
package/src/lobby/planner.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { fellShort, profileLabel, type EffortRouter, type EffortScore } from "..
|
|
|
37
37
|
|
|
38
38
|
/** Seats and the oracle read the repository to ask informed questions; they never edit. */
|
|
39
39
|
export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
40
|
-
/** RESEARCH also gets
|
|
40
|
+
/** RESEARCH also gets bot-lobby's web tools (src/web/tools.ts). */
|
|
41
41
|
export const RESEARCH_PANEL_TOOLS: readonly string[] = [...PLANNER_TOOLS, "web_search", "fetch_content", "source_check", "get_search_content"];
|
|
42
42
|
|
|
43
43
|
/** The chair of the panel, as the Plan tab, the feed and the metrics name it. */
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { loadPrompt } from "../prompts/loader.ts";
|
|
|
9
9
|
import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
|
|
10
10
|
import { describeToolCall } from "../pi/activity.ts";
|
|
11
11
|
import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
|
|
12
|
+
import { appendChange, EditLog } from "../state/changes.ts";
|
|
12
13
|
import type { LobbyFeed } from "./feed.ts";
|
|
13
14
|
import type { FileHinter } from "../classifier/files.ts";
|
|
14
15
|
import type { Classifier } from "../classifier/classifier.ts";
|
|
@@ -51,6 +52,8 @@ export interface QuickFixJob {
|
|
|
51
52
|
/** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
|
|
52
53
|
route?: string;
|
|
53
54
|
routedFrom?: string;
|
|
55
|
+
/** Files it changed with `edit`/`write`, as shown (relative to the project); the change ledger lets a running task's QA gate tell them from its own. */
|
|
56
|
+
files?: string[];
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
export interface QuickFixProfile {
|
|
@@ -103,6 +106,7 @@ export class QuickFixQueue {
|
|
|
103
106
|
jobs: QuickFixJob[] = [];
|
|
104
107
|
private readonly deps: QuickFixDeps;
|
|
105
108
|
private readonly controllers = new Map<string, AbortController>();
|
|
109
|
+
private readonly edits = new Map<string, EditLog>();
|
|
106
110
|
private counter = 0;
|
|
107
111
|
|
|
108
112
|
constructor(deps: QuickFixDeps) {
|
|
@@ -188,6 +192,8 @@ export class QuickFixQueue {
|
|
|
188
192
|
const now = Date.now();
|
|
189
193
|
if (event.type === "tool_execution_start") {
|
|
190
194
|
job.tools += 1;
|
|
195
|
+
const edits = this.edits.get(job.id);
|
|
196
|
+
if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
|
|
191
197
|
this.addStep(job, describeToolCall(event.toolName, event.args), now);
|
|
192
198
|
} else if (event.type === "turn_start") job.turns += 1;
|
|
193
199
|
else if (event.type === "thought") this.deps.feed?.thought(QUICK_FIX_SOURCE, event.text, now);
|
|
@@ -211,6 +217,7 @@ export class QuickFixQueue {
|
|
|
211
217
|
return this.hold(job, held);
|
|
212
218
|
}
|
|
213
219
|
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
220
|
+
this.edits.set(job.id, new EditLog(this.deps.cwd));
|
|
214
221
|
try {
|
|
215
222
|
const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
|
|
216
223
|
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
@@ -309,6 +316,20 @@ export class QuickFixQueue {
|
|
|
309
316
|
private finish(job: QuickFixJob): void {
|
|
310
317
|
job.finishedAt = Date.now();
|
|
311
318
|
for (const step of job.steps) step.pending = false;
|
|
319
|
+
const edited = this.edits.get(job.id)?.list() ?? [];
|
|
320
|
+
this.edits.delete(job.id);
|
|
321
|
+
// Whatever it edited, even when it failed, is on record for a task's QA gate as the user's own request.
|
|
322
|
+
if (edited.length > 0) {
|
|
323
|
+
appendChange(this.deps.root, this.deps.configDir, {
|
|
324
|
+
source: "quickfix",
|
|
325
|
+
id: job.id,
|
|
326
|
+
what: jobTitle(job),
|
|
327
|
+
files: edited,
|
|
328
|
+
startedAt: new Date(job.startedAt ?? job.createdAt).toISOString(),
|
|
329
|
+
finishedAt: new Date(job.finishedAt).toISOString(),
|
|
330
|
+
status: job.status,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
312
333
|
this.deps.feed?.end(job.id, job.status !== "success");
|
|
313
334
|
const ok = job.status === "success";
|
|
314
335
|
const outcome = ok ? "done" : `${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`;
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* turn into the feed, passes plan comments to the Master that owns a task and
|
|
7
7
|
* records each Master turn in the metrics log.
|
|
8
8
|
*/
|
|
9
|
-
import type { ExtensionAPI, ExtensionContext
|
|
10
|
-
import {
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
|
|
12
12
|
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
13
13
|
import { loadTask, peekTasks } from "../state/persistence.ts";
|
|
@@ -33,15 +33,15 @@ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
|
33
33
|
import { checkThinking } from "../pi/model-support.ts";
|
|
34
34
|
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
35
35
|
import { SessionChats } from "./session-files.ts";
|
|
36
|
-
import { answerMessage,
|
|
36
|
+
import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
|
|
37
37
|
import { QuickFixQueue } from "./quickfix.ts";
|
|
38
38
|
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
39
39
|
import { execCommand, IssuesState } from "./issues.ts";
|
|
40
40
|
import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
|
|
41
|
-
import
|
|
42
|
-
import { createMarkdownRenderer } from "./markdown.ts";
|
|
41
|
+
import { lobbyTheme } from "./theme.ts";
|
|
43
42
|
import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
|
|
44
43
|
import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
|
|
44
|
+
import { budgetClock } from "../state/budget.ts";
|
|
45
45
|
|
|
46
46
|
export const ANCHOR_KEY = "bot-lobby-anchor";
|
|
47
47
|
export { deliverComments };
|
|
@@ -62,8 +62,8 @@ interface Runtime {
|
|
|
62
62
|
planner?: PlanningSession;
|
|
63
63
|
issues: IssuesState;
|
|
64
64
|
unsubscribeFeed?: () => void;
|
|
65
|
-
/**
|
|
66
|
-
asker?:
|
|
65
|
+
/** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
|
|
66
|
+
asker?: Asker;
|
|
67
67
|
/** A questionnaire is on screen. */
|
|
68
68
|
asking: boolean;
|
|
69
69
|
/** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
|
|
@@ -425,7 +425,9 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
|
|
|
425
425
|
|
|
426
426
|
/** A task's status box without animations, for a session other than this window's. */
|
|
427
427
|
function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
|
|
428
|
-
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
const time = budgetClock(state.root, state.configDir, task.id, now);
|
|
430
|
+
return panelLines(task, persistedRuns(task), now, false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme, ...(time ? { time } : {}) });
|
|
429
431
|
}
|
|
430
432
|
|
|
431
433
|
function seatProfile(state: Runtime, member: PanelMember) {
|
|
@@ -463,10 +465,9 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
463
465
|
return state.planner;
|
|
464
466
|
}
|
|
465
467
|
|
|
466
|
-
/** The
|
|
467
|
-
function panelAsker(state: Runtime):
|
|
468
|
-
state.asker
|
|
469
|
-
return state.asker;
|
|
468
|
+
/** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
|
|
469
|
+
function panelAsker(state: Runtime): Asker {
|
|
470
|
+
return state.asker ?? askUser;
|
|
470
471
|
}
|
|
471
472
|
|
|
472
473
|
/**
|
|
@@ -483,7 +484,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
483
484
|
if (state.asking) return "the questions are already open";
|
|
484
485
|
state.asking = true;
|
|
485
486
|
try {
|
|
486
|
-
const asker =
|
|
487
|
+
const asker = panelAsker(state);
|
|
487
488
|
const chunks = questionnaires(session.questions);
|
|
488
489
|
for (let index = session.answered.length; index < chunks.length; index++) {
|
|
489
490
|
const result = await asker(chunks[index]!, state.ctx);
|
|
@@ -508,29 +509,6 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
|
|
|
508
509
|
}
|
|
509
510
|
}
|
|
510
511
|
|
|
511
|
-
const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper
|
|
515
|
-
* (and one Markdown renderer with its cache) per theme, so caches stay warm
|
|
516
|
-
* and a theme switch starts fresh.
|
|
517
|
-
*/
|
|
518
|
-
function lobbyTheme(theme: Theme): LobbyTheme {
|
|
519
|
-
let wrapped = lobbyThemes.get(theme);
|
|
520
|
-
if (!wrapped) {
|
|
521
|
-
wrapped = {
|
|
522
|
-
fg: (color, text) => theme.fg(color, text),
|
|
523
|
-
bold: (text) => theme.bold(text),
|
|
524
|
-
italic: (text) => theme.italic(text),
|
|
525
|
-
bg: (color, text) => theme.bg(color, text),
|
|
526
|
-
markdown: createMarkdownRenderer(getMarkdownTheme()),
|
|
527
|
-
strike: (text) => theme.strikethrough(text),
|
|
528
|
-
};
|
|
529
|
-
lobbyThemes.set(theme, wrapped);
|
|
530
|
-
}
|
|
531
|
-
return wrapped;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
512
|
function host(state: Runtime, tui: TUI): LobbyHost {
|
|
535
513
|
return {
|
|
536
514
|
rows: () => tui.terminal.rows,
|
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { Task } from "../../schemas/task.ts";
|
|
|
10
10
|
import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
|
|
11
11
|
import type { LobbyPanel } from "../../schemas/configuration.ts";
|
|
12
12
|
import { markdownBlocks } from "../markdown.ts";
|
|
13
|
-
import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
|
+
import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout, type PaneMark } from "../layout.ts";
|
|
14
14
|
|
|
15
15
|
/** The Lobby tab's scrollable panes. */
|
|
16
16
|
export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
|
|
@@ -36,6 +36,8 @@ export interface HomeInput {
|
|
|
36
36
|
focus?: HomePane;
|
|
37
37
|
/** Filled with where each pane landed and how much it holds. */
|
|
38
38
|
panes?: PaneLayout;
|
|
39
|
+
/** Each pane's newest entry as the frame before marked it, so the pane can measure what arrived below since. */
|
|
40
|
+
anchors?: ReadonlyMap<string, PaneMark>;
|
|
39
41
|
tick: number;
|
|
40
42
|
now: number;
|
|
41
43
|
/** Which panes show. */
|
|
@@ -144,18 +146,20 @@ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme,
|
|
|
144
146
|
|
|
145
147
|
/**
|
|
146
148
|
* What you wrote: a bubble on the right, only as wide as its longest line (up
|
|
147
|
-
* to `YOU_SHARE` of the pane), your words in
|
|
148
|
-
* user-message background — or, where the theme
|
|
149
|
-
* by a bar in that colour.
|
|
149
|
+
* to `YOU_SHARE` of the pane), your words in Markdown as pi renders them, in
|
|
150
|
+
* the accent colour on pi's user-message background — or, where the theme
|
|
151
|
+
* has no background, closed by a bar in that colour.
|
|
150
152
|
*/
|
|
151
153
|
export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
152
154
|
const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
|
|
153
|
-
|
|
155
|
+
// The renderer colours your words itself; without one (tests) they are wrapped and painted here.
|
|
156
|
+
const rendered = theme?.markdown ? markdownLines(text, most, theme, true, "you") : wrap(text, most).map((line) => paint(theme, "accent", line));
|
|
157
|
+
const lines = rendered.length > 0 ? rendered : [""];
|
|
154
158
|
const inner = Math.max(1, ...lines.map((line) => textWidth(line)));
|
|
155
159
|
const pad = " ".repeat(Math.max(0, width - inner - 2));
|
|
156
160
|
const bubble = theme?.bg
|
|
157
|
-
? (line: string) => theme.bg!("userMessageBg", ` ${fit(
|
|
158
|
-
: (line: string) => `${fit(
|
|
161
|
+
? (line: string) => theme.bg!("userMessageBg", ` ${fit(line, inner)} `)
|
|
162
|
+
: (line: string) => `${fit(line, inner)} ${paint(theme, "accent", "▐")}`;
|
|
159
163
|
return lines.map((line) => `${pad}${bubble(line)}`);
|
|
160
164
|
}
|
|
161
165
|
|
|
@@ -181,25 +185,19 @@ function openBlockLines(text: string, width: number, theme: LobbyTheme | undefin
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
/**
|
|
184
|
-
* The
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
+
* The reply the oracle is still writing. It is drawn block by block (see
|
|
189
|
+
* `markdownBlocks`): finished blocks come from the Markdown cache and only the
|
|
190
|
+
* one being written is rendered on each frame. It is drawn whole (it is
|
|
191
|
+
* capped at `MAX_REPLY_TEXT`), so the pane knows exactly how far it reaches.
|
|
188
192
|
*/
|
|
189
|
-
function liveLines(text: string, width: number, theme: LobbyTheme | undefined
|
|
193
|
+
function liveLines(text: string, width: number, theme: LobbyTheme | undefined): string[] {
|
|
190
194
|
const blocks = markdownBlocks(text);
|
|
191
|
-
const parts: string[][] = [];
|
|
192
|
-
let count = 0;
|
|
193
|
-
for (let index = blocks.length - 1; index >= 0 && count < need; index -= 1) {
|
|
194
|
-
const lines = index === blocks.length - 1 ? openBlockLines(blocks[index]!, width, theme) : oracleLines(blocks[index]!, width, theme);
|
|
195
|
-
if (lines.length === 0) continue;
|
|
196
|
-
parts.push(lines);
|
|
197
|
-
count += lines.length + 1;
|
|
198
|
-
}
|
|
199
195
|
const lines: string[] = [];
|
|
200
|
-
for (let
|
|
196
|
+
for (let index = 0; index < blocks.length; index += 1) {
|
|
197
|
+
const block = index === blocks.length - 1 ? openBlockLines(blocks[index]!, width, theme) : oracleLines(blocks[index]!, width, theme);
|
|
198
|
+
if (block.length === 0) continue;
|
|
201
199
|
if (lines.length > 0) lines.push("");
|
|
202
|
-
for (const line of
|
|
200
|
+
for (const line of block) lines.push(line);
|
|
203
201
|
}
|
|
204
202
|
return lines;
|
|
205
203
|
}
|
|
@@ -218,6 +216,20 @@ export function eventLines(text: string, at: number, width: number, theme?: Lobb
|
|
|
218
216
|
export interface PaneLines {
|
|
219
217
|
lines: string[];
|
|
220
218
|
total: number;
|
|
219
|
+
/** The newest entry and how many lines run from its first line to the end, for the next frame to measure against. */
|
|
220
|
+
mark?: PaneMark;
|
|
221
|
+
/** Lines that arrived below the anchor passed in since it was marked; absent when it is no longer there. */
|
|
222
|
+
grew?: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Where the anchor (an entry the frame before marked) sits in `entries`, so
|
|
227
|
+
* drawing goes on at least that far back; `entries.length` when it is gone.
|
|
228
|
+
* It is nearly always the last entry, so the search from the end is short.
|
|
229
|
+
*/
|
|
230
|
+
function reachOf<T extends object>(entries: readonly T[], anchor: PaneMark | undefined): { found: number; reach: number } {
|
|
231
|
+
const found = anchor ? entries.lastIndexOf(anchor.entry as T) : -1;
|
|
232
|
+
return { found, reach: found < 0 ? entries.length : found };
|
|
221
233
|
}
|
|
222
234
|
|
|
223
235
|
/** A message's drawn lines, kept until the pane's width, the theme or its grouping changes. */
|
|
@@ -259,6 +271,18 @@ function entryBlock(entry: ChatEntry, before: ChatEntry | undefined, width: numb
|
|
|
259
271
|
return lines;
|
|
260
272
|
}
|
|
261
273
|
|
|
274
|
+
export interface ChatTailOptions {
|
|
275
|
+
/** The reply the oracle is still writing. */
|
|
276
|
+
live?: string;
|
|
277
|
+
/** The oracle's turn is running. */
|
|
278
|
+
busy?: boolean;
|
|
279
|
+
tick?: number;
|
|
280
|
+
/** Tops a conversation that has earlier messages still to load. */
|
|
281
|
+
older?: string;
|
|
282
|
+
/** The newest message as the frame before marked it: drawing reaches at least back to it, to measure what arrived below. */
|
|
283
|
+
anchor?: PaneMark;
|
|
284
|
+
}
|
|
285
|
+
|
|
262
286
|
/**
|
|
263
287
|
* The newest `need` lines of the conversation, like a chat: your messages as
|
|
264
288
|
* bubbles on the right, the oracle's replies in Markdown on the left, each
|
|
@@ -269,7 +293,8 @@ function entryBlock(entry: ChatEntry, before: ChatEntry | undefined, width: numb
|
|
|
269
293
|
* whole conversation has been drawn. `older` tops a conversation that has
|
|
270
294
|
* earlier messages still to load.
|
|
271
295
|
*/
|
|
272
|
-
export function chatTail(chat: readonly ChatEntry[], width: number, theme: LobbyTheme | undefined, need: number,
|
|
296
|
+
export function chatTail(chat: readonly ChatEntry[], width: number, theme: LobbyTheme | undefined, need: number, options: ChatTailOptions = {}): PaneLines {
|
|
297
|
+
const { live, busy = false, tick = 0, older, anchor } = options;
|
|
273
298
|
const parts: string[][] = [];
|
|
274
299
|
let count = 0;
|
|
275
300
|
const add = (lines: string[]) => {
|
|
@@ -277,10 +302,18 @@ export function chatTail(chat: readonly ChatEntry[], width: number, theme: Lobby
|
|
|
277
302
|
count += lines.length + (parts.length > 0 ? 1 : 0);
|
|
278
303
|
parts.push(lines);
|
|
279
304
|
};
|
|
280
|
-
if (live?.trim()) add([speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...liveLines(live.trim(), width, theme
|
|
305
|
+
if (live?.trim()) add([speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...liveLines(live.trim(), width, theme)]);
|
|
281
306
|
else if (busy) add([speakerLine("oracle", width, theme, `${spinner(tick)} working…`)]);
|
|
307
|
+
const { found, reach } = reachOf(chat, anchor);
|
|
308
|
+
let mark: PaneMark | undefined;
|
|
309
|
+
let grew: number | undefined;
|
|
282
310
|
let index = chat.length - 1;
|
|
283
|
-
for (; index >= 0 && count < need
|
|
311
|
+
for (; index >= 0 && (count < need || index >= reach); index -= 1) {
|
|
312
|
+
const entry = chat[index]!;
|
|
313
|
+
add(entryBlock(entry, chat[index - 1], width, theme));
|
|
314
|
+
if (index === chat.length - 1) mark = { entry, below: count };
|
|
315
|
+
if (index === found) grew = count - anchor!.below;
|
|
316
|
+
}
|
|
284
317
|
const drawn = chat.length - 1 - index;
|
|
285
318
|
if (index < 0 && older) add(wrap(paint(theme, "dim", older), width));
|
|
286
319
|
const lines: string[] = [];
|
|
@@ -291,12 +324,12 @@ export function chatTail(chat: readonly ChatEntry[], width: number, theme: Lobby
|
|
|
291
324
|
// Messages not drawn yet count at the average height of those that were.
|
|
292
325
|
const rest = index + 1;
|
|
293
326
|
const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
|
|
294
|
-
return { lines, total };
|
|
327
|
+
return { lines, total, ...(mark ? { mark } : {}), ...(grew !== undefined ? { grew } : {}) };
|
|
295
328
|
}
|
|
296
329
|
|
|
297
330
|
/** The whole conversation drawn (the session browser's preview and tests; the Lobby tab draws only what shows). */
|
|
298
331
|
export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
|
|
299
|
-
return chatTail(chat, width, theme, Number.POSITIVE_INFINITY, live, busy, tick).lines;
|
|
332
|
+
return chatTail(chat, width, theme, Number.POSITIVE_INFINITY, { ...(live ? { live } : {}), busy, tick }).lines;
|
|
300
333
|
}
|
|
301
334
|
|
|
302
335
|
function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
|
|
@@ -334,19 +367,27 @@ function whole(lines: string[]): PaneLines {
|
|
|
334
367
|
return { lines, total: lines.length };
|
|
335
368
|
}
|
|
336
369
|
|
|
337
|
-
function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, need: number, theme?: LobbyTheme): PaneLines {
|
|
370
|
+
function conversation(input: HomeInput, chat: readonly ChatEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
|
|
338
371
|
const searching = Boolean(input.query);
|
|
339
372
|
const live = searching ? undefined : input.liveReply;
|
|
340
373
|
const busy = !searching && input.busy;
|
|
341
374
|
if (chat.length === 0 && !live && !busy) return whole(emptyChat(input, width, theme));
|
|
342
|
-
|
|
375
|
+
const older = searching ? undefined : input.olderNote;
|
|
376
|
+
return chatTail(chat, width, theme, need, { ...(live ? { live } : {}), busy, tick: input.tick, ...(older ? { older } : {}), ...(anchor ? { anchor } : {}) });
|
|
343
377
|
}
|
|
344
378
|
|
|
345
379
|
/** The newest `need` activity lines (one per entry), drawn only as far back as the pane shows. */
|
|
346
|
-
function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, need: number, theme?: LobbyTheme): PaneLines {
|
|
380
|
+
function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
|
|
347
381
|
if (entries.length === 0) return whole(wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width));
|
|
348
382
|
const shown = Number.isFinite(need) ? entries.slice(-Math.max(0, need)) : entries;
|
|
349
|
-
|
|
383
|
+
// One line per entry: where the anchor sits is a count, nothing needs drawing to find it.
|
|
384
|
+
const { found } = reachOf(entries, anchor);
|
|
385
|
+
return {
|
|
386
|
+
lines: shown.map((entry) => activityLine(entry, width, input.tick, theme)),
|
|
387
|
+
total: entries.length,
|
|
388
|
+
mark: { entry: entries.at(-1)!, below: 1 },
|
|
389
|
+
...(found >= 0 ? { grew: entries.length - found - anchor!.below } : {}),
|
|
390
|
+
};
|
|
350
391
|
}
|
|
351
392
|
|
|
352
393
|
/** The last `rows` lines, `offset` lines back from the newest (clamped), and where they start. */
|
|
@@ -381,8 +422,9 @@ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry
|
|
|
381
422
|
*/
|
|
382
423
|
const thoughtBlocks = new WeakMap<object, WeakMap<ThoughtEntry, { width: number; text: string; lines: string[]; at: number }>>();
|
|
383
424
|
|
|
384
|
-
/** A live thought this long is wrapped again at most every `LONG_THOUGHT_MS` while it streams
|
|
425
|
+
/** A live thought this long is wrapped again at most every `LONG_THOUGHT_MS` while it streams; rendered as Markdown (costlier), from `LONG_THOUGHT_MARKDOWN`. */
|
|
385
426
|
const LONG_THOUGHT = 1000;
|
|
427
|
+
const LONG_THOUGHT_MARKDOWN = 300;
|
|
386
428
|
const LONG_THOUGHT_MS = 150;
|
|
387
429
|
|
|
388
430
|
function thoughtBlock(thought: ThoughtEntry, width: number, theme?: LobbyTheme): string[] {
|
|
@@ -393,28 +435,49 @@ function thoughtBlock(thought: ThoughtEntry, width: number, theme?: LobbyTheme):
|
|
|
393
435
|
}
|
|
394
436
|
const hit = cache.get(thought);
|
|
395
437
|
const now = performance.now();
|
|
396
|
-
|
|
438
|
+
const long = theme?.markdown ? LONG_THOUGHT_MARKDOWN : LONG_THOUGHT;
|
|
439
|
+
if (hit && hit.width === width && (hit.text === thought.text || (thought.live && thought.text.length > long && now - hit.at < LONG_THOUGHT_MS))) return hit.lines;
|
|
397
440
|
const lead = `${paint(theme, sourceColor(thought.source), thought.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH))} `;
|
|
398
|
-
const lines = wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
|
|
441
|
+
const lines = theme?.markdown ? thoughtMarkdown(lead, thought, width, theme) : wrapHanging(lead, italic(theme, paint(theme, "dim", thought.text.replace(/\s*\n\s*/g, " "))), width);
|
|
399
442
|
cache.set(thought, { width, text: thought.text, lines, at: now });
|
|
400
443
|
return lines;
|
|
401
444
|
}
|
|
402
445
|
|
|
403
|
-
/**
|
|
404
|
-
|
|
446
|
+
/**
|
|
447
|
+
* A thought in Markdown as pi shows thinking (dimmed, in italics), after who
|
|
448
|
+
* thought it: a model's `**Checking the plan**` reads bold, not starred. The
|
|
449
|
+
* pane is small, so the blank lines between paragraphs are left out. A live
|
|
450
|
+
* thought is not kept in the renderer's cache; it changes as it streams.
|
|
451
|
+
*/
|
|
452
|
+
function thoughtMarkdown(lead: string, thought: ThoughtEntry, width: number, theme: LobbyTheme): string[] {
|
|
453
|
+
const indent = " ".repeat(textWidth(lead));
|
|
454
|
+
const body = markdownLines(thought.text, Math.max(1, width - textWidth(lead)), theme, !thought.live, "thought").filter((line) => line.trim());
|
|
455
|
+
if (body.length === 0) return [lead];
|
|
456
|
+
return body.map((line, index) => `${index === 0 ? lead : indent}${line}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The newest `need` lines of the thoughts, oldest first: only the thoughts they reach are drawn (and back to `anchor`), and the rest estimated. */
|
|
460
|
+
export function thoughtTail(thoughts: readonly ThoughtEntry[], width: number, theme: LobbyTheme | undefined, need: number, anchor?: PaneMark): PaneLines {
|
|
405
461
|
const parts: string[][] = [];
|
|
406
462
|
let count = 0;
|
|
463
|
+
const { found, reach } = reachOf(thoughts, anchor);
|
|
464
|
+
let mark: PaneMark | undefined;
|
|
465
|
+
let grew: number | undefined;
|
|
407
466
|
let index = thoughts.length - 1;
|
|
408
|
-
for (; index >= 0 && count < need; index -= 1) {
|
|
409
|
-
const
|
|
467
|
+
for (; index >= 0 && (count < need || index >= reach); index -= 1) {
|
|
468
|
+
const thought = thoughts[index]!;
|
|
469
|
+
const lines = thoughtBlock(thought, width, theme);
|
|
410
470
|
parts.push(lines);
|
|
411
471
|
count += lines.length;
|
|
472
|
+
if (index === thoughts.length - 1) mark = { entry: thought, below: count };
|
|
473
|
+
if (index === found) grew = count - anchor!.below;
|
|
412
474
|
}
|
|
413
475
|
const lines: string[] = [];
|
|
414
476
|
for (let part = parts.length - 1; part >= 0; part -= 1) for (const line of parts[part]!) lines.push(line);
|
|
415
477
|
const rest = index + 1;
|
|
416
478
|
const drawn = parts.length;
|
|
417
|
-
|
|
479
|
+
const total = rest > 0 && drawn > 0 ? lines.length + Math.ceil((rest * lines.length) / drawn) : lines.length;
|
|
480
|
+
return { lines, total, ...(mark ? { mark } : {}), ...(grew !== undefined ? { grew } : {}) };
|
|
418
481
|
}
|
|
419
482
|
|
|
420
483
|
/** Every thought, oldest first: who thought it, then the thought, dimmed. */
|
|
@@ -422,8 +485,8 @@ export function thoughtLines(thoughts: readonly ThoughtEntry[], width: number, t
|
|
|
422
485
|
return thoughtTail(thoughts, width, theme, Number.POSITIVE_INFINITY).lines;
|
|
423
486
|
}
|
|
424
487
|
|
|
425
|
-
function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, need: number, theme?: LobbyTheme): PaneLines {
|
|
426
|
-
if (thoughts.length > 0) return thoughtTail(thoughts, width, theme, need);
|
|
488
|
+
function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], width: number, need: number, anchor: PaneMark | undefined, theme?: LobbyTheme): PaneLines {
|
|
489
|
+
if (thoughts.length > 0) return thoughtTail(thoughts, width, theme, need, anchor);
|
|
427
490
|
return whole([paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")]);
|
|
428
491
|
}
|
|
429
492
|
|
|
@@ -482,23 +545,24 @@ export function renderHome(input: HomeInput, width: number, height: number, them
|
|
|
482
545
|
const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
|
|
483
546
|
/**
|
|
484
547
|
* One scrollable pane: only the lines it shows are drawn — its newest, or
|
|
485
|
-
* as far back as it is scrolled — in a box that records where it sits
|
|
548
|
+
* as far back as it is scrolled — in a box that records where it sits, and
|
|
549
|
+
* how many lines arrived below since the frame before.
|
|
486
550
|
*/
|
|
487
|
-
const pane = (name: HomePane, title: string, note: string | undefined, content: (inner: number, need: number) => PaneLines) => (top: number, left: number, w: number, h: number): string[] => {
|
|
551
|
+
const pane = (name: HomePane, title: string, note: string | undefined, content: (inner: number, need: number, anchor: PaneMark | undefined) => PaneLines) => (top: number, left: number, w: number, h: number): string[] => {
|
|
488
552
|
const rows = Math.max(0, h - 2);
|
|
489
553
|
const offset = input.offsets?.[name] ?? 0;
|
|
490
|
-
const drawn = content(Math.max(1, w - 4), rows + offset);
|
|
554
|
+
const drawn = content(Math.max(1, w - 4), rows + offset, input.anchors?.get(name));
|
|
491
555
|
const view = paneWindow(drawn, rows, offset);
|
|
492
556
|
const total = Math.max(drawn.total, drawn.lines.length);
|
|
493
|
-
notePane(input.panes, name, top, left, w, h, total);
|
|
557
|
+
notePane(input.panes, name, top, left, w, h, total, { ...(drawn.mark ? { mark: drawn.mark } : {}), grew: drawn.grew ?? 0 });
|
|
494
558
|
const right = rightNote(view.offset, note);
|
|
495
559
|
return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total, start: view.start }, theme });
|
|
496
560
|
};
|
|
497
|
-
const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner, need) => conversation(input, feed.chat, inner, need, theme));
|
|
498
|
-
const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner, need) => activity(input, feed.activity, inner, need, theme));
|
|
561
|
+
const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner, need, anchor) => conversation(input, feed.chat, inner, need, anchor, theme));
|
|
562
|
+
const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner, need, anchor) => activity(input, feed.activity, inner, need, anchor, theme));
|
|
499
563
|
const thought = currentThought(feed.thoughts);
|
|
500
564
|
const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
|
|
501
|
-
const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need) => thinkingContent(input, feed.thoughts, inner, need, theme));
|
|
565
|
+
const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need, anchor) => thinkingContent(input, feed.thoughts, inner, need, anchor, theme));
|
|
502
566
|
const top = scene.length;
|
|
503
567
|
let body: string[] = [];
|
|
504
568
|
if (main > 0 && panels.conversation && panels.activity) {
|