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

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.
@@ -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);
@@ -11,10 +11,13 @@ import { planChecklist } from "../../pi/zen.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";
14
- import { ago, beside, bold, box, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, since, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
14
+ import { ago, beside, bold, box, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
15
15
 
16
16
  export type TaskSection = "mine" | "others" | "pending" | "recent";
17
17
 
18
+ /** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
19
+ export type CheckState = "open" | "done" | "dropped";
20
+
18
21
  export interface TaskRow {
19
22
  kind: "task" | "plan";
20
23
  id: string;
@@ -22,17 +25,44 @@ export interface TaskRow {
22
25
  section: TaskSection;
23
26
  /** Task state, or `pending` for a saved plan. */
24
27
  status: string;
25
- /** Right-hand note: progress, owner or issue. */
26
- meta: string;
28
+ paused?: boolean;
29
+ check: CheckState;
30
+ /** Plan steps done, once the task has a plan. */
31
+ progress?: { done: number; total: number };
32
+ /** Who drives it, for tasks other sessions own. */
33
+ owner?: string;
34
+ /** How long ago it finished (finished tasks) or was saved (plans), as `3h`. */
35
+ age?: string;
36
+ /** The GitHub issue a plan came from. */
37
+ issue?: number;
38
+ /** Auto mode is on: the oracle drives it without asking. */
39
+ auto?: boolean;
40
+ }
41
+
42
+ /** What the lobby knows about sessions beyond this one: background sessions' names, tasks in auto mode. */
43
+ export interface RowContext {
44
+ /** Background sessions this window started, by pi session id. */
45
+ names?: ReadonlyMap<string, string>;
46
+ /** Tasks with auto mode on. */
47
+ auto?: ReadonlySet<string>;
27
48
  }
28
49
 
29
50
  export const SECTION_TITLES: Record<TaskSection, string> = {
30
51
  mine: "THIS SESSION",
31
52
  others: "OTHER SESSIONS",
32
- pending: "PENDING (planned)",
33
- recent: "RECENT",
53
+ pending: "PENDING",
54
+ recent: "FINISHED",
34
55
  };
35
56
 
57
+ /** The box each row wears: empty while there is work to do, ticked when completed, crossed when abandoned. */
58
+ export const CHECK_MARKS: Record<CheckState, string> = { open: "☐", done: "☑", dropped: "☒" };
59
+
60
+ export function checkState(task: Task): CheckState {
61
+ if (task.state === "completed") return "done";
62
+ if (task.state === "abandoned") return "dropped";
63
+ return "open";
64
+ }
65
+
36
66
  /** Finished tasks listed under RECENT. */
37
67
  export const RECENT_LIMIT = 8;
38
68
 
@@ -44,65 +74,153 @@ export function taskProgress(task: Task): { done: number; total: number } | unde
44
74
  return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
45
75
  }
46
76
 
47
- function ownerLabel(task: Task, sessionId: string | undefined): string {
77
+ function ownerLabel(task: Task, sessionId: string | undefined, names?: ReadonlyMap<string, string>): string {
48
78
  if (!task.ownerSessionId) return "no owner";
49
79
  if (task.ownerSessionId === sessionId) return "this session";
80
+ const name = names?.get(task.ownerSessionId);
81
+ // A background session is named after its task, so the row's title already says which one.
82
+ if (name) return name === task.title ? "background" : `background · ${name}`;
50
83
  return `session ${task.ownerSessionId.slice(0, 8)}`;
51
84
  }
52
85
 
53
86
  /** Rows in display order: this session's task, other sessions', pending plans, then recent finished tasks. */
54
- export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessionId: string | undefined, now: number): TaskRow[] {
87
+ export function taskRows(tasks: readonly Task[], plans: readonly PlannedTask[], sessionId: string | undefined, now: number, context: RowContext = {}): TaskRow[] {
55
88
  const active = tasks.filter((task) => !TERMINAL_STATES.includes(task.state));
56
89
  const row = (task: Task, section: TaskSection): TaskRow => {
57
- const progress = taskProgress(task);
58
- const meta = section === "recent" ? ago(now - Date.parse(task.updatedAt)) : progress ? `${progress.done}/${progress.total}` : section === "others" ? ownerLabel(task, sessionId) : "";
59
- return { kind: "task", id: task.id, title: task.title, section, status: task.paused ? `${task.state} (paused)` : task.state, meta };
90
+ const progress = section === "recent" ? undefined : taskProgress(task);
91
+ const auto = section !== "recent" && context.auto?.has(task.id);
92
+ return {
93
+ kind: "task",
94
+ id: task.id,
95
+ title: task.title,
96
+ section,
97
+ status: task.state,
98
+ ...(task.paused && section !== "recent" ? { paused: true } : {}),
99
+ check: checkState(task),
100
+ ...(progress ? { progress } : {}),
101
+ ...(section === "others" ? { owner: ownerLabel(task, sessionId, context.names) } : {}),
102
+ ...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
103
+ ...(auto ? { auto: true } : {}),
104
+ };
60
105
  };
61
106
  return [
62
107
  ...active.filter((task) => sessionId && task.ownerSessionId === sessionId).map((task) => row(task, "mine")),
63
108
  ...active.filter((task) => !sessionId || task.ownerSessionId !== sessionId).map((task) => row(task, "others")),
64
109
  ...plans.filter((plan) => plan.status === "pending").map((plan): TaskRow => ({
65
- kind: "plan", id: plan.id, title: plan.title, section: "pending", status: "pending", meta: plan.issue ? `#${plan.issue.number}` : "",
110
+ kind: "plan",
111
+ id: plan.id,
112
+ title: plan.title,
113
+ section: "pending",
114
+ status: "pending",
115
+ check: "open",
116
+ ...(plan.createdAt ? { age: ago(now - Date.parse(plan.createdAt)) } : {}),
117
+ ...(plan.issue ? { issue: plan.issue.number } : {}),
66
118
  })),
67
119
  ...tasks.filter((task) => TERMINAL_STATES.includes(task.state)).slice(0, RECENT_LIMIT).map((task) => row(task, "recent")),
68
120
  ];
69
121
  }
70
122
 
71
- const STATE_COLORS: Record<string, "success" | "warning" | "error" | "accent" | "muted" | "dim"> = {
123
+ type StateColor = Extract<LobbyColor, "success" | "warning" | "error" | "accent" | "muted" | "dim">;
124
+
125
+ const STATE_COLORS: Record<string, StateColor> = {
72
126
  completed: "success",
73
127
  abandoned: "dim",
74
128
  blocked: "error",
75
129
  awaiting_approval: "warning",
130
+ planning: "accent",
76
131
  implementing: "accent",
77
132
  reviewing: "accent",
78
133
  pending: "muted",
79
134
  };
80
135
 
81
- function statusColor(status: string): "success" | "warning" | "error" | "accent" | "muted" | "dim" {
82
- return STATE_COLORS[status.replace(/ \(paused\)$/, "")] ?? "muted";
136
+ function statusColor(status: string): StateColor {
137
+ return STATE_COLORS[status] ?? "muted";
138
+ }
139
+
140
+ /** A task's state in words: `awaiting approval`, `implementing · paused`. */
141
+ function stateWords(status: string, paused?: boolean): string {
142
+ return `${status.replace(/_/g, " ")}${paused ? " · paused" : ""}`;
143
+ }
144
+
145
+ /** The row's box, coloured by what it says: the state's colour while open, green when done, dim when dropped. */
146
+ export function checkMark(row: Pick<TaskRow, "check" | "status" | "paused">, theme?: LobbyTheme): string {
147
+ const color: StateColor = row.check === "done" ? "success" : row.check === "dropped" ? "dim" : row.paused ? "warning" : statusColor(row.status);
148
+ return paint(theme, color, CHECK_MARKS[row.check]);
149
+ }
150
+
151
+ /** A title as its box says: struck through and dim once abandoned, bold while selected. */
152
+ function rowTitle(row: TaskRow, selected: boolean, theme?: LobbyTheme): string {
153
+ if (row.check === "dropped") return paint(theme, "dim", strike(theme, row.title));
154
+ if (row.check === "done") return selected ? bold(theme, row.title) : paint(theme, "muted", row.title);
155
+ return selected ? bold(theme, row.title) : row.title;
156
+ }
157
+
158
+ /** Progress as pips, `cells` wide: filled for the share done, hollow for the rest. */
159
+ export function pips(done: number, total: number, cells: number, theme?: LobbyTheme): string {
160
+ if (cells <= 0 || total <= 0) return "";
161
+ const filled = Math.min(cells, Math.round((Math.max(0, done) / total) * cells));
162
+ return `${paint(theme, done >= total ? "success" : "accent", "▰".repeat(filled))}${paint(theme, "borderMuted", "▱".repeat(cells - filled))}`;
163
+ }
164
+
165
+ /** Plan progress on a row: a pip per step (up to eight) and `3/5`. */
166
+ function progressText(progress: TaskRow["progress"], theme?: LobbyTheme): string {
167
+ if (!progress) return "";
168
+ return `${pips(progress.done, progress.total, Math.min(progress.total, 8), theme)} ${paint(theme, "muted", `${progress.done}/${progress.total}`)}`;
169
+ }
170
+
171
+ /** The line under an open row's title: its state, who drives it and whether auto mode is on; a plan's age and issue. */
172
+ function detailsLine(row: TaskRow, theme?: LobbyTheme): string {
173
+ const dot = paint(theme, "dim", " · ");
174
+ if (row.kind === "plan") {
175
+ return [paint(theme, "muted", "planned"), row.age ? paint(theme, "dim", row.age === "now" ? "saved just now" : `saved ${row.age} ago`) : "", row.issue ? paint(theme, "dim", `#${row.issue}`) : ""].filter(Boolean).join(dot);
176
+ }
177
+ return [
178
+ paint(theme, row.paused ? "warning" : statusColor(row.status), stateWords(row.status, row.paused)),
179
+ row.auto ? paint(theme, "success", "⟳ auto") : "",
180
+ row.owner ? paint(theme, "dim", row.owner) : "",
181
+ ].filter(Boolean).join(dot);
83
182
  }
84
183
 
85
- /** List lines with section headers, plus which line holds the selected row. */
184
+ /**
185
+ * List lines with a rule over each section, plus which line holds the
186
+ * selected row. A task or plan still to do takes two lines — its box and
187
+ * title (with plan progress on the right), then its state, owner and auto
188
+ * mode; a finished one takes one, ticked or struck through, with its age.
189
+ */
86
190
  export function listLines(rows: readonly TaskRow[], selected: number, width: number, focused: boolean, theme?: LobbyTheme): { lines: string[]; selectedLine: number } {
87
191
  const lines: string[] = [];
88
192
  let selectedLine = 0;
89
193
  let section: TaskSection | undefined;
194
+ const counts = new Map<TaskSection, number>();
195
+ for (const row of rows) counts.set(row.section, (counts.get(row.section) ?? 0) + 1);
90
196
  rows.forEach((row, index) => {
91
197
  if (row.section !== section) {
92
198
  section = row.section;
93
199
  if (lines.length > 0) lines.push("");
94
- lines.push(paint(theme, "dim", SECTION_TITLES[section]));
200
+ lines.push(rule(width, SECTION_TITLES[section], theme, String(counts.get(section) ?? 0)));
201
+ }
202
+ const chosen = index === selected;
203
+ const marker = chosen ? paint(theme, "accent", "▸") : " ";
204
+ const head = `${marker} ${checkMark(row, theme)} ${rowTitle(row, chosen, theme)}`;
205
+ if (chosen) selectedLine = lines.length;
206
+ if (row.check !== "open") {
207
+ lines.push(selectRow(theme, spread(head, row.age ? paint(theme, "dim", row.age) : "", width), width, chosen, focused));
208
+ return;
95
209
  }
96
- const marker = index === selected ? paint(theme, "accent", "▸ ") : " ";
97
- const status = paint(theme, statusColor(row.status), row.status.replace(/_/g, " "));
98
- const meta = row.meta ? ` ${paint(theme, "dim", row.meta)}` : "";
99
- const title = index === selected ? bold(theme, row.title) : row.title;
100
- if (index === selected) selectedLine = lines.length;
101
- lines.push(selectRow(theme, `${marker}${title} ${paint(theme, "dim", "·")} ${status}${meta}`, width, index === selected, focused));
210
+ lines.push(selectRow(theme, spread(head, progressText(row.progress, theme), width), width, chosen, focused));
211
+ lines.push(selectRow(theme, ` ${detailsLine(row, theme)}`, width, chosen, focused));
102
212
  });
103
213
  return { lines, selectedLine };
104
214
  }
105
215
 
216
+ /** The list pane's right-hand count: what is open and what is finished, or how many rows match a search. */
217
+ export function listCount(rows: readonly TaskRow[], query?: string): string {
218
+ if (query) return `${rows.length} match${rows.length === 1 ? "" : "es"}`;
219
+ const open = rows.filter((row) => row.check === "open").length;
220
+ const finished = rows.length - open;
221
+ return [open ? `${open} open` : "", finished ? `${finished} finished` : ""].filter(Boolean).join(" · ") || "0";
222
+ }
223
+
106
224
  const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };
107
225
  const COMMENT_WORDS: Record<PlanComment["status"], string> = { open: "waiting for the owning session", delivered: "sent to the oracle", addressed: "plan amended" };
108
226
 
@@ -113,31 +231,44 @@ function section(title: string, width: number, theme?: LobbyTheme, right = ""):
113
231
  /** Detail lines for a task: header, plan with progress, comments, amendments, waits and runs. */
114
232
  export function taskDetailLines(task: Task, comments: readonly PlanComment[], sessionId: string | undefined, width: number, now: number, theme?: LobbyTheme): string[] {
115
233
  const lines: string[] = [];
116
- lines.push(...wrap(bold(theme, `${task.title}`), width));
234
+ const check = checkState(task);
235
+ const row = { check, status: task.state, ...(task.paused ? { paused: true } : {}) };
236
+ const title = check === "dropped" ? paint(theme, "dim", strike(theme, task.title)) : bold(theme, task.title);
237
+ lines.push(...wrapHanging(`${checkMark(row, theme)} `, title, width));
238
+ const dot = paint(theme, "dim", " · ");
239
+ const finished = TERMINAL_STATES.includes(task.state);
117
240
  const facts = [
118
- paint(theme, statusColor(task.state), `${task.state.replace(/_/g, " ")}${task.paused ? " (paused)" : ""}`),
119
- ownerLabel(task, sessionId),
120
- `started ${since(now - Date.parse(task.createdAt))}`,
121
- task.domains.length > 0 ? task.domains.join(", ") : "",
241
+ bold(theme, paint(theme, task.paused ? "warning" : statusColor(task.state), stateWords(task.state, task.paused))),
242
+ finished ? "" : paint(theme, "muted", ownerLabel(task, sessionId)),
243
+ paint(theme, "muted", `started ${since(now - Date.parse(task.createdAt))}`),
244
+ finished ? paint(theme, "muted", `${task.state === "abandoned" ? "dropped" : "done"} ${since(now - Date.parse(task.updatedAt))}`) : "",
122
245
  ].filter(Boolean);
123
- lines.push(paint(theme, "dim", task.id), facts.join(paint(theme, "dim", " · ")));
246
+ lines.push(...wrapHanging(" ", facts.join(dot), width));
247
+ lines.push(...wrapHanging(" ", paint(theme, "dim", [task.id, task.domains.length > 0 ? task.domains.join(", ") : ""].filter(Boolean).join(" · ")), width));
248
+ const steps = task.plan ? planChecklist(task.plan, persistedRuns(task)) : [];
249
+ const done = steps.filter((step) => step.status === "done").length;
250
+ if (steps.length > 0) {
251
+ const cells = Math.max(4, Math.min(24, width - 20));
252
+ lines.push("", ` ${pips(done, steps.length, cells, theme)} ${paint(theme, "muted", `${done} of ${steps.length} steps`)}`);
253
+ }
124
254
  const request = taskRequest(task);
125
255
  if (request && request !== task.title) lines.push(...section("Request", width, theme), ...wrap(paint(theme, "muted", request), width));
126
256
  if (task.plan) {
127
- const steps = planChecklist(task.plan, persistedRuns(task));
128
- const done = steps.filter((step) => step.status === "done").length;
129
- lines.push(...section("Approved plan", width, theme, steps.length > 0 ? `${done}/${steps.length} steps` : ""));
130
- for (const [index, step] of steps.entries()) {
131
- const mark = step.status === "done" ? paint(theme, "success", "✓") : step.status === "current" ? paint(theme, "accent", "▸") : paint(theme, "dim", "○");
132
- const text = step.status === "pending" ? paint(theme, "muted", step.text) : step.text;
133
- lines.push(...wrapHanging(`${mark} ${index + 1}. `, text, width));
257
+ if (steps.length > 0) {
258
+ lines.push(...section("Progress", width, theme, `${done}/${steps.length} steps`));
259
+ for (const [index, step] of steps.entries()) {
260
+ const mark = step.status === "done" ? paint(theme, "success", CHECK_MARKS.done) : paint(theme, step.status === "current" ? "accent" : "dim", CHECK_MARKS.open);
261
+ const text = step.status === "done" ? paint(theme, "muted", step.text) : step.status === "current" ? bold(theme, step.text) : paint(theme, "muted", step.text);
262
+ const current = step.status === "current" ? ` ${paint(theme, "accent", "◂ now")}` : "";
263
+ lines.push(...wrapHanging(`${mark} ${paint(theme, "dim", `${index + 1}.`)} `, `${text}${current}`, width));
264
+ }
134
265
  }
135
- if (steps.length > 0) lines.push("");
266
+ lines.push(...section("Approved plan", width, theme));
136
267
  lines.push(...markdownLines(task.plan, width, theme));
137
268
  } else if (task.proposal) {
138
- lines.push(...section("Proposal (no plan yet)", width, theme), ...markdownLines(task.proposal, width, theme));
269
+ lines.push(...section(TERMINAL_STATES.includes(task.state) ? "Proposal" : "Proposal (no plan yet)", width, theme), ...markdownLines(task.proposal, width, theme));
139
270
  } else {
140
- lines.push(...section("Plan", width, theme), paint(theme, "dim", "No proposal or plan yet — the oracle is still clarifying or scouting."));
271
+ lines.push(...section("Plan", width, theme), paint(theme, "dim", TERMINAL_STATES.includes(task.state) ? "It ended before a plan was made." : "No proposal or plan yet — the oracle is still clarifying or scouting."));
141
272
  }
142
273
  const open = comments.filter((comment) => comment.status !== "addressed").length;
143
274
  lines.push(...section("Comments", width, theme, open > 0 ? `${open} open` : ""));
@@ -166,13 +297,15 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
166
297
 
167
298
  /** Detail lines for a saved plan waiting to start. */
168
299
  export function planDetailLines(plan: PlannedTask, width: number, now: number, theme?: LobbyTheme): string[] {
300
+ const dot = paint(theme, "dim", " · ");
169
301
  const lines = [
170
- ...wrap(bold(theme, plan.title), width),
171
- paint(theme, "dim", plan.id),
172
- [paint(theme, "muted", "pending"), `saved ${since(now - Date.parse(plan.createdAt))}`].join(paint(theme, "dim", " · ")),
302
+ ...wrapHanging(`${checkMark({ check: "open", status: "pending" }, theme)} `, bold(theme, plan.title), width),
303
+ ...wrapHanging(" ", [bold(theme, paint(theme, "muted", "pending")), paint(theme, "muted", `saved ${since(now - Date.parse(plan.createdAt))}`)].join(dot), width),
304
+ ...wrapHanging(" ", paint(theme, "dim", plan.id), width),
173
305
  ];
174
- if (plan.issue) lines.push(...wrap(`from issue #${plan.issue.number} — ${plan.issue.title}${plan.issue.url ? ` ${paint(theme, "dim", plan.issue.url)}` : ""}`, width));
175
- lines.push("", paint(theme, "dim", "s starts it as a task in this session · d discards it"));
306
+ if (plan.issue) lines.push(...wrapHanging(" ", `from issue #${plan.issue.number} — ${plan.issue.title}${plan.issue.url ? ` ${paint(theme, "dim", plan.issue.url)}` : ""}`, width));
307
+ const key = (text: string) => paint(theme, "accent", text);
308
+ lines.push("", ...wrapHanging(" ", [`${key("s")} ${paint(theme, "muted", "start it in a new session")}`, `${key("h")} ${paint(theme, "muted", "start it here")}`, `${key("d d")} ${paint(theme, "muted", "discard it")}`].join(paint(theme, "dim", " ")), width));
176
309
  lines.push(...section("Agreed plan", width, theme), ...markdownLines(plan.brief, width, theme));
177
310
  return lines;
178
311
  }
@@ -230,7 +363,7 @@ export function renderTasks(input: TasksInput, width: number, height: number, th
230
363
  const list = listLines(input.rows, input.selected, listWidth - 4, input.focus === "list", theme);
231
364
  const rows = Math.max(0, bodyHeight - 2);
232
365
  const listStart = windowStart(list.selectedLine, list.lines.length, rows);
233
- const count = input.query ? `${input.rows.length} match${input.rows.length === 1 ? "" : "es"}` : `${input.rows.length}`;
366
+ const count = listCount(input.rows, input.query);
234
367
  const listPane = box(listWidth, bodyHeight, list.lines.slice(listStart), { title: "Tasks", right: count, focused: input.focus === "list", scroll: { total: list.lines.length, start: listStart }, theme });
235
368
  const detailStart = detailWindow(input.detail.length, rows, input.detailOffset);
236
369
  const detailPane = box(detailWidth, bodyHeight, input.detail.slice(detailStart), { title: "Detail", ...(input.detail.length > rows ? { right: position(detailStart, rows, input.detail.length) } : {}), focused: input.focus === "detail", scroll: { total: input.detail.length, start: detailStart }, theme });