@a-t-h-i/bot-lobby 0.6.5 → 0.6.7

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.
Files changed (44) hide show
  1. package/README.md +127 -6
  2. package/package.json +1 -1
  3. package/prompts/global.md +18 -0
  4. package/prompts/master.md +77 -2
  5. package/prompts/worker.md +7 -2
  6. package/src/ask/dialog.ts +15 -3
  7. package/src/ask/state.ts +10 -1
  8. package/src/ask/tool.ts +6 -1
  9. package/src/ask/view.ts +2 -1
  10. package/src/execution/agent-runner.ts +23 -1
  11. package/src/execution/fallback.ts +75 -0
  12. package/src/index.ts +1 -1
  13. package/src/lobby/feed.ts +8 -1
  14. package/src/lobby/keys.ts +0 -1
  15. package/src/lobby/layout.ts +22 -1
  16. package/src/lobby/mini.ts +159 -0
  17. package/src/lobby/planner.ts +10 -3
  18. package/src/lobby/quickfix.ts +17 -3
  19. package/src/lobby/runtime.ts +29 -26
  20. package/src/lobby/tabs/home.ts +6 -42
  21. package/src/lobby/tabs/tasks.ts +1 -1
  22. package/src/lobby/view.ts +31 -30
  23. package/src/master/master.ts +1 -1
  24. package/src/master/research.ts +1 -0
  25. package/src/pi/activity.ts +1 -33
  26. package/src/pi/events.ts +3 -16
  27. package/src/pi/master-fallback.ts +61 -0
  28. package/src/pi/model-support.ts +18 -3
  29. package/src/pi/plan-checklist.ts +305 -0
  30. package/src/pi/run-summary.ts +12 -3
  31. package/src/pi/settings-ui.ts +65 -12
  32. package/src/pi/tools.ts +5 -4
  33. package/src/pi/ui.ts +18 -284
  34. package/src/roles/worker.ts +1 -1
  35. package/src/schemas/configuration.ts +51 -15
  36. package/src/schemas/findings.ts +2 -0
  37. package/src/workflow/brief.ts +59 -0
  38. package/src/workflow/workflow.ts +15 -2
  39. package/src/pi/expressions.ts +0 -169
  40. package/src/pi/kaomoji.ts +0 -227
  41. package/src/pi/mascot-art.ts +0 -359
  42. package/src/pi/zen-large.ts +0 -699
  43. package/src/pi/zen-metrics.ts +0 -130
  44. package/src/pi/zen.ts +0 -659
package/src/lobby/feed.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * What the lobby narrates: an activity log of plain-words tool steps from the
3
3
  * Master and every subagent, a single place for thoughts, and the conversation
4
4
  * with the Master stripped of tool rows and thinking. Bounded ring buffers in
5
- * one module-level store (like the zen widget state), so every event source can
5
+ * one module-level store (like the task state in ui.ts), so every event source can
6
6
  * write without a reference to the lobby, and the lobby repaints on `version`.
7
7
  */
8
8
  import type { AgentRun } from "../schemas/findings.ts";
@@ -70,6 +70,8 @@ export class LobbyFeed {
70
70
  private readonly runSteps = new Map<string, string>();
71
71
  private readonly runThoughts = new Map<string, string>();
72
72
  private readonly runStatus = new Map<string, AgentRun["status"]>();
73
+ /** Runs already reported as switched to their fallback model. */
74
+ private readonly fellBack = new Set<string>();
73
75
 
74
76
  onChange(listener: () => void): () => void {
75
77
  this.listeners.add(listener);
@@ -200,6 +202,10 @@ export class LobbyFeed {
200
202
  const before = this.runStatus.get(key);
201
203
  this.runStatus.set(key, run.status);
202
204
  if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
205
+ if (run.fellBackFrom && !this.fellBack.has(key)) {
206
+ this.fellBack.add(key);
207
+ this.log(source, `${run.fellBackFrom} is out of usage or unavailable: running on the fallback model`, "warning", at);
208
+ }
203
209
  if (run.step && this.runSteps.get(key) !== run.step) {
204
210
  this.runSteps.set(key, run.step);
205
211
  this.step(source, run.step, key, at);
@@ -225,6 +231,7 @@ export class LobbyFeed {
225
231
  this.runSteps.clear();
226
232
  this.runThoughts.clear();
227
233
  this.runStatus.clear();
234
+ this.fellBack.clear();
228
235
  this.touch();
229
236
  }
230
237
  }
package/src/lobby/keys.ts CHANGED
@@ -17,7 +17,6 @@ export const LOBBY_ACTIONS = {
17
17
  toggleAuto: { key: "alt+g", help: "auto mode: the oracle drives the task to completion without asking" },
18
18
  nextTab: { key: "tab", help: "next tab" },
19
19
  prevTab: { key: "shift+tab", help: "previous tab" },
20
- toggleScene: { key: "alt+z", help: "show or hide the oracle and agent animations" },
21
20
  toggleConversation: { key: "alt+c", help: "show or hide the conversation" },
22
21
  toggleActivity: { key: "alt+a", help: "show or hide the activity log" },
23
22
  toggleThinking: { key: "alt+k", help: "show or hide thinking" },
@@ -268,6 +268,24 @@ export function scrollThumb(total: number, rows: number, start: number): { from:
268
268
  return { from, to: from + size };
269
269
  }
270
270
 
271
+ /** Page buttons in the bottom border of a pane that scrolls: two pages up, one up, one down, two down. */
272
+ export const PAGER = "▲▲ ▲ ▼ ▼▼";
273
+ /** Columns the pager takes with a space on each side. */
274
+ export const PAGER_WIDTH = PAGER.length + 2;
275
+ /** Narrowest box that has room for the pager beside its corners. */
276
+ const PAGER_MIN_WIDTH = PAGER_WIDTH + 6;
277
+
278
+ /** Which page button a column of a scrollable pane's bottom border is: -2, -1, 1 or 2 pages (negative up), or undefined. */
279
+ export function pagerButton(width: number, column: number): -2 | -1 | 1 | 2 | undefined {
280
+ if (width < PAGER_MIN_WIDTH) return undefined;
281
+ const from = column - width;
282
+ if (from >= -12 && from <= -10) return -2;
283
+ if (from >= -9 && from <= -7) return -1;
284
+ if (from >= -6 && from <= -5) return 1;
285
+ if (from >= -4 && from <= -2) return 2;
286
+ return undefined;
287
+ }
288
+
271
289
  /**
272
290
  * A rounded panel exactly `width` × `height`: the title set into the top
273
291
  * border, `content` inside with one column of padding, extra lines cut and
@@ -291,7 +309,10 @@ export function box(width: number, height: number, content: readonly string[], o
291
309
  const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
292
310
  const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
293
311
  const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
294
- return [top, ...rows, `${edge("╰")}${edge("─".repeat(width - 2))}${edge("╯")}`];
312
+ // A pane with more lines than rows carries page buttons in its bottom border; `pagerButton` finds them again for clicks.
313
+ const pager = thumb && width >= PAGER_MIN_WIDTH ? ` ${PAGER} ` : "";
314
+ const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - textWidth(pager)))}${pager ? paint(theme, focused ? "accent" : "muted", pager) : ""}${edge("╯")}`;
315
+ return [top, ...rows, bottom];
295
316
  }
296
317
 
297
318
  /** Lay boxes out side by side, each already exactly its width and the same height. */
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The one-line status shown under pi's editor while the lobby is hidden: a
3
+ * small graphic of where the work stands — the task's plan steps (or its stage
4
+ * before there is a plan), the planning rounds, the quick fix in hand — or a
5
+ * quiet "idle". Pure: everything it shows arrives as input.
6
+ */
7
+ import { truncateToWidth } from "@earendil-works/pi-tui";
8
+ import type { AgentRun } from "../schemas/findings.ts";
9
+ import type { Task, TaskState } from "../schemas/task.ts";
10
+ import { planChecklist } from "../pi/plan-checklist.ts";
11
+ import { agentName } from "../pi/run-summary.ts";
12
+ import { paint, type LobbyTheme } from "./layout.ts";
13
+
14
+ export interface MiniPlanning {
15
+ /** A round is running. */
16
+ busy: boolean;
17
+ /** The round in hand, and the limit (0 = unlimited). */
18
+ round: number;
19
+ limit: number;
20
+ /** Questions waiting for the user. */
21
+ questions: number;
22
+ /** The oracle's draft is ready to save. */
23
+ ready: boolean;
24
+ /** The plan was saved as a pending task. */
25
+ saved: boolean;
26
+ }
27
+
28
+ export interface MiniQuickFix {
29
+ title: string;
30
+ running: boolean;
31
+ queued: number;
32
+ }
33
+
34
+ export interface MiniInput {
35
+ task?: Task;
36
+ runs: readonly AgentRun[];
37
+ planning?: MiniPlanning;
38
+ quickfix?: MiniQuickFix;
39
+ /** The key that opens the lobby, as shown. */
40
+ key: string;
41
+ }
42
+
43
+ /** A full-workflow task's stages, in order, for the strip shown before its plan has steps. */
44
+ const STAGES: readonly TaskState[] = ["clarifying", "scouting", "synthesizing", "awaiting_approval", "planning", "implementing", "reviewing"];
45
+
46
+ /** Widest step bar; longer plans share its cells. */
47
+ const MAX_CELLS = 12;
48
+
49
+ /** `■■◐□□`: the plan's steps done, current and to do, squeezed into at most `MAX_CELLS` cells. */
50
+ export function stepBar(statuses: readonly ("done" | "current" | "pending")[]): string {
51
+ if (statuses.length === 0) return "";
52
+ const cells = Math.min(statuses.length, MAX_CELLS);
53
+ const out: string[] = [];
54
+ for (let cell = 0; cell < cells; cell += 1) {
55
+ const from = Math.floor((cell * statuses.length) / cells);
56
+ const to = Math.max(from + 1, Math.floor(((cell + 1) * statuses.length) / cells));
57
+ const slice = statuses.slice(from, to);
58
+ out.push(slice.every((status) => status === "done") ? "■" : slice.some((status) => status === "current") ? "◐" : slice.some((status) => status === "done") ? "◐" : "□");
59
+ }
60
+ return out.join("");
61
+ }
62
+
63
+ /** `●●◐○○○○`: how far along the workflow a task without a plan yet is. */
64
+ export function stageBar(state: TaskState): string {
65
+ const at = STAGES.indexOf(state);
66
+ if (at < 0) return "";
67
+ return STAGES.map((_stage, index) => (index < at ? "●" : index === at ? "◐" : "○")).join("");
68
+ }
69
+
70
+ /** `■■□□□` for `round` of `limit`, or empty without a limit. */
71
+ function roundBar(round: number, limit: number): string {
72
+ if (limit <= 0) return "";
73
+ const done = Math.min(limit, Math.max(0, round));
74
+ return "■".repeat(done) + "□".repeat(limit - done);
75
+ }
76
+
77
+ const STATE_WORDS: Partial<Record<TaskState, string>> = {
78
+ awaiting_approval: "awaiting your approval",
79
+ };
80
+
81
+ function taskSegment(input: MiniInput, theme?: LobbyTheme): string | undefined {
82
+ const task = input.task;
83
+ if (!task || task.state === "completed" || task.state === "abandoned") return undefined;
84
+ const steps = task.plan ? planChecklist(task.plan, input.runs) : [];
85
+ const done = steps.filter((step) => step.status === "done").length;
86
+ const bar = steps.length > 0 ? `${stepBar(steps.map((step) => step.status))} ${done}/${steps.length}` : stageBar(task.state);
87
+ const state = task.paused ? `${task.state} (paused)` : STATE_WORDS[task.state] ?? task.state;
88
+ const running = [...input.runs].reverse().find((run) => run.status === "running");
89
+ const doing = running ? ` · ${agentName(running)}${running.activity ? ` ${running.activity}` : ""}` : "";
90
+ const alert = task.state === "awaiting_approval" || task.paused;
91
+ return `${paint(theme, "accent", task.id)} ${paint(theme, "success", bar)} ${paint(theme, alert ? "warning" : "muted", state)}${paint(theme, "dim", doing)}`;
92
+ }
93
+
94
+ function planningSegment(planning: MiniPlanning | undefined, theme?: LobbyTheme): string | undefined {
95
+ if (!planning || planning.saved) return undefined;
96
+ const rounds = roundBar(planning.round, planning.limit);
97
+ const head = `${paint(theme, "accent", "planning")} ${paint(theme, "success", rounds || `round ${planning.round}`)}${rounds ? paint(theme, "muted", ` ${planning.round}/${planning.limit}`) : ""}`;
98
+ if (planning.questions > 0) return `${head} ${paint(theme, "warning", `● ${planning.questions} question${planning.questions === 1 ? "" : "s"} for you`)}`;
99
+ if (planning.busy) return `${head} ${paint(theme, "muted", "the panel is thinking")}`;
100
+ if (planning.ready) return `${head} ${paint(theme, "success", "plan ready to save")}`;
101
+ return head;
102
+ }
103
+
104
+ function quickFixSegment(quickfix: MiniQuickFix | undefined, theme?: LobbyTheme): string | undefined {
105
+ if (!quickfix) return undefined;
106
+ const queued = quickfix.queued > 0 ? ` +${quickfix.queued} queued` : "";
107
+ return `${paint(theme, "accent", "quick fix")} ${paint(theme, "success", quickfix.running ? "◐" : "○")} ${paint(theme, "muted", quickfix.title)}${paint(theme, "dim", queued)}`;
108
+ }
109
+
110
+ /** The status line, at most `width` columns. */
111
+ export function miniLine(input: MiniInput, width: number, theme?: LobbyTheme): string {
112
+ if (width < 12) return "";
113
+ const segments = [taskSegment(input, theme), planningSegment(input.planning, theme), quickFixSegment(input.quickfix, theme)].filter((segment): segment is string => Boolean(segment));
114
+ const body = segments.length > 0 ? segments.join(paint(theme, "dim", " │ ")) : paint(theme, "dim", "idle");
115
+ const hint = paint(theme, "dim", ` ${input.key} opens`);
116
+ const line = ` ${paint(theme, "accent", "◆ bot-lobby")} ${body}`;
117
+ return truncateToWidth(`${line}${hint}`, width, "…");
118
+ }
119
+
120
+ /** An agent at work right now, for the lobby's bottom-line indicator. */
121
+ export interface WorkingAgent {
122
+ name: string;
123
+ activity?: string;
124
+ /** Epoch ms it started, for its elapsed time. */
125
+ since?: number;
126
+ }
127
+
128
+ /** The subagents running now: task runs still going, and the quick fix in hand. */
129
+ export function workingAgents(runs: readonly AgentRun[], quickFix?: { startedAt?: number }): WorkingAgent[] {
130
+ const agents: WorkingAgent[] = [];
131
+ for (const run of runs) {
132
+ if (run.status !== "running") continue;
133
+ const since = Date.parse(run.startedAt);
134
+ agents.push({ name: agentName(run), ...(run.activity ? { activity: run.activity } : {}), ...(Number.isFinite(since) ? { since } : {}) });
135
+ }
136
+ if (quickFix) agents.push({ name: "QUICK FIX", ...(quickFix.startedAt ? { since: quickFix.startedAt } : {}) });
137
+ return agents;
138
+ }
139
+
140
+ function elapsed(ms: number): string {
141
+ const seconds = Math.max(0, Math.floor(ms / 1000));
142
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
143
+ }
144
+
145
+ /**
146
+ * `◐ DESIGN editing 2m · DEV running 40s`, at most `width` columns: it gives up
147
+ * the activity and time, then the names, then shows only a count, and nothing
148
+ * when even that does not fit or nothing is working. `spin` is the spinner glyph.
149
+ */
150
+ export function agentsIndicator(agents: readonly WorkingAgent[], now: number, spin: string, width: number, theme?: LobbyTheme): string {
151
+ if (agents.length === 0) return "";
152
+ const full = agents.map((agent) => `${agent.name}${agent.activity ? ` ${agent.activity}` : ""}${agent.since ? ` ${elapsed(now - agent.since)}` : ""}`).join(" · ");
153
+ const names = [...new Set(agents.map((agent) => agent.name))].join(" · ");
154
+ const count = `${agents.length} agent${agents.length === 1 ? "" : "s"} working`;
155
+ for (const text of [full, names, count]) {
156
+ if (text.length + 2 <= width) return `${paint(theme, "accent", spin)} ${paint(theme, "muted", text)}`;
157
+ }
158
+ return "";
159
+ }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { loadPrompt } from "../prompts/loader.ts";
22
22
  import { compilePrompt } from "../prompts/compiler.ts";
23
+ import { withFallback } from "../execution/fallback.ts";
23
24
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
24
25
  import { describeToolCall } from "../pi/activity.ts";
25
26
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
@@ -593,12 +594,12 @@ export class PlanningSession {
593
594
  const startedAt = Date.now();
594
595
  let outcome: RunOutcome;
595
596
  try {
596
- const result = await runPiAgent(
597
+ const attempt = (model: string | undefined, thinking: string) => runPiAgent(
597
598
  {
598
599
  cwd: this.deps.cwd,
599
600
  ...request,
600
- model: profile.model,
601
- thinking: profile.thinking,
601
+ model,
602
+ thinking,
602
603
  timeoutMs: profile.timeoutMs,
603
604
  signal,
604
605
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -607,6 +608,12 @@ export class PlanningSession {
607
608
  },
608
609
  this.deps.runProcess ?? spawnPiProcess,
609
610
  );
611
+ // A model that is out of usage hands the turn to the fallback the settings name.
612
+ const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
613
+ if (switchedFrom && profile.fallback) {
614
+ this.deps.feed?.log(label, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`, "warning");
615
+ profile = { ...profile, model: profile.fallback.model, thinking: profile.fallback.thinking };
616
+ }
610
617
  outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
611
618
  } catch (error) {
612
619
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
@@ -6,6 +6,7 @@
6
6
  * and the lobby feed, and every finished job lands in the metrics log.
7
7
  */
8
8
  import { loadPrompt } from "../prompts/loader.ts";
9
+ import { withFallback } from "../execution/fallback.ts";
9
10
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
10
11
  import { describeToolCall } from "../pi/activity.ts";
11
12
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
@@ -74,6 +75,8 @@ export interface QuickFixProfile {
74
75
  thinking: string;
75
76
  timeoutMs: number;
76
77
  instructions?: string;
78
+ /** Where the job goes when its model runs out of usage or is unavailable. */
79
+ fallback?: { model: string; thinking: string };
77
80
  }
78
81
 
79
82
  export interface QuickFixDeps {
@@ -260,6 +263,17 @@ export class QuickFixQueue {
260
263
  },
261
264
  this.deps.runProcess ?? spawnPiProcess,
262
265
  );
266
+ // A model that is out of usage hands the job to the fallback the settings name.
267
+ const attemptOrFallback = async (model: string | undefined, thinking: string) => {
268
+ const { result: outcome, switchedFrom } = await withFallback(model, thinking, profile.fallback, attempt);
269
+ if (switchedFrom && profile.fallback) {
270
+ job.model = profile.fallback.model;
271
+ job.thinking = profile.fallback.thinking;
272
+ this.addStep(job, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model} (${profile.fallback.thinking})`, Date.now());
273
+ this.changed();
274
+ }
275
+ return outcome;
276
+ };
263
277
  let result;
264
278
  if (route) {
265
279
  job.route = routeLabel(route);
@@ -267,7 +281,7 @@ export class QuickFixQueue {
267
281
  job.thinking = route.thinking;
268
282
  if (route.model) job.model = route.model;
269
283
  this.changed();
270
- result = await attempt(route.model, route.thinking);
284
+ result = await attemptOrFallback(route.model, route.thinking);
271
285
  if (fellShort(result) && !controller.signal.aborted) {
272
286
  // The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
273
287
  appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
@@ -276,10 +290,10 @@ export class QuickFixQueue {
276
290
  delete job.routedFrom;
277
291
  job.thinking = profile.thinking;
278
292
  if (profile.model) job.model = profile.model;
279
- result = await attempt(profile.model, profile.thinking);
293
+ result = await attemptOrFallback(profile.model, profile.thinking);
280
294
  }
281
295
  } else {
282
- result = await attempt(profile.model, profile.thinking);
296
+ result = await attemptOrFallback(profile.model, profile.thinking);
283
297
  }
284
298
  job.status = result.status;
285
299
  job.report = result.output.trim() || undefined;
@@ -20,8 +20,7 @@ import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listA
20
20
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
21
21
  import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
22
22
  import { describeToolCall } from "../pi/activity.ts";
23
- import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
24
- import { panelLines } from "../pi/zen.ts";
23
+ import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
25
24
  import { shortTitle } from "../text.ts";
26
25
  import { isSubagentProcess } from "../pi/quiet.ts";
27
26
  import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
@@ -36,14 +35,14 @@ import { checkThinking } from "../pi/model-support.ts";
36
35
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
37
36
  import { SessionChats } from "./session-files.ts";
38
37
  import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
39
- import { QuickFixQueue } from "./quickfix.ts";
38
+ import { jobTitle, QuickFixQueue } from "./quickfix.ts";
39
+ import { miniLine, type MiniInput } from "./mini.ts";
40
40
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
41
41
  import { execCommand, IssuesState } from "./issues.ts";
42
42
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
43
43
  import { lobbyTheme } from "./theme.ts";
44
44
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
45
45
  import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
46
- import { budgetClock } from "../state/budget.ts";
47
46
 
48
47
  export const ANCHOR_KEY = "bot-lobby-anchor";
49
48
  export { deliverComments };
@@ -59,7 +58,6 @@ interface Runtime {
59
58
  visible: boolean;
60
59
  /** Stepped aside while pi shows a dialog. */
61
60
  asideForPrompt: boolean;
62
- scene: ZenScene;
63
61
  quickfix: QuickFixQueue;
64
62
  planner?: PlanningSession;
65
63
  issues: IssuesState;
@@ -97,18 +95,39 @@ let frameTimer: ReturnType<typeof setTimeout> | undefined;
97
95
  * through pi.
98
96
  */
99
97
  function rerender(): void {
100
- if (!runtime?.visible || frameTimer) return;
98
+ // Hidden, only the one-line status under the editor changes.
99
+ if (!runtime || (!runtime.visible && !miniShown(runtime)) || frameTimer) return;
101
100
  const wait = lastFrame + FRAME_MS - Date.now();
102
101
  const paint = () => {
103
102
  frameTimer = undefined;
104
103
  lastFrame = Date.now();
105
- if (runtime?.visible) runtime.tui?.requestRender();
104
+ if (runtime?.visible || (runtime && miniShown(runtime))) runtime.tui?.requestRender();
106
105
  };
107
106
  if (wait <= 0) return paint();
108
107
  frameTimer = setTimeout(paint, wait);
109
108
  frameTimer.unref?.();
110
109
  }
111
110
 
111
+ /** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
112
+ function miniShown(state: Runtime): boolean {
113
+ return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
114
+ }
115
+
116
+ /** What the status line shows: the task, the planning round, the quick fix in hand. */
117
+ function miniInput(state: Runtime): MiniInput {
118
+ const snapshot = taskSnapshot();
119
+ const planner = state.planner;
120
+ const job = state.quickfix.running ?? state.quickfix.jobs.find((entry) => entry.status === "queued");
121
+ const queued = state.quickfix.jobs.filter((entry) => entry.status === "queued" && entry !== job).length;
122
+ return {
123
+ ...(snapshot.task ? { task: snapshot.task } : {}),
124
+ runs: snapshot.runs,
125
+ ...(planner ? { planning: { busy: planner.busy, round: planner.turns, limit: loadConfig().lobby.maxPlanningRounds, questions: planner.awaitingAnswers ? planner.questions.length : 0, ready: planner.reply?.status === "ready", saved: Boolean(planner.saved) } } : {}),
126
+ ...(job ? { quickfix: { title: jobTitle(job), running: job.status === "running", queued } } : {}),
127
+ key: "Alt+L",
128
+ };
129
+ }
130
+
112
131
  function sessionModel(ctx: ExtensionContext): string | undefined {
113
132
  return ctx.model ? modelRef(ctx.model) : undefined;
114
133
  }
@@ -426,13 +445,6 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
426
445
  return `deleted ${taskId} for good`;
427
446
  }
428
447
 
429
- /** A task's status box without animations, for a session other than this window's. */
430
- function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
431
- const now = Date.now();
432
- const time = budgetClock(state.root, state.configDir, task.id, now);
433
- return panelLines(task, persistedRuns(task), now, false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme, ...(time ? { time } : {}) });
434
- }
435
-
436
448
  function seatProfile(state: Runtime, member: PanelMember) {
437
449
  return resolvePanelProfile(loadConfig(), member, {
438
450
  lookup: modelLookup(state.ctx),
@@ -518,15 +530,9 @@ function host(state: Runtime, tui: TUI): LobbyHost {
518
530
  theme: () => lobbyTheme(state.ctx.ui.theme),
519
531
  sessionId: () => state.ctx.sessionManager.getSessionId(),
520
532
  zen: () => {
521
- const snapshot = zenSnapshot();
533
+ const snapshot = taskSnapshot();
522
534
  return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
523
535
  },
524
- // The scene's height budget is 3/4 of the rows it is given.
525
- scene: (width, height, animated) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme, Date.now(), !animated),
526
- advanceScene: (now) => {
527
- state.scene.advance(now);
528
- return state.scene.delay(now);
529
- },
530
536
  feed: lobbyFeed,
531
537
  masterBusy: () => !state.ctx.isIdle(),
532
538
  tasks: () => peekTasks(state.root, state.configDir),
@@ -577,7 +583,6 @@ function host(state: Runtime, tui: TUI): LobbyHost {
577
583
  sessionChat: (sessionId) => chats.chat(sessionId),
578
584
  hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
579
585
  chatHistory: (sessionId) => chatHistory(state, sessionId),
580
- taskScene: (task, width, height) => taskScene(state, task, width, height),
581
586
  requestRender: () => tui.requestRender(),
582
587
  };
583
588
  }
@@ -716,7 +721,6 @@ function shutdown(): void {
716
721
  state.planner?.cancel();
717
722
  state.view?.dispose();
718
723
  state.handle?.hide();
719
- setWidgetSuppressor(() => false);
720
724
  onRunUpdates(undefined);
721
725
  onMinimizeChange(undefined);
722
726
  }
@@ -755,7 +759,6 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
755
759
  asking: false,
756
760
  mouse: false,
757
761
  inSettings: false,
758
- scene: new ZenScene(),
759
762
  quickfix: undefined as unknown as QuickFixQueue,
760
763
  issues: new IssuesState(execCommand, ctx.cwd, rerender),
761
764
  };
@@ -781,7 +784,6 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
781
784
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
782
785
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
783
786
  state.unsubscribeFeed = lobbyFeed.onChange(rerender);
784
- setWidgetSuppressor(() => runtime?.visible === true);
785
787
  onRunUpdates((runs) => lobbyFeed.runs(runs));
786
788
  onMinimizeChange((value) => {
787
789
  if (value) hideLobby();
@@ -789,7 +791,8 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
789
791
  // Capture pi's TUI through a zero-line widget below the editor.
790
792
  ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
791
793
  state.tui = tui;
792
- return { render: () => [], invalidate: () => {} };
794
+ // Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
795
+ return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
793
796
  }, { placement: "belowEditor" });
794
797
  // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
795
798
  onOwnerEvent((event) => {
@@ -1,9 +1,8 @@
1
1
  /**
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
2
+ * The Lobby tab: the status of this session's task on top, then the
4
3
  * conversation with the oracle (text only, no tool rows, no thinking), the
5
4
  * activity log of plain-words steps from every agent, and the one place where
6
- * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
5
+ * thoughts show up. Pure: the clock arrives as `now`.
7
6
  */
8
7
  import { textWidth } from "../../width.ts";
9
8
  import type { Task } from "../../schemas/task.ts";
@@ -18,8 +17,6 @@ export type HomePane = (typeof HOME_PANES)[number];
18
17
 
19
18
  export interface HomeInput {
20
19
  task?: Task;
21
- /** Draws the zen scene into at most `height` lines, animated or still; absent when no task is active. */
22
- scene?: (width: number, height: number, animated: boolean) => string[];
23
20
  chat: readonly ChatEntry[];
24
21
  /** The oracle's reply while it streams. */
25
22
  liveReply?: string;
@@ -48,8 +45,6 @@ export interface HomeInput {
48
45
  keys?: Record<LobbyPanel, string>;
49
46
  /** Another session is shown: its name titles the conversation. */
50
47
  title?: string;
51
- /** The scene is another session's still status (no animations to toggle). */
52
- stillScene?: boolean;
53
48
  /** What the empty conversation says instead of the default (a session starting, say). */
54
49
  emptyNote?: string;
55
50
  /** What the empty activity log says instead of "No activity yet.". */
@@ -60,11 +55,6 @@ export interface HomeInput {
60
55
 
61
56
  /** Wide terminals put the conversation and the activity log side by side. */
62
57
  export const HOME_COLUMNS_MIN = 100;
63
- /** Most lines the scene may take, and its share of the body; the still status needs far fewer. */
64
- const SCENE_SHARE = 0.45;
65
- const MAX_SCENE = 36;
66
- const MAX_STILL = 10;
67
-
68
58
  const SOURCE_COLORS: Record<string, LobbyColor> = {
69
59
  MASTER: "accent",
70
60
  DEV: "success",
@@ -490,31 +480,6 @@ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], wi
490
480
  return whole([paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")]);
491
481
  }
492
482
 
493
- /**
494
- * The scene: with animations off (the `animations` panel, off by default)
495
- * only the task's status box, what the agents are doing and the checklist;
496
- * with them on, the animated oracle and agents too. Its first line names the
497
- * key that toggles the animations.
498
- */
499
- function sceneLines(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
500
- if (!input.task || !input.scene) return [];
501
- const animated = input.panels.animations && !input.stillScene;
502
- const budget = Math.min(animated ? MAX_SCENE : MAX_STILL, Math.floor(height * SCENE_SHARE));
503
- if (budget < (animated ? 6 : 4)) return [];
504
- const lines = input.scene(width, budget, animated).slice(0, budget);
505
- const note = input.keys && !input.stillScene ? `${input.keys.animations} ${animated ? "hides" : "shows"} animations` : undefined;
506
- return keyNote(lines, width, note, theme);
507
- }
508
-
509
- /** `note` at the right end of the first line, dimmed, when it fits beside what is there. */
510
- function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
511
- const first = lines[0];
512
- if (!note || first === undefined) return [...lines];
513
- const gap = width - textWidth(first) - textWidth(note) - 1;
514
- if (gap < 2) return [...lines];
515
- return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
516
- }
517
-
518
483
  function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
519
484
  const keys = input.keys;
520
485
  const text = keys
@@ -536,12 +501,11 @@ export function renderHome(input: HomeInput, width: number, height: number, them
536
501
  if (height <= 0) return [];
537
502
  const { panels } = input;
538
503
  const feed = filterFeed(input);
539
- const scene = sceneLines(input, width, height, theme);
540
- const rest = height - scene.length;
504
+ const rest = height;
541
505
  const showMain = panels.conversation || panels.activity;
542
506
  const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
543
507
  const main = rest - thinkHeight;
544
- if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
508
+ if (!showMain && !panels.thinking) return fill(hiddenHint(input, width, rest, theme), height, width);
545
509
  const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
546
510
  /**
547
511
  * One scrollable pane: only the lines it shows are drawn — its newest, or
@@ -563,7 +527,7 @@ export function renderHome(input: HomeInput, width: number, height: number, them
563
527
  const thought = currentThought(feed.thoughts);
564
528
  const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
565
529
  const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need, anchor) => thinkingContent(input, feed.thoughts, inner, need, anchor, theme));
566
- const top = scene.length;
530
+ const top = 0;
567
531
  let body: string[] = [];
568
532
  if (main > 0 && panels.conversation && panels.activity) {
569
533
  if (width >= HOME_COLUMNS_MIN) {
@@ -576,5 +540,5 @@ export function renderHome(input: HomeInput, width: number, height: number, them
576
540
  } else if (main > 0 && panels.conversation) body = chatBox(top, 0, width, main);
577
541
  else if (main > 0 && panels.activity) body = activityBox(top, 0, width, main);
578
542
  const think = thinkHeight >= 3 ? thinkingBox(top + main, 0, width, thinkHeight) : [];
579
- return fill([...scene, ...fill(body, main), ...think], height, width);
543
+ return fill([...fill(body, main), ...think], height, width);
580
544
  }
@@ -7,7 +7,7 @@
7
7
  import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
8
8
  import type { PlannedTask } from "../../state/backlog.ts";
9
9
  import type { PlanComment } from "../../state/comments.ts";
10
- import { planChecklist } from "../../pi/zen.ts";
10
+ import { planChecklist } from "../../pi/plan-checklist.ts";
11
11
  import { persistedRuns } from "../../pi/ui.ts";
12
12
  import { describeRun, runFromLog } from "../../pi/run-summary.ts";
13
13
  import { pendingApprovals } from "../../workflow/approvals.ts";