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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,15 @@
1
1
  /**
2
- * The Lobby tab: the zen scene of this session's task on top, then the
2
+ * The Lobby tab: the zen scene of this session's task on top (the oracle
3
+ * and agent animations, or just their status when animations are off), then the
3
4
  * conversation with the oracle (text only, no tool rows, no thinking), the
4
5
  * activity log of plain-words steps from every agent, and the one place where
5
6
  * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
6
7
  */
8
+ import { visibleWidth } from "@earendil-works/pi-tui";
7
9
  import type { Task } from "../../schemas/task.ts";
8
10
  import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
9
11
  import type { LobbyPanel } from "../../schemas/configuration.ts";
10
- import { beside, bold, box, clock, fill, italic, markdownHanging, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
12
+ import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
11
13
 
12
14
  /** The Lobby tab's scrollable panes. */
13
15
  export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
@@ -15,8 +17,8 @@ export type HomePane = (typeof HOME_PANES)[number];
15
17
 
16
18
  export interface HomeInput {
17
19
  task?: Task;
18
- /** Draws the zen scene into at most `height` lines; absent when no task is active. */
19
- scene?: (width: number, height: number) => string[];
20
+ /** Draws the zen scene into at most `height` lines, animated or still; absent when no task is active. */
21
+ scene?: (width: number, height: number, animated: boolean) => string[];
20
22
  chat: readonly ChatEntry[];
21
23
  /** The oracle's reply while it streams. */
22
24
  liveReply?: string;
@@ -41,13 +43,22 @@ export interface HomeInput {
41
43
  query?: string;
42
44
  /** Key labels for the pane toggles, shown on the panes. */
43
45
  keys?: Record<LobbyPanel, string>;
46
+ /** Another session is shown: its name titles the conversation. */
47
+ title?: string;
48
+ /** The scene is another session's still status (no animations to toggle). */
49
+ stillScene?: boolean;
50
+ /** What the empty conversation says instead of the default (a session starting, say). */
51
+ emptyNote?: string;
52
+ /** What the empty activity log says instead of "No activity yet.". */
53
+ activityNote?: string;
44
54
  }
45
55
 
46
56
  /** Wide terminals put the conversation and the activity log side by side. */
47
57
  export const HOME_COLUMNS_MIN = 100;
48
- /** Most lines the scene may take, and its share of the body. */
58
+ /** Most lines the scene may take, and its share of the body; the still status needs far fewer. */
49
59
  const SCENE_SHARE = 0.45;
50
60
  const MAX_SCENE = 36;
61
+ const MAX_STILL = 10;
51
62
 
52
63
  const SOURCE_COLORS: Record<string, LobbyColor> = {
53
64
  MASTER: "accent",
@@ -83,34 +94,121 @@ export function activityLine(entry: ActivityEntry, width: number, tick: number,
83
94
  return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
84
95
  }
85
96
 
86
- function chatLead(role: ChatEntry["role"], theme?: LobbyTheme): string {
87
- if (role === "you") return `${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `;
88
- if (role === "oracle") return `${bold(theme, paint(theme, "toolTitle", "oracle"))} ${paint(theme, "dim", "▸")} `;
89
- return `${paint(theme, "dim", "·")} `;
97
+ /**
98
+ * How each speaker is shown: a mark and a name in its colour, and the side of
99
+ * the conversation it speaks from — you on the right, the oracle on the left.
100
+ */
101
+ export const SPEAKERS = {
102
+ you: { mark: "●", name: "You", color: "accent", side: "right" },
103
+ oracle: { mark: "◆", name: "Oracle", color: "toolTitle", side: "left" },
104
+ panel: { mark: "◆", name: "Panel", color: "toolTitle", side: "left" },
105
+ } as const satisfies Record<string, { mark: string; name: string; color: LobbyColor; side: "left" | "right" }>;
106
+ export type Speaker = keyof typeof SPEAKERS;
107
+
108
+ /** Messages from one speaker within this long of each other share a header. */
109
+ const GROUP_MS = 5 * 60_000;
110
+ /** The oracle's replies sit under its name, past its mark. */
111
+ const BODY_INDENT = 2;
112
+ /** Your messages take at most this share of the pane, the oracle's this much, so the two sides read apart. */
113
+ const YOU_SHARE = 0.72;
114
+ const ORACLE_SHARE = 0.86;
115
+ /** Narrower panes give every message (nearly) the full width. */
116
+ const NARROW = 48;
117
+
118
+ /** The widest a message from one side may be in a pane `width` wide. */
119
+ function messageWidth(width: number, share: number, margin: number): number {
120
+ return Math.max(1, width < NARROW ? width - margin : Math.floor(width * share));
121
+ }
122
+
123
+ /**
124
+ * Who speaks and when: `◆ Oracle ········ 12:04` on the left for the oracle,
125
+ * `12:04 You ●` on the right for you. `note` is the time, or what the
126
+ * speaker is doing (`⠋ writing`).
127
+ */
128
+ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, note = ""): string {
129
+ const { mark, name, color, side } = SPEAKERS[speaker];
130
+ const who = paint(theme, color, mark);
131
+ const label = bold(theme, paint(theme, color, name));
132
+ const dimmed = note ? paint(theme, "dim", note) : "";
133
+ if (side === "right") {
134
+ const head = dimmed ? `${dimmed} ${label} ${who}` : `${label} ${who}`;
135
+ return `${" ".repeat(Math.max(0, width - visibleWidth(head)))}${head}`;
136
+ }
137
+ const left = `${who} ${label}`;
138
+ const gap = width - visibleWidth(left) - visibleWidth(dimmed);
139
+ return gap >= 1 && dimmed ? `${left}${" ".repeat(gap)}${dimmed}` : left;
140
+ }
141
+
142
+ /**
143
+ * What you wrote: a bubble on the right, only as wide as its longest line (up
144
+ * to `YOU_SHARE` of the pane), your words in the accent colour on pi's
145
+ * user-message background — or, where the theme has no background, closed
146
+ * by a bar in that colour.
147
+ */
148
+ export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
149
+ const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
150
+ const lines = wrap(text, most);
151
+ const inner = Math.max(1, ...lines.map((line) => visibleWidth(line)));
152
+ const pad = " ".repeat(Math.max(0, width - inner - 2));
153
+ const bubble = theme?.bg
154
+ ? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "accent", line), inner)} `)
155
+ : (line: string) => `${fit(paint(theme, "accent", line), inner)} ${paint(theme, "accent", "▐")}`;
156
+ return lines.map((line) => `${pad}${bubble(line)}`);
90
157
  }
91
158
 
92
- /** The conversation as wrapped lines, oldest first, a blank line between turns. */
159
+ /** The oracle's reply as Markdown on the left, under its name. */
160
+ function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
161
+ const indent = " ".repeat(BODY_INDENT);
162
+ return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
163
+ }
164
+
165
+ /** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
166
+ export function eventLines(text: string, at: number, width: number, theme?: LobbyTheme): string[] {
167
+ if (text.startsWith("✗")) return wrap(paint(theme, "error", text), width);
168
+ const label = ` ${text}${at > 0 ? ` · ${clock(at)}` : ""} `;
169
+ const room = width - visibleWidth(label);
170
+ if (room < 6) return wrap(paint(theme, "dim", text), width);
171
+ const left = Math.floor(room / 2);
172
+ return [paint(theme, "dim", `${"─".repeat(left)}${label}${"─".repeat(room - left)}`)];
173
+ }
174
+
175
+ /**
176
+ * The conversation, oldest first, like a chat: your messages as bubbles on
177
+ * the right, the oracle's replies in Markdown on the left, each turn under a
178
+ * line naming its speaker and time, events as centred rules, and while the
179
+ * oracle works its header says so (streaming the reply under it).
180
+ */
93
181
  export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
94
182
  const lines: string[] = [];
95
- const push = (role: ChatEntry["role"], text: string) => {
96
- if (lines.length > 0) lines.push("");
97
- // The oracle writes Markdown; what you typed and notes are shown as written.
98
- if (role === "oracle") lines.push(...markdownHanging(chatLead(role, theme), text, width, theme));
99
- else lines.push(...wrapHanging(chatLead(role, theme), role === "note" ? paint(theme, "dim", text) : text, width));
183
+ const gap = () => {
184
+ if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
100
185
  };
101
- for (const entry of chat) push(entry.role, entry.text);
186
+ let last: ChatEntry | undefined;
187
+ for (const entry of chat) {
188
+ gap();
189
+ if (entry.role === "note") {
190
+ lines.push(...eventLines(entry.text, entry.at, width, theme));
191
+ last = undefined;
192
+ continue;
193
+ }
194
+ const grouped = last?.role === entry.role && entry.at >= last.at && entry.at - last.at < GROUP_MS;
195
+ if (!grouped) lines.push(speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : ""));
196
+ lines.push(...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)));
197
+ last = entry;
198
+ }
102
199
  if (live?.trim()) {
103
- push("oracle", live.trim());
104
- lines[lines.length - 1] = `${lines.at(-1)!.trimEnd()} ${paint(theme, "accent", spinner(tick))}`;
200
+ gap();
201
+ lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...oracleLines(live.trim(), width, theme));
105
202
  } else if (busy) {
106
- if (lines.length > 0) lines.push("");
107
- lines.push(...wrapHanging(chatLead("oracle", theme), paint(theme, "dim", `${spinner(tick)} working…`), width));
203
+ gap();
204
+ lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} working…`));
108
205
  }
109
206
  return lines;
110
207
  }
111
208
 
112
209
  function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
113
210
  if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
211
+ if (input.emptyNote) return wrap(paint(theme, "dim", input.emptyNote), width);
114
212
  const lines = input.task
115
213
  ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
116
214
  : [
@@ -147,7 +245,7 @@ function conversation(input: HomeInput, chat: readonly ChatEntry[], width: numbe
147
245
  }
148
246
 
149
247
  function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, theme?: LobbyTheme): string[] {
150
- if (entries.length === 0) return [paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : "No activity yet.")];
248
+ if (entries.length === 0) return wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width);
151
249
  return entries.map((entry) => activityLine(entry, width, input.tick, theme));
152
250
  }
153
251
 
@@ -177,17 +275,35 @@ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], wi
177
275
  return [paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")];
178
276
  }
179
277
 
180
- function sceneLines(input: HomeInput, width: number, height: number): string[] {
181
- if (!input.task || !input.scene || !input.panels.scene) return [];
182
- const budget = Math.min(MAX_SCENE, Math.floor(height * SCENE_SHARE));
183
- if (budget < 6) return [];
184
- return input.scene(width, budget).slice(0, budget);
278
+ /**
279
+ * The scene: with animations off (the `animations` panel, off by default)
280
+ * only the task's status box, what the agents are doing and the checklist;
281
+ * with them on, the animated oracle and agents too. Its first line names the
282
+ * key that toggles the animations.
283
+ */
284
+ function sceneLines(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
285
+ if (!input.task || !input.scene) return [];
286
+ const animated = input.panels.animations && !input.stillScene;
287
+ const budget = Math.min(animated ? MAX_SCENE : MAX_STILL, Math.floor(height * SCENE_SHARE));
288
+ if (budget < (animated ? 6 : 4)) return [];
289
+ const lines = input.scene(width, budget, animated).slice(0, budget);
290
+ const note = input.keys && !input.stillScene ? `${input.keys.animations} ${animated ? "hides" : "shows"} animations` : undefined;
291
+ return keyNote(lines, width, note, theme);
292
+ }
293
+
294
+ /** `note` at the right end of the first line, dimmed, when it fits beside what is there. */
295
+ function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
296
+ const first = lines[0];
297
+ if (!note || first === undefined) return [...lines];
298
+ const gap = width - visibleWidth(first) - visibleWidth(note) - 1;
299
+ if (gap < 2) return [...lines];
300
+ return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
185
301
  }
186
302
 
187
303
  function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
188
304
  const keys = input.keys;
189
305
  const text = keys
190
- ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking${input.task ? ` · ${keys.scene} scene` : ""}`
306
+ ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking`
191
307
  : "Every pane is hidden.";
192
308
  const lines = wrap(paint(theme, "dim", text), width);
193
309
  const top = Math.max(0, Math.floor((height - lines.length) / 2));
@@ -205,7 +321,7 @@ export function renderHome(input: HomeInput, width: number, height: number, them
205
321
  if (height <= 0) return [];
206
322
  const { panels } = input;
207
323
  const feed = filterFeed(input);
208
- const scene = sceneLines(input, width, height);
324
+ const scene = sceneLines(input, width, height, theme);
209
325
  const rest = height - scene.length;
210
326
  const showMain = panels.conversation || panels.activity;
211
327
  const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
@@ -220,7 +336,7 @@ export function renderHome(input: HomeInput, width: number, height: number, them
220
336
  const right = rightNote(view.offset, note);
221
337
  return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total: all.length, start: view.start }, theme });
222
338
  };
223
- const chatBox = pane("conversation", input.task ? `Conversation · ${input.task.id}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
339
+ const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
224
340
  const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner) => activity(input, feed.activity, inner, theme));
225
341
  const thought = currentThought(feed.thoughts);
226
342
  const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
@@ -9,8 +9,8 @@
9
9
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
10
10
  import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
11
11
  import type { PlannedTask } from "../../state/backlog.ts";
12
- import { beside, bold, box, fill, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
- import { sourceColor, tailWindow } from "./home.ts";
12
+ import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+ import { sourceColor, speakerLine, tailWindow, youLines } from "./home.ts";
14
14
 
15
15
  export interface SeatView {
16
16
  label: string;
@@ -77,43 +77,34 @@ export interface PlanTabInput {
77
77
  layout?: PlanLayout;
78
78
  /** Filled with where the conversation (`talk`) and the draft landed, for scrolling. */
79
79
  panes?: PaneLayout;
80
+ /** The key that saves the plan, as the status line names it. */
81
+ saveKey?: string;
80
82
  }
81
83
 
82
84
  export const PLAN_COLUMNS_MIN = 100;
83
85
  const LABEL_WIDTH = 8;
84
86
 
87
+ /** Before a session: one sentence, then who sits on the panel and the model each runs on. */
85
88
  function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[] {
86
- // The roster leads with the chair; the intro names it separately.
87
- const members = input.seats.filter((seat) => seat.label !== ORACLE_LABEL);
88
- const seats = members.filter((seat) => seat.seated).map((seat) => seat.label);
89
- const text = [
90
- bold(theme, "Plan a task with the whole team before anyone writes code."),
91
- "",
92
- `Describe what you want below. The planning panel — the oracle chairing${seats.length > 0 ? `, with ${seats.join(", ")}` : ""} — reads the codebase and questions you, each seat from its own domain: contracts and data, flows and states, acceptance criteria and tests, libraries and prior art.`,
93
- "",
94
- "After each round the oracle puts the panel's questions to you one at a time, each with options and room for your own answer, and folds every answer into the draft plan, so all the agents start from the same decisions.",
95
- "",
96
- "Comment on any line of the draft: enter moves to the draft, ↑↓ pick a line (or click it), c comments. When the panel agrees, s saves the plan to the pending tasks list. While browsing (esc), 1-4 seat or unseat DEV, DESIGN, QA and RESEARCH.",
97
- "",
98
- paint(theme, "dim", `oracle: ${input.profile} (m changes it; each seat uses its domain's settings, alt+s)`),
99
- ...members.map((seat) => paint(theme, "dim", `${seat.label.toLowerCase()}: ${seat.seated ? seat.profile ?? "" : "not seated"}`)),
100
- ];
101
- return text.flatMap((line) => wrap(line, width));
89
+ const rows = input.seats.map((seat) => {
90
+ const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label.padEnd(LABEL_WIDTH + 2));
91
+ const model = seat.label === ORACLE_LABEL ? input.profile : seat.seated ? seat.profile ?? "" : "not seated";
92
+ return fit(`${name}${paint(theme, seat.seated ? "muted" : "dim", model)}`, width);
93
+ });
94
+ return [...wrap(bold(theme, "Describe a task below and the panel questions you until the plan is clear."), width), "", ...rows];
102
95
  }
103
96
 
104
97
  function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
105
98
  const parts: string[] = [];
99
+ const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
106
100
  if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
107
- else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — esc, then r retries`));
108
- else if (view.reply?.status === "ready") parts.push(paint(theme, "success", "✓ READY — the panel agrees; s saves it as a pending task"));
109
- else if (view.awaitingAnswers) {
110
- const left = view.answeredChunks > 0 ? " (some answered — enter resumes)" : " — enter answers them one at a time";
111
- parts.push(paint(theme, "warning", `● ${view.questions.length} question${view.questions.length === 1 ? "" : "s"} for you${left}`));
112
- }
113
- if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${view.lineComments.length} line comment${view.lineComments.length === 1 ? "" : "s"} to send`));
101
+ else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — r retries`));
102
+ else if (view.reply?.status === "ready") parts.push(paint(theme, "success", `✓ ready — ${input.saveKey ?? "Ctrl+S"} saves it`));
103
+ else if (view.awaitingAnswers) parts.push(paint(theme, "warning", `● ${count(view.questions.length, "question")} — enter ${view.answeredChunks > 0 ? "resumes" : "answers them"}`));
104
+ if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${count(view.lineComments.length, "comment")} to send`));
114
105
  if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
115
106
  if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
116
- return parts.join(paint(theme, "dim", " · ")) || paint(theme, "dim", "Answer below; enter sends.");
107
+ return parts.join(paint(theme, "dim", " · "));
117
108
  }
118
109
 
119
110
  /** One seat on the roster: a spinner and its step while it works, then ready, its question count or a failure. */
@@ -124,6 +115,8 @@ function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
124
115
  if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
125
116
  if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
126
117
  if (seat.ready) return `${name} ${paint(theme, "success", "✓ ready")}`;
118
+ // A seat whose questions the oracle settled itself has nothing waiting on you.
119
+ if (seat.questions === 0) return `${name} ${paint(theme, "dim", "done")}`;
127
120
  return `${name} ${paint(theme, "warning", `${seat.questions} question${seat.questions === 1 ? "" : "s"}`)}`;
128
121
  }
129
122
 
@@ -158,12 +151,14 @@ export function conversationLines(view: PlanView, width: number, theme?: LobbyTh
158
151
  .filter(({ question }) => matches(`${question.from} ${question.text} ${question.options.map((option) => option.label).join(" ")}`, query));
159
152
  if (message.role === "you" ? !matches(message.text, query) : message.questions?.length ? questions.length === 0 : !matches(message.text, query)) continue;
160
153
  if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
154
+ // The same turns as the Lobby's conversation: who speaks and when, your words in a band.
155
+ const time = message.at > 0 ? clock(message.at) : "";
161
156
  if (message.role === "you") {
162
- lines.push(...wrapHanging(`${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `, message.text, width));
157
+ lines.push(speakerLine("you", width, theme, time), ...youLines(message.text, width, theme));
163
158
  continue;
164
159
  }
165
- lines.push(`${bold(theme, paint(theme, "toolTitle", "panel"))} ${paint(theme, "dim", "▸")}`);
166
- lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width)));
160
+ lines.push(speakerLine("panel", width, theme, time));
161
+ lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width).map((line) => (line ? ` ${line}` : ""))));
167
162
  }
168
163
  if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
169
164
  return lines;
@@ -201,7 +196,7 @@ export function renderPlan(input: PlanTabInput, width: number, height: number, t
201
196
  if (height <= 0) return [];
202
197
  const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
203
198
  const view = input.session;
204
- if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", right: input.profile, theme })], height, width);
199
+ if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", theme })], height, width);
205
200
  const header = [
206
201
  ...notice,
207
202
  ...wrap(`${bold(theme, view.title ? `Planning · ${view.title}` : "Planning")} ${statusLine(view, input, theme)}`, width),
@@ -73,16 +73,13 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
73
73
  return lines;
74
74
  }
75
75
 
76
+ /** Before the first fix: one sentence, then the agent and the model it runs on. */
76
77
  function intro(input: QuickFixTabInput, width: number, theme?: LobbyTheme): string[] {
77
78
  return [
78
- bold(theme, "Make a direct change, the way you would ask pi."),
79
+ ...wrap(bold(theme, "Describe a small change below and one agent makes it now, beside any running task."), width),
79
80
  "",
80
- "Type what you want below and press enter. One coding agent makes the change right away — no scouting, proposal, plan or review — while any bot-lobby task keeps running. Quick fixes run one at a time in the order you send them.",
81
- "",
82
- "Keep them small: a rename, a typo, a one-function fix. If it turns out bigger, the agent stops and says so, and you can plan it as a task.",
83
- "",
84
- paint(theme, "dim", `quick fix: ${input.profile} — esc, then m changes the model, thinking, time limit and instructions (alt+s: all settings)`),
85
- ].flatMap((line) => wrap(line, width));
81
+ `${paint(theme, "mdCode", "QUICK FIX".padEnd(11))}${paint(theme, "muted", input.profile)}`,
82
+ ];
86
83
  }
87
84
 
88
85
  /** Jobs whose prompt, steps or report mention `query`. */
@@ -104,7 +101,7 @@ export function renderQuickFix(input: QuickFixTabInput, width: number, height: n
104
101
  const jobs = newestFirst(filterJobs(input.jobs, input.query));
105
102
  if (jobs.length === 0) {
106
103
  const content = input.query && input.jobs.length > 0 ? [paint(theme, "dim", `No quick fix mentions "${input.query}".`)] : intro(input, width - 4, theme);
107
- return box(width, height, content, { title: "Quick fix", right: input.profile, theme });
104
+ return box(width, height, content, { title: "Quick fix", ...(input.query ? { right: input.profile } : {}), theme });
108
105
  }
109
106
  const selected = Math.min(Math.max(0, input.selected), jobs.length - 1);
110
107
  const { list: listWidth, detail: detailWidth, wide } = quickFixWidths(width);