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

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 (49) hide show
  1. package/README.md +132 -15
  2. package/package.json +1 -1
  3. package/prompts/master.md +15 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +19 -5
  9. package/src/ask/state.ts +8 -2
  10. package/src/ask/tool.ts +3 -1
  11. package/src/ask/view.ts +6 -3
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/execution/workspace.ts +185 -0
  16. package/src/knowledge/edit.ts +109 -0
  17. package/src/knowledge/notes.ts +143 -0
  18. package/src/knowledge/selector.ts +1 -1
  19. package/src/knowledge/store.ts +16 -2
  20. package/src/lobby/ask.ts +42 -21
  21. package/src/lobby/issues.ts +1 -1
  22. package/src/lobby/knowledge.ts +179 -0
  23. package/src/lobby/layout.ts +95 -18
  24. package/src/lobby/planner.ts +199 -7
  25. package/src/lobby/pr-review.ts +360 -0
  26. package/src/lobby/pulls.ts +250 -0
  27. package/src/lobby/runtime.ts +115 -13
  28. package/src/lobby/split.ts +230 -0
  29. package/src/lobby/tabs/git.ts +162 -0
  30. package/src/lobby/tabs/knowledge.ts +135 -0
  31. package/src/lobby/tabs/plan.ts +3 -1
  32. package/src/lobby/tabs/tasks.ts +11 -2
  33. package/src/lobby/view.ts +432 -32
  34. package/src/master/master.ts +39 -13
  35. package/src/pi/commands.ts +12 -7
  36. package/src/pi/model-support.ts +9 -0
  37. package/src/pi/plan-checklist.ts +42 -0
  38. package/src/pi/route.ts +2 -1
  39. package/src/pi/settings-ui.ts +41 -1
  40. package/src/pi/start-flags.ts +14 -0
  41. package/src/pi/start-task.ts +56 -5
  42. package/src/pi/tools.ts +4 -2
  43. package/src/schemas/configuration.ts +32 -3
  44. package/src/schemas/task.ts +15 -0
  45. package/src/state/backlog.ts +27 -4
  46. package/src/state/metrics.ts +2 -2
  47. package/src/state/persistence.ts +5 -5
  48. package/src/text.ts +38 -0
  49. package/src/workflow/workflow.ts +11 -0
package/src/lobby/view.ts CHANGED
@@ -24,15 +24,24 @@ import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } f
24
24
  import { issueText, type IssuesState } from "./issues.ts";
25
25
  import { agentsIndicator, workingAgents } from "./mini.ts";
26
26
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
27
- import { beside, bold, box, fit, highlight, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
+ import { beside, BRANCH_GLYPH, bold, box, fit, highlight, pageStep, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
28
28
  import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
29
29
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
30
30
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
31
31
  import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
32
32
  import { renderIssues } from "./tabs/issues.ts";
33
+ import { renderGit } from "./tabs/git.ts";
34
+ import { renderKnowledge, type KnowledgeLayout } from "./tabs/knowledge.ts";
35
+ import type { EntryRef, KnowledgeBook, KnowledgeFileInfo, KnowledgeView } from "./knowledge.ts";
36
+ import type { PullsState } from "./pulls.ts";
37
+ import type { PullReviews } from "./pr-review.ts";
38
+ import type { WorkspaceInfo } from "../execution/workspace.ts";
33
39
  import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
34
40
 
35
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics"] as const;
41
+ /** The mark before a branch name in the title (shared with the task details). */
42
+ export { BRANCH_GLYPH };
43
+
44
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge"] as const;
36
45
  export type TabId = (typeof TAB_IDS)[number];
37
46
 
38
47
  export const TAB_LABELS: Record<TabId, string> = {
@@ -42,6 +51,8 @@ export const TAB_LABELS: Record<TabId, string> = {
42
51
  quickfix: "Quick fix",
43
52
  issues: "Issues",
44
53
  metrics: "Metrics",
54
+ git: "Git",
55
+ knowledge: "Knowledge",
45
56
  };
46
57
 
47
58
  /** The tabs on show: Issues only while `lobby.issues` switches it on. */
@@ -149,6 +160,8 @@ export interface LobbyHost {
149
160
  newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
150
161
  /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
151
162
  answerPanel(): Promise<string>;
163
+ /** Save the plan as a pending task, offering to split a long one into several first; returns a notice. */
164
+ savePlan(): Promise<string>;
152
165
  /** The seats a new session starts with, from settings. */
153
166
  defaultPanel(): readonly PanelMember[];
154
167
  /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
@@ -156,6 +169,14 @@ export interface LobbyHost {
156
169
  /** `model · thinking` a panel seat runs on. */
157
170
  seatLabel(member: PanelMember): string;
158
171
  issues: IssuesState;
172
+ /** The Git tab's pull requests, read through `gh`. */
173
+ pulls: PullsState;
174
+ /** Reviews of pull requests by an agent, and Jev's reads of them. */
175
+ reviews: PullReviews;
176
+ /** Every agent's knowledge files: read, edited, commented on. */
177
+ knowledge: KnowledgeBook;
178
+ /** Edit a text in pi's multi-line editor (the lobby steps aside meanwhile); the new text, or undefined when cancelled. */
179
+ editText(title: string, text: string): Promise<string | undefined>;
159
180
  /** The Issues tab is switched on (`lobby.issues`). */
160
181
  issuesEnabled(): boolean;
161
182
  /** Which panes show (`lobby.panels`), and remembering a change. */
@@ -197,6 +218,10 @@ export interface LobbyHost {
197
218
  /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
198
219
  chatHistory(sessionId?: string): readonly ChatEntry[];
199
220
  profileLabel(kind: LobbyAgentKind): string;
221
+ /** The repository (or folder) and branch the title shows; the folder's name alone until git has answered. */
222
+ workspace?(): WorkspaceInfo;
223
+ /** Ask for the workspace to be read again (the branch may have changed); it arrives through `workspace()`. */
224
+ refreshWorkspace?(): void;
200
225
  requestRender(): void;
201
226
  now?(): number;
202
227
  }
@@ -209,6 +234,9 @@ export const DATA_REFRESH_MS = 2000;
209
234
  const NOTICE_MS = 6000;
210
235
  /** Lines one wheel notch scrolls. */
211
236
  const WHEEL_LINES = 3;
237
+ /** The longest repository and branch names the title shows whole. */
238
+ const TITLE_NAME_MAX = 28;
239
+ const TITLE_BRANCH_MAX = 36;
212
240
 
213
241
  /** The prompt editor, with its target written into the top border. */
214
242
  class LobbyEditor extends Editor {
@@ -339,19 +367,33 @@ export class LobbyView implements Component, Focusable {
339
367
  private issueFocus: "list" | "detail" = "list";
340
368
  private issueDetailOffset = 0;
341
369
  private issueDraft = false;
370
+ private gitSelected = 0;
371
+ private gitFocus: "list" | "detail" = "list";
372
+ private gitDetailOffset = 0;
373
+ /** The prompt is taking what the review of the selected pull request should look at first. */
374
+ private gitFocusDraft = false;
375
+ private kSelected = 0;
376
+ private kFocus: "list" | "detail" = "list";
377
+ /** The picked entry of the open knowledge file (an index into its entries), and how far its pane is scrolled. */
378
+ private kCursor = 0;
379
+ private kOffset = 0;
380
+ private readonly kLayout: KnowledgeLayout = { offset: 0, total: 0, rows: 0 };
381
+ /** The prompt is writing an edit of the picked entry, a note on it, or a new entry after it. */
382
+ private kDraft: { kind: "edit" | "comment" | "add"; entry?: EntryRef } | undefined;
342
383
  private metricsSelected = 0;
343
384
  private metricsBy: GroupBy = "model";
344
385
  private metricsSort: SortKey = "runs";
345
386
 
346
387
  /** The Tasks tab lists archived tasks too (v). */
347
388
  showArchived = false;
348
- private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; at: number } = {
389
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; knowledge: { files: KnowledgeFileInfo[]; view?: KnowledgeView }; at: number } = {
349
390
  tasks: [],
350
391
  plans: [],
351
392
  archived: [],
352
393
  comments: new Map(),
353
394
  metrics: [],
354
395
  classifierMetrics: [],
396
+ knowledge: { files: [] },
355
397
  at: 0,
356
398
  };
357
399
 
@@ -415,6 +457,8 @@ export class LobbyView implements Component, Focusable {
415
457
  || Boolean(this.host.planner()?.busy)
416
458
  || this.host.sessions().some((session) => session.busy)
417
459
  || (this.issuesOn && this.host.issues.loading)
460
+ || (this.tab === "git" && this.host.pulls.loading)
461
+ || this.host.reviews.busy
418
462
  || zen.runs.some((run) => run.status === "running")
419
463
  || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
420
464
  }
@@ -433,6 +477,7 @@ export class LobbyView implements Component, Focusable {
433
477
  const now = this.now();
434
478
  if (!force && now - this.data.at < DATA_REFRESH_MS) return;
435
479
  this.dataVersion += 1;
480
+ this.host.refreshWorkspace?.();
436
481
  this.data = {
437
482
  tasks: this.host.tasks(),
438
483
  plans: this.host.plans(),
@@ -440,6 +485,7 @@ export class LobbyView implements Component, Focusable {
440
485
  comments: new Map(),
441
486
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
442
487
  classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
488
+ knowledge: this.tab === "knowledge" ? this.readKnowledge() : this.data.knowledge,
443
489
  at: now,
444
490
  };
445
491
  if (this.tab === "tasks") {
@@ -448,6 +494,19 @@ export class LobbyView implements Component, Focusable {
448
494
  }
449
495
  }
450
496
 
497
+ /** Every knowledge file, and the selected one read as entries. */
498
+ private readKnowledge(): { files: KnowledgeFileInfo[]; view?: KnowledgeView } {
499
+ const files = this.host.knowledge.files();
500
+ this.kSelected = Math.max(0, Math.min(this.kSelected, files.length - 1));
501
+ const info = files[this.kSelected];
502
+ return { files, ...(info ? { view: this.host.knowledge.open(info.agent, info.file) } : {}) };
503
+ }
504
+
505
+ /** Read the open knowledge file again (after an edit, or another file was picked). */
506
+ private reloadKnowledge(): void {
507
+ this.data.knowledge = this.readKnowledge();
508
+ }
509
+
451
510
  private say(text: string, kind: Notice["kind"] = "info"): void {
452
511
  this.notice = { text, at: this.now(), kind };
453
512
  }
@@ -475,12 +534,18 @@ export class LobbyView implements Component, Focusable {
475
534
  setTab(tab: TabId): void {
476
535
  if (tab === this.tab) return;
477
536
  if (!this.tabs().includes(tab)) return this.say(`the ${TAB_LABELS[tab]} tab is off — set lobby.${tab} to true in the config to bring it back`, "warning");
537
+ // An entry being edited is not a draft to come back to: its text was only lent to the prompt.
538
+ if (this.kDraft) {
539
+ this.kDraft = undefined;
540
+ this.editor.setText("");
541
+ }
478
542
  this.drafts[this.tab] = this.editor.getText();
479
543
  this.tab = tab;
480
544
  this.editor.setText(this.drafts[tab] ?? "");
481
545
  this.commentTarget = undefined;
482
546
  this.lineTarget = undefined;
483
547
  this.issueDraft = false;
548
+ this.gitFocusDraft = false;
484
549
  this.armed = undefined;
485
550
  this.help = false;
486
551
  this.picking = false;
@@ -489,6 +554,8 @@ export class LobbyView implements Component, Focusable {
489
554
  this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
490
555
  this.refreshData(true);
491
556
  if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
557
+ if (tab === "git" && !this.host.pulls.loaded && !this.host.pulls.loading) void this.host.pulls.refresh().then(() => this.loadPullDetail());
558
+ else if (tab === "git") void this.loadPullDetail();
492
559
  this.host.requestRender();
493
560
  }
494
561
 
@@ -688,6 +755,11 @@ export class LobbyView implements Component, Focusable {
688
755
  }
689
756
  this.commentTarget = undefined;
690
757
  this.issueDraft = false;
758
+ this.gitFocusDraft = false;
759
+ if (this.kDraft) {
760
+ this.kDraft = undefined;
761
+ this.editor.setText("");
762
+ }
691
763
  this.newSession = false;
692
764
  if (this.lineTarget) {
693
765
  this.lineTarget = undefined;
@@ -714,7 +786,7 @@ export class LobbyView implements Component, Focusable {
714
786
  if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
715
787
  const tabs = this.tabs();
716
788
  if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
717
- if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues") return this.setMode("type");
789
+ if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues" && this.tab !== "git" && this.tab !== "knowledge") return this.setMode("type");
718
790
  if (this.tabCommand(data)) return;
719
791
  const printable = isPrintable(data);
720
792
  if (printable && PROMPT_FIRST.has(this.tab)) {
@@ -736,6 +808,10 @@ export class LobbyView implements Component, Focusable {
736
808
  return this.fixFocus;
737
809
  case "issues":
738
810
  return this.issueFocus;
811
+ case "git":
812
+ return this.gitFocus;
813
+ case "knowledge":
814
+ return this.kFocus;
739
815
  case "metrics":
740
816
  return "table";
741
817
  }
@@ -782,6 +858,12 @@ export class LobbyView implements Component, Focusable {
782
858
  case "issues":
783
859
  this.issueFocus = direction < 0 ? "list" : "detail";
784
860
  return;
861
+ case "git":
862
+ this.gitFocus = direction < 0 ? "list" : "detail";
863
+ return;
864
+ case "knowledge":
865
+ this.kFocus = direction < 0 ? "list" : "detail";
866
+ return;
785
867
  case "metrics":
786
868
  return;
787
869
  }
@@ -807,11 +889,38 @@ export class LobbyView implements Component, Focusable {
807
889
  void this.loadIssueDetail();
808
890
  }
809
891
  return;
892
+ case "git":
893
+ if (this.gitFocus === "detail" || Math.abs(delta) > 1) this.gitDetailOffset = Math.max(0, this.gitDetailOffset + delta);
894
+ else {
895
+ this.gitSelected = Math.max(0, Math.min(this.host.pulls.pulls.length - 1, this.gitSelected + delta));
896
+ this.gitDetailOffset = 0;
897
+ void this.loadPullDetail();
898
+ }
899
+ return;
900
+ case "knowledge":
901
+ return this.moveKnowledge(delta);
810
902
  case "metrics":
811
903
  return this.scrollPane("table", delta);
812
904
  }
813
905
  }
814
906
 
907
+ /** Arrows and pages on the Knowledge tab: pick a file, or pick an entry of the open one (a page moves several). */
908
+ private moveKnowledge(delta: number): void {
909
+ const knowledge = this.data.knowledge;
910
+ if (this.kFocus === "list") {
911
+ const next = Math.max(0, Math.min(knowledge.files.length - 1, this.kSelected + delta));
912
+ if (next === this.kSelected) return;
913
+ this.kSelected = next;
914
+ this.kCursor = 0;
915
+ this.kOffset = 0;
916
+ this.reloadKnowledge();
917
+ return;
918
+ }
919
+ const count = knowledge.view?.entries.length ?? 0;
920
+ const step = Math.abs(delta) > 1 ? Math.sign(delta) * Math.max(2, Math.round(Math.abs(delta) / 3)) : delta;
921
+ this.kCursor = Math.max(0, Math.min(count - 1, this.kCursor + step));
922
+ }
923
+
815
924
  /**
816
925
  * Scroll one pane of the current tab by `delta` lines, positive toward its
817
926
  * end. Lists move their selection instead; the draft moves its cursor (the
@@ -990,6 +1099,11 @@ export class LobbyView implements Component, Focusable {
990
1099
  if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
991
1100
  }
992
1101
 
1102
+ private async loadPullDetail(): Promise<void> {
1103
+ const pull = this.host.pulls.pulls[this.gitSelected];
1104
+ if (pull) await this.host.pulls.detail(pull.number);
1105
+ }
1106
+
993
1107
  private async loadIssueDetail(): Promise<void> {
994
1108
  const issue = this.host.issues.issues[this.issueSelected];
995
1109
  if (issue) await this.host.issues.detail(issue.number);
@@ -1051,6 +1165,10 @@ export class LobbyView implements Component, Focusable {
1051
1165
  return false;
1052
1166
  case "issues":
1053
1167
  return this.issuesCommand(data, enter, escape);
1168
+ case "git":
1169
+ return this.gitCommand(data, enter, escape);
1170
+ case "knowledge":
1171
+ return this.knowledgeCommand(data, enter, escape);
1054
1172
  case "metrics":
1055
1173
  if (data === "g") return (this.metricsBy = this.metricsBy === "model" ? "model-kind" : "model", this.metricsSelected = 0), true;
1056
1174
  if (data === "s") return (this.metricsSort = SORT_KEYS[(SORT_KEYS.indexOf(this.metricsSort) + 1) % SORT_KEYS.length]!), true;
@@ -1462,17 +1580,25 @@ export class LobbyView implements Component, Focusable {
1462
1580
  this.say(on ? `auto mode on — the oracle drives ${task.id} to completion without asking` : `auto mode off — the oracle asks you again on ${task.id}`);
1463
1581
  }
1464
1582
 
1465
- /** Save the planning session's draft to the pending tasks, from any tab and in either mode. */
1583
+ /**
1584
+ * Save the planning session's draft to the pending tasks, from any tab and
1585
+ * in either mode. A plan with many steps is first offered a split into
1586
+ * several tasks, which takes the oracle a moment and asks the user.
1587
+ */
1466
1588
  savePlan(): void {
1467
1589
  const session = this.host.planner();
1468
1590
  if (!session?.reply?.plan) return this.say(session?.busy ? "the first draft is still being written" : "no plan to save yet — describe a task in the Plan tab", "warning");
1469
- try {
1470
- const saved = session.save();
1471
- this.say(`saved ${saved.id} to the pending tasks — start it from the Tasks tab${session.reply.status === "ready" ? "" : " (the panel had not agreed yet)"}`);
1472
- } catch (error) {
1473
- this.say((error as Error).message, "warning");
1474
- }
1475
- this.refreshData(true);
1591
+ void this.host.savePlan().then(
1592
+ (notice) => {
1593
+ this.say(notice);
1594
+ this.refreshData(true);
1595
+ this.host.requestRender();
1596
+ },
1597
+ (error: Error) => {
1598
+ this.say(error.message, "warning");
1599
+ this.host.requestRender();
1600
+ },
1601
+ );
1476
1602
  }
1477
1603
 
1478
1604
  /** The oracle puts the round's questions to the user through the questionnaire. */
@@ -1589,6 +1715,162 @@ export class LobbyView implements Component, Focusable {
1589
1715
  return false;
1590
1716
  }
1591
1717
 
1718
+ /** The selected pull request on the Git tab. */
1719
+ private selectedPull() {
1720
+ return this.host.pulls.pulls[this.gitSelected];
1721
+ }
1722
+
1723
+ /** Review the selected pull request with a read-only agent, optionally looking at `focus` first. */
1724
+ private startReview(focus?: string): void {
1725
+ const pull = this.selectedPull();
1726
+ if (!pull) return this.say("no pull request selected — r loads the open ones", "warning");
1727
+ if (this.host.reviews.running(pull.number)) return this.say(`already reviewing #${pull.number} — x stops it`, "warning");
1728
+ this.gitFocus = "detail";
1729
+ this.gitDetailOffset = 0;
1730
+ this.say(`reviewing #${pull.number}${focus ? ` — looking at: ${clip(focus, 40)}` : ""} — it streams into the activity log; x stops it`);
1731
+ void this.host.reviews.start(pull.number, { ...(focus ? { focus } : {}), ...(this.host.pulls.details.has(pull.number) ? { detail: this.host.pulls.details.get(pull.number)! } : {}) }).then(() => this.host.requestRender());
1732
+ }
1733
+
1734
+ private gitCommand(data: string, enter: boolean, escape: boolean): boolean {
1735
+ const pull = this.selectedPull();
1736
+ if (enter) {
1737
+ this.gitFocus = this.gitFocus === "list" ? "detail" : "list";
1738
+ void this.loadPullDetail();
1739
+ return true;
1740
+ }
1741
+ if (escape) return (this.gitFocus = "list"), true;
1742
+ if (data === "r") {
1743
+ this.host.pulls.forget();
1744
+ void this.host.pulls.refresh().then(() => this.loadPullDetail());
1745
+ return true;
1746
+ }
1747
+ if (!pull) {
1748
+ // Nothing listed: the review keys say so instead of doing nothing.
1749
+ if (!"vftx".includes(data) || data.length !== 1) return false;
1750
+ this.say("no pull request selected — r loads the open ones", "warning");
1751
+ return true;
1752
+ }
1753
+ if (data === "v") return this.startReview(), true;
1754
+ if (data === "f") {
1755
+ this.gitFocusDraft = true;
1756
+ this.setMode("type");
1757
+ return true;
1758
+ }
1759
+ if (data === "t") {
1760
+ this.gitFocus = "detail";
1761
+ this.gitDetailOffset = 0;
1762
+ this.say(`asking Jev to read #${pull.number}…`);
1763
+ void this.host.reviews.readWithJev(pull.number, this.host.pulls.details.get(pull.number)).then(() => this.host.requestRender());
1764
+ return true;
1765
+ }
1766
+ if (data === "x") {
1767
+ this.say(this.host.reviews.cancel(pull.number) ? `stopping the review of #${pull.number}` : `no review of #${pull.number} is running`, "warning");
1768
+ return true;
1769
+ }
1770
+ return false;
1771
+ }
1772
+
1773
+ /** The picked entry of the open knowledge file, as the edit functions find it again. */
1774
+ private pickedEntry(): { entry: EntryRef; text: string } | undefined {
1775
+ const entry = this.data.knowledge.view?.entries[this.kCursor];
1776
+ return entry ? { entry: { text: entry.text, occurrence: entry.occurrence }, text: entry.text } : undefined;
1777
+ }
1778
+
1779
+ private knowledgeCommand(data: string, enter: boolean, escape: boolean): boolean {
1780
+ const info = this.data.knowledge.files[this.kSelected];
1781
+ if (enter) return (this.kFocus = this.kFocus === "list" ? "detail" : "list"), true;
1782
+ if (escape) return (this.kFocus = "list"), true;
1783
+ if (data === "r") {
1784
+ this.reloadKnowledge();
1785
+ this.say("knowledge reread from disk");
1786
+ return true;
1787
+ }
1788
+ if (!info) return false;
1789
+ if (data === "E") {
1790
+ const view = this.data.knowledge.view;
1791
+ if (!view) return true;
1792
+ void this.host.editText(`${info.label} — ${info.agent}/${info.file}`, view.content).then((text) => {
1793
+ if (text !== undefined) this.say(this.host.knowledge.replaceFile(info.agent, info.file, text));
1794
+ this.reloadKnowledge();
1795
+ this.host.requestRender();
1796
+ });
1797
+ return true;
1798
+ }
1799
+ if (!"ecndx".includes(data) || data.length !== 1) return false;
1800
+ const picked = this.pickedEntry();
1801
+ // Adding needs no entry to start from: it goes at the end of an empty or entry-less file.
1802
+ if (!picked && data !== "n") {
1803
+ this.say("nothing is picked — n adds an entry", "warning");
1804
+ return true;
1805
+ }
1806
+ this.kFocus = "detail";
1807
+ if (data === "e" && picked) {
1808
+ this.kDraft = { kind: "edit", entry: picked.entry };
1809
+ this.editor.setText(picked.text);
1810
+ this.setMode("type");
1811
+ return true;
1812
+ }
1813
+ if (data === "c" && picked) {
1814
+ this.kDraft = { kind: "comment", entry: picked.entry };
1815
+ this.setMode("type");
1816
+ return true;
1817
+ }
1818
+ if (data === "n") {
1819
+ this.kDraft = { kind: "add", ...(picked ? { entry: picked.entry } : {}) };
1820
+ this.setMode("type");
1821
+ return true;
1822
+ }
1823
+ if (data === "d" && picked) {
1824
+ const key = `kdelete:${info.agent}/${info.file}:${picked.entry.occurrence}:${picked.text}`;
1825
+ if (this.armed !== key) {
1826
+ this.armed = key;
1827
+ this.say("press d again to delete this entry (the file is archived first, and its notes go with it)", "warning");
1828
+ return true;
1829
+ }
1830
+ this.armed = undefined;
1831
+ this.say(this.host.knowledge.remove(info.agent, info.file, picked.entry));
1832
+ this.reloadKnowledge();
1833
+ return true;
1834
+ }
1835
+ if (data === "x" && picked) {
1836
+ const notes = this.data.knowledge.view?.attached.filter((note) => note.entry === picked.text) ?? [];
1837
+ const newest = notes.at(-1);
1838
+ if (!newest) {
1839
+ this.say("this entry has no notes", "warning");
1840
+ return true;
1841
+ }
1842
+ const key = `kunnote:${newest.id}`;
1843
+ if (this.armed !== key) {
1844
+ this.armed = key;
1845
+ this.say(`press x again to remove the note “${clip(newest.text, 40)}”`, "warning");
1846
+ return true;
1847
+ }
1848
+ this.armed = undefined;
1849
+ this.say(this.host.knowledge.unnote(newest.id));
1850
+ this.reloadKnowledge();
1851
+ return true;
1852
+ }
1853
+ return true;
1854
+ }
1855
+
1856
+ /** The prompt's text on the Knowledge tab: an edit of the picked entry, a note on it, or a new entry after it. */
1857
+ private submitKnowledge(body: string): void {
1858
+ const draft = this.kDraft;
1859
+ const info = this.data.knowledge.files[this.kSelected];
1860
+ if (!draft || !info) {
1861
+ this.editor.setText(body);
1862
+ return this.say("press e to edit the picked entry, c to comment on it, n to add one", "warning");
1863
+ }
1864
+ this.kDraft = undefined;
1865
+ this.setMode("browse");
1866
+ const book = this.host.knowledge;
1867
+ const notice = draft.kind === "edit" ? book.edit(info.agent, info.file, draft.entry!, body) : draft.kind === "comment" ? book.comment(info.agent, info.file, draft.entry!, body) : book.add(info.agent, info.file, draft.entry, body);
1868
+ this.say(notice, /^(saved|note saved)/.test(notice) ? "info" : "warning");
1869
+ this.reloadKnowledge();
1870
+ // A new entry goes right after the picked one, which the cursor then moves onto.
1871
+ if (draft.kind === "add" && notice.startsWith("saved")) this.kCursor = Math.min(this.kCursor + (draft.entry ? 1 : 0), Math.max(0, (this.data.knowledge.view?.entries.length ?? 1) - 1));
1872
+ }
1873
+
1592
1874
  /* --------------------------------------------------------------- mouse */
1593
1875
 
1594
1876
  /** Mouse events when pi runs full screen (pi-tui dispatches them to the overlay). */
@@ -1618,7 +1900,7 @@ export class LobbyView implements Component, Focusable {
1618
1900
  /** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
1619
1901
  private wheel(direction: number, x = -1, y = -1): void {
1620
1902
  if (this.help) return;
1621
- if (this.tab === "issues") return this.scroll(direction * WHEEL_LINES);
1903
+ if (this.tab === "issues" || this.tab === "git" || this.tab === "knowledge") return this.scroll(direction * WHEEL_LINES);
1622
1904
  this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
1623
1905
  }
1624
1906
 
@@ -1644,9 +1926,9 @@ export class LobbyView implements Component, Focusable {
1644
1926
  // The page buttons in a scrolling pane's bottom border scroll it a page or two.
1645
1927
  const paneBox = pane ? this.panes.get(pane) : undefined;
1646
1928
  if (pane && paneBox && paneBox.total > paneBox.rows && row === paneBox.top + paneBox.height - 1) {
1647
- const button = pagerButton(paneBox.width, x - paneBox.left);
1929
+ const button = pagerButton(paneBox.width, x - paneBox.left, paneBox.total, paneBox.rows);
1648
1930
  if (button) {
1649
- this.scrollPane(pane, button * Math.max(1, paneBox.rows - 1), false, true);
1931
+ this.scrollPane(pane, button * pageStep(paneBox.rows), false, true);
1650
1932
  return;
1651
1933
  }
1652
1934
  }
@@ -1674,6 +1956,7 @@ export class LobbyView implements Component, Focusable {
1674
1956
  private submit(text: string): void {
1675
1957
  const body = text.trim();
1676
1958
  if (!body) {
1959
+ if (this.tab === "knowledge" && this.kDraft) return this.say(this.kDraft.kind === "edit" ? "nothing to save — d d deletes an entry, esc cancels" : "write something first, or esc to cancel", "warning");
1677
1960
  // An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
1678
1961
  if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
1679
1962
  if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
@@ -1713,6 +1996,16 @@ export class LobbyView implements Component, Focusable {
1713
1996
  this.say("filing the issue…");
1714
1997
  void this.host.issues.create(body);
1715
1998
  return;
1999
+ case "knowledge":
2000
+ return this.submitKnowledge(body);
2001
+ case "git":
2002
+ if (!this.gitFocusDraft) {
2003
+ this.editor.setText(body);
2004
+ return this.say("press f to review the selected pull request with a focus", "warning");
2005
+ }
2006
+ this.gitFocusDraft = false;
2007
+ this.setMode("browse");
2008
+ return this.startReview(body);
1716
2009
  case "metrics":
1717
2010
  return;
1718
2011
  }
@@ -1802,46 +2095,83 @@ export class LobbyView implements Component, Focusable {
1802
2095
  else if (session?.awaitingAnswers) badges.plan = `${session.questions.length}?`;
1803
2096
  if (this.host.quickfix.running) badges.quickfix = spinner(this.tick);
1804
2097
  if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
1805
- const brand = bold(theme, paint(theme, "accent", " ◆ bot-lobby "));
1806
- let left = `${brand}${paint(theme, "borderMuted", "│")}`;
1807
- const spans: Array<{ tab: TabId; from: number; to: number }> = [];
1808
- for (const [index, tab] of this.tabs().entries()) {
2098
+ if (this.host.reviews.busy) badges.git = spinner(this.tick);
2099
+ else if (this.host.pulls.pulls.length > 0) badges.git = String(this.host.pulls.pulls.length);
2100
+ const cells = this.tabs().map((tab, index) => {
1809
2101
  const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
1810
2102
  const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
1811
2103
  const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1812
- let cell = ` ${number} ${name}${badge} `;
1813
- if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1814
- const from = textWidth(left);
1815
- left += cell;
1816
- spans.push({ tab, from, to: textWidth(left) });
1817
- }
1818
- this.tabSpans = spans;
1819
- const room = width - textWidth(left) - 1;
2104
+ const cell = ` ${number} ${name}${badge} `;
2105
+ return { tab, text: tab === this.tab ? (theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`) : cell };
2106
+ });
1820
2107
  const entry = this.viewedEntry();
1821
2108
  const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1822
2109
  const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
1823
2110
  const waiting = this.host.sessions().reduce((count, session) => count + session.dialogs.length, 0);
1824
2111
  const asking = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting · ${keyLabel(this.keys.sessions)}`)}` : "";
2112
+ // Short of room, the key to open the sessions goes before the waiting count does.
2113
+ const asked = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting`)}` : "";
1825
2114
  let choices: string[];
1826
2115
  if (entry.view.kind !== "here") {
1827
2116
  // Another session in view: its name and what it is doing lead, so it is never mistaken for this window.
1828
2117
  const working = entry.status === "working";
1829
2118
  const dot = paint(theme, working ? "accent" : "dim", working ? spinner(this.tick) : "◆");
1830
2119
  const where = paint(theme, "muted", `${entry.name} · ${entry.status}`);
1831
- choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
2120
+ choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto}${asked} `, `${dot}${auto}${asked} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
1832
2121
  } else {
1833
2122
  const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
1834
2123
  const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
1835
2124
  // Long task ids give way to the state, then to the dot alone.
1836
2125
  choices = zen.task
1837
- ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
1838
- : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
2126
+ ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${zen.task.id} ${state}${auto}${asked} `, `${dot} ${state}${auto}${asked} `, `${dot}${auto}${asked} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
2127
+ : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, `${paint(theme, "dim", "no task in this session")}${asked} `, `${asked.trimStart()} `, help, ""];
2128
+ }
2129
+ // The longest title that leaves room for the tabs and, when a session waits on you, for saying so.
2130
+ const tabsWidth = cells.reduce((sum, cell) => sum + textWidth(cell.text), 0);
2131
+ const titles = this.titles(entry.task, theme);
2132
+ let brand = titles.at(-1)!;
2133
+ let status = "";
2134
+ for (const [index, title] of titles.entries()) {
2135
+ const room = width - textWidth(title) - 1 - tabsWidth - 1;
2136
+ const shown = choices.find((choice) => textWidth(choice) <= room) ?? "";
2137
+ if (index === titles.length - 1 || (room >= 0 && (waiting === 0 || shown.includes("waiting")))) {
2138
+ brand = title;
2139
+ status = shown;
2140
+ break;
2141
+ }
1839
2142
  }
1840
- const status = choices.find((choice) => textWidth(choice) <= room) ?? "";
2143
+ let left = `${brand}${paint(theme, "borderMuted", "│")}`;
2144
+ const spans: Array<{ tab: TabId; from: number; to: number }> = [];
2145
+ for (const cell of cells) {
2146
+ const from = textWidth(left);
2147
+ left += cell.text;
2148
+ spans.push({ tab: cell.tab, from, to: textWidth(left) });
2149
+ }
2150
+ this.tabSpans = spans;
1841
2151
  const gap = width - textWidth(left) - textWidth(status);
1842
2152
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
1843
2153
  }
1844
2154
 
2155
+ /**
2156
+ * The lobby's title, longest first: the repository (or folder) it works
2157
+ * from and the branch, `◆ my-repo (⎇ main)`, then the name alone, then just
2158
+ * the mark. A task with a worktree of its own shows that branch, since that
2159
+ * is where its work happens.
2160
+ */
2161
+ private titles(task: Task | undefined, theme: LobbyTheme): string[] {
2162
+ const workspace = this.host.workspace?.();
2163
+ const name = clip(workspace?.name || "bot-lobby", TITLE_NAME_MAX);
2164
+ const own = task?.git?.mode === "worktree" ? task.git.branch : undefined;
2165
+ const branch = own ?? workspace?.branch;
2166
+ const glyph = paint(theme, "accent", " ◆ ");
2167
+ const label = (text: string) => bold(theme, paint(theme, "accent", text));
2168
+ return [
2169
+ ...(branch ? [`${glyph}${label(name)} ${paint(theme, "muted", `(${BRANCH_GLYPH} ${clip(branch, TITLE_BRANCH_MAX)})`)} `] : []),
2170
+ `${glyph}${label(name)} `,
2171
+ glyph,
2172
+ ];
2173
+ }
2174
+
1845
2175
  private promptLabel(): string {
1846
2176
  const zen = this.host.zen();
1847
2177
  const browsing = this.mode === "browse";
@@ -1876,6 +2206,18 @@ export class LobbyView implements Component, Focusable {
1876
2206
  return this.host.quickfix.running ? "describe a quick change; it runs after the current one" : "describe a quick change";
1877
2207
  case "issues":
1878
2208
  return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
2209
+ case "knowledge": {
2210
+ const picked = this.pickedEntry();
2211
+ if (this.kDraft?.kind === "edit") return `edit “${clip(picked?.text ?? "", 40)}” · enter saves it · esc cancels`;
2212
+ if (this.kDraft?.kind === "comment") return `a note on “${clip(picked?.text ?? "", 40)}” — every agent reads it under the entry · enter saves`;
2213
+ if (this.kDraft?.kind === "add") return picked ? `a new entry after “${clip(picked.text, 40)}” · enter adds it` : "a new entry · enter adds it";
2214
+ return "e edits the picked entry, c comments on it, n adds one";
2215
+ }
2216
+ case "git": {
2217
+ const pull = this.selectedPull();
2218
+ if (this.gitFocusDraft) return `what should the review of #${pull?.number ?? "?"} look at first? · enter reviews`;
2219
+ return pull ? `f reviews #${pull.number} with a focus you type, v without one` : "r loads the open pull requests";
2220
+ }
1879
2221
  case "metrics":
1880
2222
  return "";
1881
2223
  }
@@ -1963,6 +2305,28 @@ export class LobbyView implements Component, Focusable {
1963
2305
  { key: "n", text: "file a new issue" },
1964
2306
  { key: "r", text: "reload issues" },
1965
2307
  ];
2308
+ case "knowledge":
2309
+ return [
2310
+ { key: "↑ ↓", text: "pick a file, or (in the entries) pick an entry; PageUp/PageDown several" },
2311
+ { key: "enter / ← →", text: "move between the files and their entries" },
2312
+ { key: "e", text: "edit the picked entry: it comes into the prompt (Shift+Enter for a new line, enter saves)" },
2313
+ { key: "c", text: "comment on it: a note every agent reads under that entry" },
2314
+ { key: "n", text: "add an entry after the picked one" },
2315
+ { key: "d d", text: "delete the picked entry, and its notes" },
2316
+ { key: "x x", text: "take back the newest note on the picked entry" },
2317
+ { key: "E", text: "edit the whole file in pi's editor" },
2318
+ { key: "r", text: "reread the files from disk" },
2319
+ ];
2320
+ case "git":
2321
+ return [
2322
+ { key: "↑ ↓", text: "select a pull request, or scroll its detail (PageUp/PageDown)" },
2323
+ { key: "enter / ← →", text: "move between the list and the detail" },
2324
+ { key: "v", text: "review it with a read-only agent on QA's model (nothing is posted to GitHub)" },
2325
+ { key: "f", text: "review it with a focus you type first (Shift+Enter for a new line, enter reviews)" },
2326
+ { key: "t", text: "Jev's quick read: size, risk, security, breaking changes, missing tests" },
2327
+ { key: "x", text: "stop the review that is running" },
2328
+ { key: "r", text: "reload the pull requests" },
2329
+ ];
1966
2330
  case "metrics":
1967
2331
  return [
1968
2332
  { key: "↑ ↓", text: "select a row of the table" },
@@ -2013,6 +2377,8 @@ export class LobbyView implements Component, Focusable {
2013
2377
  }
2014
2378
  if (this.tab === "lobby" && this.host.masterBusy()) return [["enter", "steer"], ["esc", "stop the oracle"], [k("hide"), "hide"]];
2015
2379
  if (this.tab === "plan" && this.lineTarget) return [["enter", "add the comment"], ["esc", "cancel"]];
2380
+ if (this.tab === "knowledge" && this.kDraft) return [["enter", this.kDraft.kind === "edit" ? "save" : this.kDraft.kind === "comment" ? "save the note" : "add it"], ["shift+enter", "new line"], ["esc", "cancel"]];
2381
+ if (this.tab === "git" && this.gitFocusDraft) return [["enter", "review with this focus"], ["shift+enter", "new line"], ["esc", "cancel"]];
2016
2382
  const enter = this.tab === "plan" && session?.awaitingAnswers && !this.editor.getText().trim() ? "answer questions" : this.tab === "quickfix" ? "run it" : "send";
2017
2383
  return [["enter", enter], ["esc", "browse"], [k("hide"), "hide"]];
2018
2384
  }
@@ -2048,6 +2414,19 @@ export class LobbyView implements Component, Focusable {
2048
2414
  case "issues":
2049
2415
  keys.push(["↑↓", "select"], ["p", "plan it"], ["n", "new"]);
2050
2416
  break;
2417
+ case "knowledge":
2418
+ keys.push(["↑↓", "pick"], ["enter", this.kFocus === "list" ? "entries" : "files"]);
2419
+ if (this.kFocus === "detail" || this.pickedEntry()) keys.push(["e", "edit"], ["c", "comment"], ["n", "new"], ["d d", "delete"]);
2420
+ keys.push(["E", "edit file"]);
2421
+ break;
2422
+ case "git": {
2423
+ const pull = this.selectedPull();
2424
+ keys.push(["↑↓", "select"]);
2425
+ if (pull) keys.push(["v", "review"], ["f", "review with a focus"], ["t", "Jev's read"]);
2426
+ if (pull && this.host.reviews.running(pull.number)) keys.push(["x", "stop"]);
2427
+ keys.push(["r", "reload"]);
2428
+ break;
2429
+ }
2051
2430
  case "metrics":
2052
2431
  keys.push(["↑↓", "select"], ["g", "group"], ["s", "sort"]);
2053
2432
  break;
@@ -2068,7 +2447,7 @@ export class LobbyView implements Component, Focusable {
2068
2447
  ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
2069
2448
  ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
2070
2449
  ...row("wheel", "scroll the pane under the pointer", inner),
2071
- ...row("▲ ▼", "click the buttons on a pane's bottom edge: a page up or down (▲▲ ▼▼ two pages)", inner),
2450
+ ...row("prev next", "click on a long pane's bottom edge (▲ prev · page 2/5 · next ▼) to move a page; dimmed at either end", inner),
2072
2451
  ];
2073
2452
  const modes = (inner: number) => [
2074
2453
  section("Typing"),
@@ -2142,6 +2521,26 @@ export class LobbyView implements Component, Focusable {
2142
2521
  loading: issues.loading, loaded: issues.loaded, ...(issues.error ? { error: issues.error } : {}), ...(issues.notice ? { notice: issues.notice } : {}), tick: this.tick, now,
2143
2522
  }, width, height, theme);
2144
2523
  }
2524
+ case "knowledge": {
2525
+ const knowledge = this.data.knowledge;
2526
+ this.kSelected = Math.min(this.kSelected, Math.max(0, knowledge.files.length - 1));
2527
+ this.kCursor = Math.min(this.kCursor, Math.max(0, (knowledge.view?.entries.length ?? 1) - 1));
2528
+ const lines = renderKnowledge({ files: knowledge.files, selected: this.kSelected, ...(knowledge.view ? { view: knowledge.view } : {}), cursor: this.kCursor, focus: this.kFocus, offset: this.kOffset, layout: this.kLayout }, width, height, theme);
2529
+ this.kOffset = this.kLayout.offset;
2530
+ return lines;
2531
+ }
2532
+ case "git": {
2533
+ const pulls = this.host.pulls;
2534
+ this.gitSelected = Math.min(this.gitSelected, Math.max(0, pulls.pulls.length - 1));
2535
+ const selected = pulls.pulls[this.gitSelected];
2536
+ const detail = selected ? pulls.details.get(selected.number) : undefined;
2537
+ const review = selected ? this.host.reviews.review(selected.number) : undefined;
2538
+ const read = selected ? this.host.reviews.reads.get(selected.number) : undefined;
2539
+ return renderGit({
2540
+ pulls: pulls.pulls, selected: this.gitSelected, ...(detail ? { detail } : {}), ...(review ? { review } : {}), ...(read ? { read } : {}),
2541
+ focus: this.gitFocus, detailOffset: this.gitDetailOffset, loading: pulls.loading, loaded: pulls.loaded, ...(pulls.error ? { error: pulls.error } : {}), tick: this.tick, now,
2542
+ }, width, height, theme);
2543
+ }
2145
2544
  case "metrics": {
2146
2545
  const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
2147
2546
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
@@ -2198,6 +2597,7 @@ export class LobbyView implements Component, Focusable {
2198
2597
  nextMode: session.nextMode,
2199
2598
  ...(session.seed ? { seed: session.seed } : {}),
2200
2599
  ...(session.saved ? { saved: session.saved } : {}),
2600
+ ...(session.savedParts.length > 1 ? { savedParts: session.savedParts.length } : {}),
2201
2601
  ...(session.title ? { title: session.title } : {}),
2202
2602
  awaitingAnswers: session.awaitingAnswers,
2203
2603
  answeredChunks: session.answered.length,