@tt-a1i/openpi 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,12 @@ import { sanitizeTerminalText } from "../../../shared/terminal-text.ts";
17
17
  import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
18
18
  import { formatContextUtilization } from "../format.ts";
19
19
  import type { SubagentReadModel } from "../manager.ts";
20
- import { TranscriptRenderer, buildTranscriptLines } from "./transcript.ts";
20
+ import {
21
+ SPINNER_INTERVAL_MS,
22
+ TranscriptRenderer,
23
+ buildTranscriptLines,
24
+ spinnerFrame,
25
+ } from "./transcript.ts";
21
26
 
22
27
  export function sanitizeSubagentDisplayLine(value: string) {
23
28
  return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
@@ -30,26 +35,55 @@ function configuredKeys(
30
35
  return keybindings.getKeys(binding).join("/") || "unbound";
31
36
  }
32
37
 
33
- function statusGlyph(snap: SubagentSnapshot, theme: Theme): string {
38
+ /**
39
+ * One spinner definition for the whole subagent UI: the dashboard glyph, the
40
+ * takeover header, and the transcript's live tools must animate in step, so the
41
+ * frames and their cadence live in `transcript.ts` and are imported here.
42
+ */
43
+ function statusGlyph(
44
+ snap: SubagentSnapshot,
45
+ theme: Theme,
46
+ now = Date.now(),
47
+ ): string {
34
48
  switch (snap.status) {
35
49
  case "running":
36
- return theme.fg("warning", "■");
50
+ return theme.fg("warning", spinnerFrame(now));
37
51
  case "done":
38
- return theme.fg("success", "");
52
+ return theme.fg("success", "");
39
53
  case "error":
40
- return theme.fg("error", "");
54
+ return theme.fg("error", "");
41
55
  }
42
56
  }
43
57
 
44
- function statusWord(snap: SubagentSnapshot, theme: Theme): string {
45
- switch (snap.status) {
46
- case "running":
47
- return theme.fg("warning", "running");
48
- case "done":
49
- return theme.fg("success", "done");
50
- case "error":
51
- return theme.fg("error", "failed");
58
+ /**
59
+ * Tool names come from the child's own tool-call events, so they are as
60
+ * untrusted as their arguments and must be sanitized before reaching the
61
+ * terminal: `truncateToWidth` only ignores escape sequences while measuring.
62
+ */
63
+ function runningActivity(snap: SubagentSnapshot) {
64
+ if (snap.status !== "running") return "";
65
+ const liveTool = snap.liveTools.at(-1);
66
+ if (liveTool) {
67
+ const name = sanitizeSubagentDisplayLine(liveTool.name);
68
+ const args = truncateToWidth(
69
+ sanitizeSubagentDisplayLine(liveTool.argsPreview ?? ""),
70
+ 48,
71
+ );
72
+ return args ? `${name} · ${args}` : name;
52
73
  }
74
+ for (const item of [...snap.transcript].reverse()) {
75
+ if (item.kind === "toolResult") {
76
+ return sanitizeSubagentDisplayLine(item.name);
77
+ }
78
+ if (item.kind !== "assistant") continue;
79
+ const tool = [...item.parts]
80
+ .reverse()
81
+ .find((part) => part.type === "toolCall");
82
+ if (tool?.type === "toolCall") {
83
+ return sanitizeSubagentDisplayLine(tool.name);
84
+ }
85
+ }
86
+ return "";
53
87
  }
54
88
 
55
89
  // --- Entry points --------------------------------------------------------------
@@ -126,7 +160,7 @@ export function reconcileDashboardSelection(
126
160
  selection.id = subs[selection.index]?.id;
127
161
  }
128
162
 
129
- class SubagentDashboard implements Component {
163
+ export class SubagentDashboard implements Component {
130
164
  private tui: TUI;
131
165
  private theme: Theme;
132
166
  private keybindings: KeybindingsManager;
@@ -135,7 +169,7 @@ class SubagentDashboard implements Component {
135
169
  private done: (value: string | null) => void;
136
170
 
137
171
  private closed = false;
138
- private ticker: ReturnType<typeof setInterval>;
172
+ private ticker?: ReturnType<typeof setInterval>;
139
173
  private unsubChange: () => void;
140
174
 
141
175
  constructor(
@@ -152,19 +186,29 @@ class SubagentDashboard implements Component {
152
186
  this.view = view;
153
187
  this.selection = selection;
154
188
  this.done = done;
155
- // Elapsed times, token counts, and statuses tick along at 1Hz.
156
- this.ticker = setInterval(() => this.tui.requestRender(), 1000);
157
- this.unsubChange = view.subscribe(() => this.tui.requestRender());
189
+ this.refreshTicker();
190
+ this.unsubChange = view.subscribe(() => {
191
+ this.refreshTicker();
192
+ this.tui.requestRender();
193
+ });
158
194
  }
159
195
 
160
196
  private subs(): ReadonlyArray<SubagentSnapshot> {
161
197
  return this.view.list();
162
198
  }
163
199
 
200
+ private refreshTicker() {
201
+ const interval = this.subs().some((snap) => snap.status === "running")
202
+ ? SPINNER_INTERVAL_MS
203
+ : 1000;
204
+ if (this.ticker) clearInterval(this.ticker);
205
+ this.ticker = setInterval(() => this.tui.requestRender(), interval);
206
+ }
207
+
164
208
  private cleanup() {
165
209
  if (this.closed) return false;
166
210
  this.closed = true;
167
- clearInterval(this.ticker);
211
+ if (this.ticker) clearInterval(this.ticker);
168
212
  this.unsubChange();
169
213
  return true;
170
214
  }
@@ -237,45 +281,36 @@ class SubagentDashboard implements Component {
237
281
  const subs = this.subs();
238
282
  reconcileDashboardSelection(this.selection, subs);
239
283
 
284
+ // One timestamp per frame so every row's spinner shows the same frame.
285
+ const now = Date.now();
240
286
  const rows = this.tui.terminal.rows || 30;
241
- // Render exactly terminal rows - 1 so the overlay covers the header,
242
- // chat, editor, and extra footer lines while leaving pi's final footer
243
- // row visible.
244
- const bodyHeight = Math.max(6, rows - 5);
245
- const innerWidth = width - 2;
287
+ const maxBodyHeight = Math.max(1, rows - 5);
288
+ const bodyHeight =
289
+ subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length);
290
+ const innerWidth = Math.max(0, width - 2);
246
291
 
247
292
  const lines: string[] = [];
248
-
249
- // Header: title left, count right
250
- const headerLeft = theme.fg("accent", theme.bold("Subagents"));
251
- const headerRight = theme.fg(
252
- "muted",
253
- `${subs.length} agent${subs.length === 1 ? "" : "s"}`,
254
- );
255
- const headerPad = Math.max(
256
- 1,
257
- width - visibleWidth(headerLeft) - visibleWidth(headerRight) - 4,
258
- );
259
- lines.push(
260
- truncateToWidth(
261
- ` ${headerLeft}${" ".repeat(headerPad)}${headerRight} `,
262
- width,
263
- ),
264
- );
265
-
266
- // Top border with panel title
267
- const settled = subs.filter((s) => s.status !== "running").length;
293
+ const running = subs.filter((snap) => snap.status === "running").length;
294
+ const done = subs.filter((snap) => snap.status === "done").length;
295
+ const failed = subs.filter((snap) => snap.status === "error").length;
296
+ const summary =
297
+ [
298
+ running > 0 ? `${running} running` : "",
299
+ done > 0 ? `${done} done` : "",
300
+ failed > 0 ? `${failed} failed` : "",
301
+ ]
302
+ .filter(Boolean)
303
+ .join(" · ") || "no agents";
268
304
  lines.push(
269
305
  theme.fg("border", "╭") +
270
- this.borderSegment(innerWidth, `agents · ${settled}/${subs.length}`) +
306
+ this.borderSegment(innerWidth, `Subagents · ${summary}`) +
271
307
  theme.fg("border", "╮"),
272
308
  );
273
309
 
274
- // Rows
275
310
  const divider = theme.fg("border", "│");
276
- const rowLines = this.renderRows(subs, innerWidth, bodyHeight);
277
- for (let i = 0; i < bodyHeight; i++) {
278
- lines.push(divider + this.pad(rowLines[i] ?? "", innerWidth) + divider);
311
+ const rowLines = this.renderRows(subs, innerWidth, bodyHeight, now);
312
+ for (const row of rowLines) {
313
+ lines.push(divider + this.pad(row, innerWidth) + divider);
279
314
  }
280
315
 
281
316
  // Bottom border
@@ -303,37 +338,40 @@ class SubagentDashboard implements Component {
303
338
  subs: ReadonlyArray<SubagentSnapshot>,
304
339
  width: number,
305
340
  height: number,
341
+ now: number,
306
342
  ): string[] {
307
343
  const theme = this.theme;
308
344
  const out: string[] = [];
309
345
 
310
- // Scroll window around selection
346
+ const needsMore = subs.length > height && height > 1;
347
+ const visibleHeight = needsMore ? height - 1 : height;
348
+
349
+ // Scroll window around selection. The more indicator gets its own row, so
350
+ // it never replaces a selectable subagent.
311
351
  let start = 0;
312
- if (subs.length > height) {
352
+ if (subs.length > visibleHeight) {
313
353
  start = Math.min(
314
- Math.max(0, this.selection.index - Math.floor(height / 2)),
315
- subs.length - height,
354
+ Math.max(0, this.selection.index - Math.floor(visibleHeight / 2)),
355
+ Math.max(0, subs.length - visibleHeight),
316
356
  );
317
357
  }
318
- const visible = subs.slice(start, start + height);
358
+ const visible = subs.slice(start, start + visibleHeight);
319
359
 
320
360
  for (let i = 0; i < visible.length; i++) {
321
361
  const snap = visible[i];
322
362
  const index = start + i;
323
363
  const isSelected = index === this.selection.index;
324
364
 
325
- // Left: marker, status square, title
326
365
  const marker = isSelected ? theme.fg("accent", "❯") : " ";
327
366
  const safeTitle = sanitizeSubagentDisplayLine(snap.title) || snap.id;
328
367
  const title = isSelected
329
368
  ? theme.fg("accent", safeTitle)
330
369
  : theme.fg("text", safeTitle);
331
- const left = ` ${marker} ${statusGlyph(snap, theme)} ${title}`;
370
+ const activity = runningActivity(snap);
371
+ const prefix = ` ${marker} ${statusGlyph(snap, theme, now)} `;
332
372
 
333
- // Right: backend · model · context utilization · elapsed · status
334
373
  const utilization = formatContextUtilization(snap.usage);
335
- const dot = theme.fg("dim", " · ");
336
- const rightParts = [
374
+ const metadata = [
337
375
  theme.fg("muted", snap.backend),
338
376
  theme.fg(
339
377
  "muted",
@@ -341,24 +379,45 @@ class SubagentDashboard implements Component {
341
379
  ),
342
380
  ...(utilization ? [theme.fg("muted", utilization)] : []),
343
381
  theme.fg("muted", formatElapsed(snap)),
344
- statusWord(snap, theme),
345
382
  ];
346
- const right = `${rightParts.join(dot)} `;
347
-
383
+ const dot = theme.fg("dim", " · ");
384
+ // Preserve elapsed and the activity-bearing left side longest. Shed the
385
+ // least useful metadata as a segment instead of clipping a joined tail.
386
+ while (
387
+ metadata.length > 1 &&
388
+ visibleWidth(metadata.join(dot)) > Math.max(0, width - 16)
389
+ ) {
390
+ metadata.shift();
391
+ }
392
+ const right = metadata.join(dot);
348
393
  const rightWidth = visibleWidth(right);
349
- const leftMax = Math.max(0, width - rightWidth - 2);
350
- const leftTruncated = truncateToWidth(left, leftMax);
351
- const gap = Math.max(2, width - visibleWidth(leftTruncated) - rightWidth);
352
- out.push(truncateToWidth(leftTruncated + " ".repeat(gap) + right, width));
394
+ const leftMax = Math.max(0, width - rightWidth - (right ? 2 : 0));
395
+ const activityLabel = activity ? theme.fg("muted", ` · ${activity}`) : "";
396
+ const left =
397
+ prefix +
398
+ truncateToWidth(
399
+ title,
400
+ Math.max(
401
+ 0,
402
+ leftMax - visibleWidth(prefix) - visibleWidth(activityLabel),
403
+ ),
404
+ ) +
405
+ truncateToWidth(
406
+ activityLabel,
407
+ Math.max(0, leftMax - visibleWidth(prefix)),
408
+ );
409
+ const gap = right
410
+ ? Math.max(1, width - visibleWidth(left) - rightWidth)
411
+ : 0;
412
+ out.push(truncateToWidth(left + " ".repeat(gap) + right, width));
353
413
  }
354
414
 
355
- if (start > 0) {
356
- out[0] = truncateToWidth(theme.fg("dim", ` ... ${start} more`), width);
357
- }
358
- if (start + height < subs.length) {
359
- out[out.length - 1] = truncateToWidth(
360
- theme.fg("dim", ` ... ${subs.length - start - height} more`),
361
- width,
415
+ if (needsMore) {
416
+ out.push(
417
+ truncateToWidth(
418
+ theme.fg("dim", ` … ${subs.length - visible.length} more`),
419
+ width,
420
+ ),
362
421
  );
363
422
  }
364
423
  return out;
@@ -371,7 +430,7 @@ class SubagentDashboard implements Component {
371
430
 
372
431
  const TRANSCRIPT_SCROLL_STEP = 6;
373
432
 
374
- class TakeoverView implements Component, Focusable {
433
+ export class TakeoverView implements Component, Focusable {
375
434
  private tui: TUI;
376
435
  private theme: Theme;
377
436
  private keybindings: KeybindingsManager;
@@ -386,7 +445,7 @@ class TakeoverView implements Component, Focusable {
386
445
  private scrollOffset = 0;
387
446
  private unsubscribe: () => void;
388
447
  private renderTimer?: ReturnType<typeof setTimeout>;
389
- private ticker: ReturnType<typeof setInterval>;
448
+ private ticker?: ReturnType<typeof setInterval>;
390
449
  private closed = false;
391
450
 
392
451
  private _focused = false;
@@ -414,9 +473,11 @@ class TakeoverView implements Component, Focusable {
414
473
  this.view = view;
415
474
  this.done = done;
416
475
  this.options = options;
417
- this.unsubscribe = view.subscribeTo(id, () => this.scheduleRender());
418
- // Elapsed time in the header ticks along at 1Hz.
419
- this.ticker = setInterval(() => this.tui.requestRender(), 1000);
476
+ this.unsubscribe = view.subscribeTo(id, () => {
477
+ this.refreshTicker();
478
+ this.scheduleRender();
479
+ });
480
+ this.refreshTicker();
420
481
  this.input.onSubmit = (value: string) => {
421
482
  const text = value.trim();
422
483
  if (!text) return;
@@ -431,6 +492,13 @@ class TakeoverView implements Component, Focusable {
431
492
  return this.view.get(this.id);
432
493
  }
433
494
 
495
+ private refreshTicker() {
496
+ const interval =
497
+ this.snap()?.status === "running" ? SPINNER_INTERVAL_MS : 1000;
498
+ if (this.ticker) clearInterval(this.ticker);
499
+ this.ticker = setInterval(() => this.tui.requestRender(), interval);
500
+ }
501
+
434
502
  private scheduleRender() {
435
503
  if (this.renderTimer) return;
436
504
  // Streaming can emit an event per token. Limit terminal repaints so this
@@ -445,7 +513,7 @@ class TakeoverView implements Component, Focusable {
445
513
  if (this.closed) return false;
446
514
  this.closed = true;
447
515
  this.unsubscribe();
448
- clearInterval(this.ticker);
516
+ if (this.ticker) clearInterval(this.ticker);
449
517
  if (this.renderTimer) clearTimeout(this.renderTimer);
450
518
  this.renderTimer = undefined;
451
519
  return true;
@@ -504,59 +572,95 @@ class TakeoverView implements Component, Focusable {
504
572
 
505
573
  private viewportHeight(): number {
506
574
  const rows = this.tui.terminal.rows || 30;
507
- // The complete view renders viewport + 7 chrome rows. Using rows - 8
508
- // makes the overlay exactly terminal rows - 1.
509
- return Math.max(6, rows - 8);
575
+ // Top rule, transcript rule, input, key hints, and bottom rule are five
576
+ // chrome rows. The overlay leaves Pi's final footer row visible.
577
+ return Math.max(1, rows - 6);
578
+ }
579
+
580
+ private rule(width: number, left = "", right = "") {
581
+ const fill = "─";
582
+ const available = Math.max(1, width);
583
+ const leftWidth = visibleWidth(left);
584
+ const rightWidth = visibleWidth(right);
585
+ if (!right || leftWidth + rightWidth + 2 > available) {
586
+ return truncateToWidth(
587
+ left + fill.repeat(Math.max(0, available - leftWidth)),
588
+ available,
589
+ );
590
+ }
591
+ return (
592
+ left +
593
+ fill.repeat(Math.max(1, available - leftWidth - rightWidth)) +
594
+ right
595
+ );
510
596
  }
511
597
 
512
598
  render(width: number): string[] {
513
599
  const theme = this.theme;
514
- const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
600
+ const now = Date.now();
515
601
  const lines: string[] = [];
516
602
  const snap = this.snap();
517
603
 
518
604
  if (!snap) {
519
- lines.push(border);
520
- lines.push(theme.fg("dim", `${this.id} is no longer tracked`));
521
- lines.push(border);
605
+ const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
606
+ lines.push(
607
+ border,
608
+ theme.fg("dim", `${this.id} is no longer tracked`),
609
+ border,
610
+ );
522
611
  return lines;
523
612
  }
524
613
 
525
- lines.push(border);
614
+ const title = sanitizeSubagentDisplayLine(snap.title) || snap.id;
615
+ const headerLeft =
616
+ theme.fg("borderAccent", "─ ") +
617
+ statusGlyph(snap, theme, now) +
618
+ " " +
619
+ theme.fg("accent", theme.bold(title)) +
620
+ theme.fg("borderAccent", " ");
526
621
  const utilization = formatContextUtilization(snap.usage);
527
- const header =
528
- `${statusGlyph(snap, theme)} ` +
529
- theme.fg(
530
- "accent",
531
- theme.bold(sanitizeSubagentDisplayLine(snap.title) || snap.id),
532
- ) +
533
- theme.fg("muted", ` · ${snap.status} · ${formatElapsed(snap)}`) +
534
- (this.options?.badge
535
- ? theme.fg(
536
- "muted",
537
- ` · ${sanitizeSubagentDisplayLine(this.options.badge)}`,
538
- )
539
- : "") +
622
+ const metadata = [
623
+ ...(this.options?.badge
624
+ ? [theme.fg("muted", sanitizeSubagentDisplayLine(this.options.badge))]
625
+ : []),
540
626
  theme.fg(
541
- "dim",
542
- ` · ${snap.backend}: ${sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?"}`,
543
- ) +
544
- (utilization ? theme.fg("dim", ` · ${utilization}`) : "");
545
- lines.push(truncateToWidth(header, width));
546
- lines.push(border);
547
-
548
- // Fixed-height transcript viewport. Error and scroll status consume rows
549
- // inside the viewport so streaming/scrolling never changes overlay height.
627
+ "muted",
628
+ sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?",
629
+ ),
630
+ ...(utilization ? [theme.fg("muted", utilization)] : []),
631
+ theme.fg("muted", formatElapsed(snap)),
632
+ ];
633
+ const dot = theme.fg("dim", " · ");
634
+ while (
635
+ metadata.length > 1 &&
636
+ visibleWidth(headerLeft) + visibleWidth(metadata.join(dot)) + 2 > width
637
+ ) {
638
+ metadata.shift();
639
+ }
640
+ lines.push(
641
+ this.rule(
642
+ width,
643
+ truncateToWidth(
644
+ headerLeft,
645
+ Math.max(1, width - visibleWidth(metadata.join(dot)) - 2),
646
+ ),
647
+ metadata.join(dot),
648
+ ),
649
+ );
650
+
651
+ // Fixed-height transcript viewport. Errors consume a row, but scroll state
652
+ // is represented by the following rule so its height never changes.
653
+ // `now` is shared with the header glyph so both spinners show one frame.
550
654
  const transcript = buildTranscriptLines(
551
655
  snap,
552
656
  width,
553
657
  theme,
554
658
  this.transcriptRenderer,
659
+ { now },
555
660
  );
556
661
  const viewport = this.viewportHeight();
557
662
  const errorRows = snap.errorText ? 1 : 0;
558
- const scrollRows = this.scrollOffset > 0 ? 1 : 0;
559
- const transcriptCapacity = Math.max(1, viewport - errorRows - scrollRows);
663
+ const transcriptCapacity = Math.max(1, viewport - errorRows);
560
664
  const maxOffset = Math.max(0, transcript.length - transcriptCapacity);
561
665
  if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
562
666
 
@@ -572,39 +676,33 @@ class TakeoverView implements Component, Focusable {
572
676
  ),
573
677
  );
574
678
  }
575
-
576
- const capacity = Math.max(
577
- 1,
578
- viewport - body.length - (this.scrollOffset > 0 ? 1 : 0),
579
- );
580
679
  const end = transcript.length - this.scrollOffset;
581
- const visible = transcript.slice(Math.max(0, end - capacity), end);
582
- if (visible.length === 0) body.push(theme.fg("dim", "(no output yet)"));
680
+ const visible = transcript.slice(
681
+ Math.max(0, end - Math.max(1, viewport - body.length)),
682
+ end,
683
+ );
684
+ if (visible.length === 0) body.push(theme.fg("dim", "waiting for output…"));
583
685
  else body.push(...visible);
584
-
585
- if (this.scrollOffset > 0) {
586
- body.push(
587
- truncateToWidth(
588
- theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`),
589
- width,
590
- ),
591
- );
592
- }
593
686
  while (body.length < viewport) body.push("");
594
687
  lines.push(...body.slice(0, viewport));
595
688
 
596
- lines.push(border);
689
+ lines.push(
690
+ this.rule(
691
+ width,
692
+ theme.fg("borderAccent", "─"),
693
+ this.scrollOffset > 0 ? theme.fg("dim", `↓ ${this.scrollOffset}`) : "",
694
+ ),
695
+ );
597
696
  lines.push(...this.input.render(width));
697
+ const hints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`;
698
+ const compactHints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll`;
598
699
  lines.push(
599
700
  truncateToWidth(
600
- theme.fg(
601
- "dim",
602
- `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`,
603
- ),
701
+ theme.fg("dim", visibleWidth(hints) <= width ? hints : compactHints),
604
702
  width,
605
703
  ),
606
704
  );
607
- lines.push(border);
705
+ lines.push(this.rule(width, theme.fg("borderAccent", "─")));
608
706
  return lines;
609
707
  }
610
708