@a-t-h-i/bot-lobby 0.6.2 → 0.6.3

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 CHANGED
@@ -74,6 +74,13 @@ agreed in the Plan tab skips approval too.
74
74
  **Safety nets:** every agent has a time limit (asked to wrap up at 75%), a
75
75
  stall watchdog and one retry; `Esc` aborts every running agent.
76
76
 
77
+ **A fresh context per task.** Every agent runs in its own Pi process with its
78
+ own context. The oracle, which is your session, starts each task clean: its
79
+ model sees only the conversation since the task started, and once a task ends
80
+ your next request starts fresh (the lobby shows *context cleared*, and the
81
+ oracle is told where the finished task's record is). The session file keeps
82
+ everything. `workflow.freshContext: false` in the config turns this off.
83
+
77
84
  ## The lobby
78
85
 
79
86
  A full-screen view with a prompt at the bottom that talks to whatever tab is
@@ -96,6 +103,9 @@ Rebind any key under `lobby.keys` in the config.
96
103
  Pi session. The Lobby tab can show any session, and your prompt steers it;
97
104
  `● waiting` in the tab bar means one has a question for you.
98
105
 
106
+ The conversation keeps its newest 100 messages in memory; scroll to the top
107
+ to load the rest.
108
+
99
109
  ## Planning
100
110
 
101
111
  Describe an idea on the Plan tab. Each round, the **seats** — DEV, DESIGN, QA
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/index.ts CHANGED
@@ -10,10 +10,13 @@ import { registerLobbyEvents } from "./lobby/runtime.ts";
10
10
  import { registerOwner } from "./pi/owner.ts";
11
11
  import { registerClassifier } from "./classifier/instance.ts";
12
12
  import { registerClassifierTools } from "./classifier/tools.ts";
13
+ import { registerFreshContext } from "./pi/fresh-context.ts";
13
14
 
14
15
  export default function (pi: ExtensionAPI): void {
15
16
  // First, so every session_start handler below finds the classifier bound to this session's keys.
16
17
  registerClassifier(pi, CONFIG_DIR_NAME);
18
+ // Before the lifecycle, so a task that ended while the oracle was idle is closed before it builds the next turn's prompt.
19
+ registerFreshContext(pi, CONFIG_DIR_NAME);
17
20
  registerLifecycle(pi, CONFIG_DIR_NAME);
18
21
  // After the lifecycle, so the lobby opens over a task the widget state already knows.
19
22
  registerLobbyEvents(pi, CONFIG_DIR_NAME);
package/src/lobby/feed.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { AgentRun } from "../schemas/findings.ts";
9
9
  import { agentName } from "../pi/run-summary.ts";
10
+ import { markdownBlocks } from "./markdown.ts";
10
11
 
11
12
  export type ActivityKind = "info" | "success" | "warning" | "error";
12
13
 
@@ -43,9 +44,12 @@ export interface ChatEntry {
43
44
 
44
45
  export const MAX_ACTIVITY = 400;
45
46
  export const MAX_THOUGHTS = 40;
46
- export const MAX_CHAT = 200;
47
+ /** Messages the conversation keeps in memory; earlier ones are loaded from the session when scrolled to. */
48
+ export const MAX_CHAT = 100;
47
49
  /** Longest thought kept; a streaming thought keeps its newest text. */
48
50
  export const MAX_THOUGHT_TEXT = 4000;
51
+ /** The streaming reply kept: several panes' worth; past it, whole blocks are dropped from its start. */
52
+ export const MAX_REPLY_TEXT = 8000;
49
53
 
50
54
  function bounded<T>(list: T[], max: number): T[] {
51
55
  return list.length > max ? list.slice(-max) : list;
@@ -57,6 +61,8 @@ export class LobbyFeed {
57
61
  chat: ChatEntry[] = [];
58
62
  /** The oracle's reply while it streams; cleared when the message ends. */
59
63
  reply = "";
64
+ /** Earlier messages than `chat` holds exist in the session (loaded only when scrolled back to). */
65
+ chatOlder = false;
60
66
  version = 0;
61
67
  private nextId = 1;
62
68
  private readonly listeners = new Set<() => void>();
@@ -90,16 +96,22 @@ export class LobbyFeed {
90
96
 
91
97
  /** A one-off entry that is already done (a receipt, a transition, a note). */
92
98
  log(source: string, text: string, kind: ActivityKind = "info", at = Date.now()): void {
93
- this.activity = bounded([...this.activity, { id: this.nextId++, at, source, text, kind, pending: false }], MAX_ACTIVITY);
99
+ this.pushActivity({ id: this.nextId++, at, source, text, kind, pending: false });
94
100
  this.touch();
95
101
  }
96
102
 
97
103
  /** Open an in-flight step; `key` lets `end` settle exactly this one. */
98
104
  begin(source: string, text: string, key?: string, at = Date.now()): void {
99
- this.activity = bounded([...this.activity, { id: this.nextId++, at, source, text, kind: "info", pending: true, ...(key ? { key } : {}) }], MAX_ACTIVITY);
105
+ this.pushActivity({ id: this.nextId++, at, source, text, kind: "info", pending: true, ...(key ? { key } : {}) });
100
106
  this.touch();
101
107
  }
102
108
 
109
+ /** Append in place (a busy agent logs many steps a second), dropping the oldest past the limit. */
110
+ private pushActivity(entry: ActivityEntry): void {
111
+ this.activity.push(entry);
112
+ if (this.activity.length > MAX_ACTIVITY) this.activity.splice(0, this.activity.length - MAX_ACTIVITY);
113
+ }
114
+
103
115
  /** Settle the step opened with `key`; an error marks it. */
104
116
  end(key: string, isError = false): void {
105
117
  if (this.settle(undefined, key, isError ? "error" : undefined)) this.touch();
@@ -116,7 +128,7 @@ export class LobbyFeed {
116
128
  const last = this.thoughts.at(-1);
117
129
  if (last && last.live && last.source === source) {
118
130
  const text = last.text + delta;
119
- last.text = text.length > MAX_THOUGHT_TEXT ? text.slice(-MAX_THOUGHT_TEXT) : text;
131
+ last.text = text.length > MAX_THOUGHT_TEXT ? trimThought(text) : text;
120
132
  } else {
121
133
  this.thoughts = bounded([...this.thoughts, { id: this.nextId++, at, source, text: delta.slice(-MAX_THOUGHT_TEXT), live: true }], MAX_THOUGHTS);
122
134
  }
@@ -146,7 +158,7 @@ export class LobbyFeed {
146
158
 
147
159
  /** Stream the oracle's reply text as it arrives. */
148
160
  replyDelta(delta: string): void {
149
- this.reply = (this.reply + delta).slice(-MAX_THOUGHT_TEXT);
161
+ this.reply = trimReply(this.reply + delta);
150
162
  this.touch();
151
163
  }
152
164
 
@@ -160,13 +172,19 @@ export class LobbyFeed {
160
172
  say(role: ChatRole, text: string, at = Date.now()): void {
161
173
  const body = text.trim();
162
174
  if (!body) return;
163
- this.chat = bounded([...this.chat, { id: this.nextId++, at, role, text: body }], MAX_CHAT);
175
+ this.chat.push({ id: this.nextId++, at, role, text: body });
176
+ if (this.chat.length > MAX_CHAT) {
177
+ this.chat.splice(0, this.chat.length - MAX_CHAT);
178
+ this.chatOlder = true;
179
+ }
164
180
  this.touch();
165
181
  }
166
182
 
167
- /** Replace the conversation (seeding from the session when the lobby first opens). */
183
+ /** Replace the conversation (seeding from the session when the lobby first opens); only the newest `MAX_CHAT` are kept. */
168
184
  seedChat(entries: ReadonlyArray<{ role: ChatRole; text: string; at?: number }>): void {
169
- this.chat = bounded(entries.filter((entry) => entry.text.trim()).map((entry) => ({ id: this.nextId++, at: entry.at ?? 0, role: entry.role, text: entry.text.trim() })), MAX_CHAT);
185
+ const kept = entries.filter((entry) => entry.text.trim());
186
+ this.chatOlder = kept.length > MAX_CHAT;
187
+ this.chat = kept.slice(-MAX_CHAT).map((entry) => ({ id: this.nextId++, at: entry.at ?? 0, role: entry.role, text: entry.text.trim() }));
170
188
  this.touch();
171
189
  }
172
190
 
@@ -202,6 +220,7 @@ export class LobbyFeed {
202
220
  this.activity = [];
203
221
  this.thoughts = [];
204
222
  this.chat = [];
223
+ this.chatOlder = false;
205
224
  this.reply = "";
206
225
  this.runSteps.clear();
207
226
  this.runThoughts.clear();
@@ -210,6 +229,38 @@ export class LobbyFeed {
210
229
  }
211
230
  }
212
231
 
232
+ /** A streaming thought past `MAX_THOUGHT_TEXT` cut to three quarters of it from a word's start, so its start then holds still a while. */
233
+ function trimThought(text: string): string {
234
+ const cut = text.slice(-Math.floor(MAX_THOUGHT_TEXT * 0.75));
235
+ const space = cut.search(/\s/);
236
+ return space >= 0 && space < 80 ? cut.slice(space + 1) : cut;
237
+ }
238
+
239
+ /**
240
+ * A streaming reply past `max` characters cut down to three quarters of that
241
+ * (so its start then holds still for a while) by dropping whole Markdown
242
+ * blocks from its start, so what is left renders as it did (a cut inside a
243
+ * code block would turn the rest of the reply inside out). A single block
244
+ * longer than that keeps its end, and its opening fence if it is code.
245
+ */
246
+ export function trimReply(text: string, max = MAX_REPLY_TEXT): string {
247
+ if (text.length <= max) return text;
248
+ const target = Math.floor(max * 0.75);
249
+ const blocks = markdownBlocks(text);
250
+ let length = text.length;
251
+ let first = 0;
252
+ // Each dropped block takes the blank line after it too.
253
+ while (first < blocks.length - 1 && length > target) length -= blocks[first++]!.length + 2;
254
+ if (length <= target) return blocks.slice(first).join("\n\n");
255
+ const last = blocks.at(-1) ?? text;
256
+ const opener = /^ {0,3}(?:`{3,}|~{3,}).*\n/.exec(last)?.[0] ?? "";
257
+ const rest = last.slice(opener.length);
258
+ const cut = rest.slice(-(target - opener.length));
259
+ // From a line start, so no line (or code) is shown half cut.
260
+ const line = cut.indexOf("\n");
261
+ return `${opener}${line >= 0 && line < cut.length - 1 ? cut.slice(line + 1) : cut}`;
262
+ }
263
+
213
264
  /** The process-wide lobby feed; the Master session is the only writer that matters. */
214
265
  export const lobbyFeed = new LobbyFeed();
215
266
 
@@ -240,11 +291,28 @@ export function chatText(role: "user" | "assistant", text: string): Array<{ role
240
291
  return [{ role: "you", text: body }];
241
292
  }
242
293
 
294
+ /** The session entry bot-lobby leaves where the oracle's context starts over (see pi/fresh-context.ts). */
295
+ export const CONTEXT_MARK = "bot-lobby-context";
296
+
297
+ /** What the conversation shows where a task's end cleared the oracle's context. */
298
+ export const CLEARED_NOTE = "context cleared · the next request starts fresh";
299
+
300
+ /** Whether a session entry is where a finished task cleared the oracle's context. */
301
+ export function isClearedMark(entry: unknown): boolean {
302
+ const record = entry as { type?: string; customType?: string; data?: { kind?: unknown } } | undefined;
303
+ return record?.type === "custom" && record.customType === CONTEXT_MARK && record.data?.kind === "end";
304
+ }
305
+
243
306
  /** Session entries (`{ type: "message", message }`) as conversation entries, oldest first. */
244
307
  export function chatFromEntries(entries: readonly unknown[], max = MAX_CHAT): Array<{ role: ChatRole; text: string; at?: number }> {
245
308
  const chat: Array<{ role: ChatRole; text: string; at?: number }> = [];
246
309
  for (const entry of entries) {
247
310
  const record = entry as { type?: string; timestamp?: string; message?: { role?: string; content?: unknown } };
311
+ if (isClearedMark(entry)) {
312
+ const at = record.timestamp ? Date.parse(record.timestamp) : Number.NaN;
313
+ chat.push({ role: "note", text: CLEARED_NOTE, ...(Number.isFinite(at) ? { at } : {}) });
314
+ continue;
315
+ }
248
316
  if (record?.type !== "message") continue;
249
317
  const role = record.message?.role;
250
318
  if (role !== "user" && role !== "assistant") continue;
@@ -4,7 +4,8 @@
4
4
  * its widths and heights explicitly and never reads a clock or the terminal,
5
5
  * so each tab renders deterministically in tests.
6
6
  */
7
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
+ import { textWidth } from "../width.ts";
8
9
 
9
10
  /** Colours the lobby uses; a subset of pi's theme so tests can pass a plain stub. */
10
11
  export type LobbyColor =
@@ -29,7 +30,7 @@ export interface LobbyTheme {
29
30
  italic?(text: string): string;
30
31
  bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
31
32
  /** Render Markdown to styled lines; plain wrapping without it (tests). */
32
- markdown?(text: string, width: number): string[];
33
+ markdown?(text: string, width: number, keep?: boolean): string[];
33
34
  /** Strike text through (an abandoned task's title); plain without it (tests). */
34
35
  strike?(text: string): string;
35
36
  }
@@ -54,7 +55,7 @@ export function strike(theme: LobbyTheme | undefined, text: string): string {
54
55
  /** `left` and `right` on one line of exactly `width` columns: the left side gives way first. */
55
56
  export function spread(left: string, right: string, width: number): string {
56
57
  if (!right) return fit(left, width);
57
- const room = width - visibleWidth(right) - 1;
58
+ const room = width - textWidth(right) - 1;
58
59
  if (room < 4) return fit(left, width);
59
60
  return `${fit(left, room)} ${right}`;
60
61
  }
@@ -62,8 +63,8 @@ export function spread(left: string, right: string, width: number): string {
62
63
  /** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
63
64
  export function fit(text: string, width: number): string {
64
65
  if (width <= 0) return "";
65
- const cut = visibleWidth(text) > width ? truncateToWidth(text, width, "…") : text;
66
- const pad = width - visibleWidth(cut);
66
+ const cut = textWidth(text) > width ? truncateToWidth(text, width, "…") : text;
67
+ const pad = width - textWidth(cut);
67
68
  return pad > 0 ? `${cut}${" ".repeat(pad)}` : cut;
68
69
  }
69
70
 
@@ -83,7 +84,7 @@ export function wrap(text: string, width: number): string[] {
83
84
 
84
85
  /** Wrap with a hanging indent: the first line carries `lead`, the rest align under it. */
85
86
  export function wrapHanging(lead: string, text: string, width: number): string[] {
86
- const indent = visibleWidth(lead);
87
+ const indent = textWidth(lead);
87
88
  const body = wrap(text, Math.max(1, width - indent));
88
89
  if (body.length === 0) return [lead];
89
90
  return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
@@ -94,7 +95,7 @@ export function rule(width: number, title = "", theme?: LobbyTheme, right = "",
94
95
  if (width <= 0) return "";
95
96
  const head = title ? `── ${title} ` : "";
96
97
  const tail = right ? ` ${right} ──` : "";
97
- const fill = width - visibleWidth(head) - visibleWidth(tail);
98
+ const fill = width - textWidth(head) - textWidth(tail);
98
99
  if (fill < 1) return fit(paint(theme, color, head.trimEnd() || "─".repeat(width)), width);
99
100
  const titled = title ? `${paint(theme, color, "── ")}${bold(theme, paint(theme, "accent", title))}${paint(theme, color, " ")}` : "";
100
101
  const ending = right ? `${paint(theme, color, " ")}${paint(theme, "muted", right)}${paint(theme, color, " ──")}` : "";
@@ -183,8 +184,8 @@ export function spinner(tick: number): string {
183
184
  }
184
185
 
185
186
  /** Markdown for plans, reports and replies: the theme's renderer, or headings bold without their `#` and the rest wrapped. */
186
- export function markdownLines(text: string, width: number, theme?: LobbyTheme): string[] {
187
- if (theme?.markdown) return theme.markdown(text, width);
187
+ export function markdownLines(text: string, width: number, theme?: LobbyTheme, keep = true): string[] {
188
+ if (theme?.markdown) return theme.markdown(text, width, keep);
188
189
  return text.split("\n").flatMap((line) => {
189
190
  const heading = /^#{1,6}\s+(.*)$/.exec(line);
190
191
  return wrap(heading ? bold(theme, paint(theme, "mdHeading", heading[1]!)) : line, width);
@@ -193,7 +194,7 @@ export function markdownLines(text: string, width: number, theme?: LobbyTheme):
193
194
 
194
195
  /** Markdown behind a hanging lead (`oracle ▸ `): the first line carries the lead, the rest align under it. */
195
196
  export function markdownHanging(lead: string, text: string, width: number, theme?: LobbyTheme): string[] {
196
- const indent = visibleWidth(lead);
197
+ const indent = textWidth(lead);
197
198
  const body = markdownLines(text, Math.max(1, width - indent), theme);
198
199
  if (body.length === 0) return [lead];
199
200
  return body.map((line, index) => (index === 0 ? `${lead}${line}` : line ? `${" ".repeat(indent)}${line}` : ""));
@@ -261,11 +262,11 @@ export function box(width: number, height: number, content: readonly string[], o
261
262
  const inner = width - 4;
262
263
  const title = options.title ? ` ${options.title} ` : "";
263
264
  const right = options.right ? ` ${options.right} ` : "";
264
- let room = width - 2 - visibleWidth(title) - visibleWidth(right);
265
+ let room = width - 2 - textWidth(title) - textWidth(right);
265
266
  const shownRight = room >= 1 ? right : "";
266
- room = width - 2 - visibleWidth(title) - visibleWidth(shownRight);
267
+ room = width - 2 - textWidth(title) - textWidth(shownRight);
267
268
  const titled = room >= 1 ? title : fit(title, Math.max(0, width - 3));
268
- const fillWidth = Math.max(0, width - 2 - visibleWidth(titled) - visibleWidth(shownRight));
269
+ const fillWidth = Math.max(0, width - 2 - textWidth(titled) - textWidth(shownRight));
269
270
  const paintedTitle = titled ? bold(theme, paint(theme, focused ? "accent" : "text", titled)) : "";
270
271
  const top = `${edge("╭")}${paintedTitle}${edge("─".repeat(fillWidth))}${shownRight ? paint(theme, "dim", shownRight) : ""}${edge("╮")}`;
271
272
  const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
@@ -1,15 +1,14 @@
1
1
  /**
2
2
  * Markdown for the lobby: pi's own renderer (headings, emphasis, lists, code
3
3
  * with syntax highlighting, quotes, tables) under the session's theme, with
4
- * heading hashes dropped for a cleaner read. Renders are cached per theme,
5
- * width and text, since the lobby repaints several times a second.
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
6
  */
7
7
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
8
8
  import { Markdown, stripTerminalSequences, type MarkdownTheme } from "@earendil-works/pi-tui";
9
9
 
10
- export type MarkdownRenderer = (text: string, width: number) => string[];
11
-
12
- const CACHE_LIMIT = 96;
10
+ /** 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[];
13
12
 
14
13
  /** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
15
14
  const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
@@ -21,28 +20,72 @@ export function tidyHeading(line: string, bold: (text: string) => string): strin
21
20
  return bold(body.trimEnd());
22
21
  }
23
22
 
23
+ /** Rendered Markdown kept per renderer: the most recently used survive. */
24
+ export const CACHE_LIMIT = 512;
25
+
24
26
  /**
25
- * A renderer bound to a theme provider. `theme()` is read on every call, so a
26
- * theme switch re-renders instead of serving stale colours from the cache.
27
+ * A renderer for one Markdown theme. Renders are kept per width and text, the
28
+ * least recently used dropped first. pi's `getMarkdownTheme()` builds a new
29
+ * theme object on every call, so the theme is taken once, here: a theme
30
+ * switch makes a new renderer (the lobby keeps one per pi theme).
27
31
  */
28
- export function createMarkdownRenderer(theme: () => MarkdownTheme = getMarkdownTheme): MarkdownRenderer {
29
- const caches = new WeakMap<object, Map<string, string[]>>();
30
- return (text, width) => {
31
- const current = theme();
32
- let cache = caches.get(current);
33
- if (!cache) {
34
- cache = new Map();
35
- caches.set(current, cache);
36
- }
32
+ export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme()): MarkdownRenderer {
33
+ const cache = new Map<string, string[]>();
34
+ return (text, width, keep = true) => {
37
35
  const key = `${width}\0${text}`;
38
36
  const hit = cache.get(key);
39
- if (hit) return hit;
40
- const lines = new Markdown(text, 0, 0, current).render(Math.max(1, width)).map((line) => tidyHeading(line.trimEnd(), current.bold));
37
+ if (hit) {
38
+ // Most recently used last, so the oldest is the one dropped.
39
+ cache.delete(key);
40
+ cache.set(key, hit);
41
+ return hit;
42
+ }
43
+ const lines = new Markdown(text, 0, 0, theme).render(Math.max(1, width)).map((line) => tidyHeading(line.trimEnd(), theme.bold));
41
44
  // Drop the blank lines pi pads the render with at either end.
42
45
  while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
43
46
  while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
47
+ if (!keep) return lines;
44
48
  cache.set(key, lines);
45
49
  if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!);
46
50
  return lines;
47
51
  };
48
52
  }
53
+
54
+ /** A fenced code block's opening or closing line. */
55
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
56
+ /** A list item's first line: after a blank line it continues the list above, so blocks never split there. */
57
+ const LIST_ITEM = /^(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
58
+
59
+ /**
60
+ * Markdown split into blocks that render, one by one, into the same lines as
61
+ * the whole (each block's lines, a blank line between blocks, blocks that
62
+ * render to nothing left out). A split falls on a blank line outside code
63
+ * fences that is followed by an unindented line that does not start a list
64
+ * item — the only places where one block can never run into the next. A
65
+ * streaming reply can then keep its finished blocks rendered and render only
66
+ * the one still being written.
67
+ */
68
+ export function markdownBlocks(text: string): string[] {
69
+ const lines = text.split("\n");
70
+ const blocks: string[] = [];
71
+ let fence: string | undefined;
72
+ let start = 0;
73
+ for (let index = 0; index < lines.length; index += 1) {
74
+ const line = lines[index]!;
75
+ const marker = FENCE.exec(line)?.[1];
76
+ if (marker) {
77
+ if (!fence) fence = marker;
78
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
79
+ continue;
80
+ }
81
+ if (fence || line.trim() || index === 0) continue;
82
+ const next = lines[index + 1];
83
+ if (next === undefined || !/^\S/.test(next) || LIST_ITEM.test(next)) continue;
84
+ const block = lines.slice(start, index).join("\n");
85
+ if (block.trim()) blocks.push(block);
86
+ start = index + 1;
87
+ }
88
+ const last = lines.slice(start).join("\n");
89
+ if (last.trim()) blocks.push(last);
90
+ return blocks;
91
+ }
@@ -7,10 +7,10 @@
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
9
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
- import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
10
+ import { getMarkdownTheme, 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
- import { listTasks, loadTask } from "../state/persistence.ts";
13
+ import { loadTask, peekTasks } from "../state/persistence.ts";
14
14
  import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
15
15
  import { addPlanComment, readPlanComments } from "../state/comments.ts";
16
16
  import { isAutoMode } from "../state/auto.ts";
@@ -28,7 +28,7 @@ import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-
28
28
  import { modelLookup } from "../pi/tools.ts";
29
29
  import { startPlannedTask, startTask } from "../pi/start-task.ts";
30
30
  import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
31
- import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
31
+ import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike, type ChatEntry } from "./feed.ts";
32
32
  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";
@@ -83,8 +83,28 @@ export function lobbyView(): LobbyView | undefined {
83
83
  return runtime?.view;
84
84
  }
85
85
 
86
+ /** Repaints asked for by what happens in the background (a streamed token, a run update) come at most this often. */
87
+ export const FRAME_MS = 40;
88
+ let lastFrame = 0;
89
+ let frameTimer: ReturnType<typeof setTimeout> | undefined;
90
+
91
+ /**
92
+ * Repaint the lobby for something that happened in the background. A reply
93
+ * streams dozens of events a second; they share frames, at most one per
94
+ * `FRAME_MS`, instead of each asking for its own. Keys repaint at once
95
+ * through pi.
96
+ */
86
97
  function rerender(): void {
87
- if (runtime?.visible) runtime.tui?.requestRender();
98
+ if (!runtime?.visible || frameTimer) return;
99
+ const wait = lastFrame + FRAME_MS - Date.now();
100
+ const paint = () => {
101
+ frameTimer = undefined;
102
+ lastFrame = Date.now();
103
+ if (runtime?.visible) runtime.tui?.requestRender();
104
+ };
105
+ if (wait <= 0) return paint();
106
+ frameTimer = setTimeout(paint, wait);
107
+ frameTimer.unref?.();
88
108
  }
89
109
 
90
110
  function sessionModel(ctx: ExtensionContext): string | undefined {
@@ -304,6 +324,16 @@ function liveSessions(state: Runtime): LiveSession[] {
304
324
  return sessions;
305
325
  }
306
326
 
327
+ /**
328
+ * A session's whole conversation, oldest first: this window's from pi's
329
+ * branch (already in memory), another's from its session file. Loaded only
330
+ * while the lobby is scrolled back through it.
331
+ */
332
+ function chatHistory(state: Runtime, sessionId?: string): ChatEntry[] {
333
+ if (sessionId) return chats.history(sessionId);
334
+ return chatFromEntries(state.ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY).map((line, index) => ({ id: -(index + 1), at: line.at ?? 0, role: line.role, text: line.text }));
335
+ }
336
+
307
337
  /** Leave a message for a session running in another terminal. */
308
338
  function sendToSession(state: Runtime, sessionId: string, text: string): string {
309
339
  try {
@@ -478,10 +508,13 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
478
508
  }
479
509
  }
480
510
 
481
- const renderMarkdown = createMarkdownRenderer();
482
511
  const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
483
512
 
484
- /** pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper per theme so caches stay warm. */
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
+ */
485
518
  function lobbyTheme(theme: Theme): LobbyTheme {
486
519
  let wrapped = lobbyThemes.get(theme);
487
520
  if (!wrapped) {
@@ -490,7 +523,7 @@ function lobbyTheme(theme: Theme): LobbyTheme {
490
523
  bold: (text) => theme.bold(text),
491
524
  italic: (text) => theme.italic(text),
492
525
  bg: (color, text) => theme.bg(color, text),
493
- markdown: renderMarkdown,
526
+ markdown: createMarkdownRenderer(getMarkdownTheme()),
494
527
  strike: (text) => theme.strikethrough(text),
495
528
  };
496
529
  lobbyThemes.set(theme, wrapped);
@@ -515,7 +548,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
515
548
  },
516
549
  feed: lobbyFeed,
517
550
  masterBusy: () => !state.ctx.isIdle(),
518
- tasks: () => listTasks(state.root, state.configDir),
551
+ tasks: () => peekTasks(state.root, state.configDir),
519
552
  plans: () => listPlannedTasks(state.root, state.configDir),
520
553
  comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
521
554
  metrics: () => readMetrics(state.root, state.configDir),
@@ -561,6 +594,8 @@ function host(state: Runtime, tui: TUI): LobbyHost {
561
594
  restoreTask: (taskId) => restoreTask(state, taskId),
562
595
  deleteTask: (taskId, where) => deleteTask(state, taskId, where),
563
596
  sessionChat: (sessionId) => chats.chat(sessionId),
597
+ hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
598
+ chatHistory: (sessionId) => chatHistory(state, sessionId),
564
599
  taskScene: (task, width, height) => taskScene(state, task, width, height),
565
600
  requestRender: () => tui.requestRender(),
566
601
  };
@@ -742,7 +777,8 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
742
777
  });
743
778
  runtime = state;
744
779
  lobbyFeed.clear();
745
- lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch()));
780
+ // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
781
+ lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
746
782
  state.unsubscribeFeed = lobbyFeed.onChange(rerender);
747
783
  setWidgetSuppressor(() => runtime?.visible === true);
748
784
  onRunUpdates((runs) => lobbyFeed.runs(runs));