@tt-a1i/openpi 0.3.1 → 0.5.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.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -5,22 +5,36 @@ import type {
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  DynamicBorder,
8
- SessionManager,
9
8
  keyHint,
9
+ SessionManager,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import {
12
12
  CancellableLoader,
13
13
  Container,
14
14
  Key,
15
- SelectList,
15
+ matchesKey,
16
16
  type SelectItem,
17
+ SelectList,
17
18
  Spacer,
18
19
  Text,
19
- matchesKey,
20
20
  truncateToWidth,
21
21
  visibleWidth,
22
22
  wrapTextWithAnsi,
23
23
  } from "@earendil-works/pi-tui";
24
+ import { hintLine } from "../shared/screen-chrome.ts";
25
+ import {
26
+ createSessionStatsLoader,
27
+ type SessionStatsState,
28
+ } from "./git-stats.js";
29
+ import {
30
+ createSessionPreviewCache,
31
+ measureSessionPreviewBytes,
32
+ previewCacheKey,
33
+ } from "./preview-cache.js";
34
+ import {
35
+ loadSessionPreviewData,
36
+ readPreviewFileIdentity,
37
+ } from "./preview-loader.js";
24
38
  import {
25
39
  buildPreviewError,
26
40
  buildSessionDescription,
@@ -28,14 +42,15 @@ import {
28
42
  buildSessionPreview,
29
43
  buildSessionSearchEntries,
30
44
  filterSessionEntries,
45
+ formatRelativeTime,
31
46
  getSessionPaneLayout,
32
- parseLimit,
33
47
  type PreviewBlock,
48
+ type PreviewMessageLike,
49
+ parseLimit,
50
+ selectSessionStatsWindow,
34
51
  type SessionInfoLike,
35
52
  type SessionPreview,
36
- formatRelativeTime,
37
53
  } from "./sessions.js";
38
- import { createSessionStatsLoader, type SessionStats } from "./git-stats.js";
39
54
 
40
55
  const DEFAULT_VISIBLE = 12;
41
56
  const SNIPPET_MAX = 60;
@@ -82,7 +97,7 @@ const themeText = (
82
97
  ): string => {
83
98
  if (kind === "title") return theme.fg("accent", theme.bold(text));
84
99
  if (kind === "subtitle") return theme.fg("dim", text);
85
- if (kind === "rule") return theme.fg("border", text);
100
+ if (kind === "rule") return theme.fg("borderMuted", text);
86
101
  if (kind === "user") return theme.fg("accent", theme.bold(text));
87
102
  if (kind === "assistant") return theme.fg("warning", theme.bold(text));
88
103
  if (kind === "tool") return theme.fg("muted", theme.bold(text));
@@ -290,20 +305,16 @@ const renderPreviewBlock = (
290
305
  return renderTextBlock(`◇ ${block.label}`, block.text, width, theme, "muted");
291
306
  };
292
307
 
293
- /** Cached across picker openings, while the loader bounds process-wide Git work. */
294
- const statsLoader = createSessionStatsLoader();
295
- const statsCache = statsLoader.cache;
296
-
297
308
  const formatItemLabel = (
298
309
  session: SessionInfoLike,
299
310
  width: number,
300
311
  theme: any,
301
- statsCache: Map<string, SessionStats>,
312
+ statsState: SessionStatsState | undefined,
302
313
  ): string => {
303
314
  const itemWidth = width - 4;
304
315
  const timeStr = formatRelativeTime(session.modified);
305
316
 
306
- const stats = statsCache.get(session.path);
317
+ const stats = statsState?.status === "ready" ? statsState.stats : undefined;
307
318
  let statsStr = "";
308
319
  let statsLen = 0;
309
320
  if (stats && (stats.add > 0 || stats.mod > 0 || stats.del > 0)) {
@@ -318,6 +329,12 @@ const formatItemLabel = (
318
329
  (stats.del > 0 ? `${stats.del}`.length + 1 : 0) +
319
330
  parts.length -
320
331
  1;
332
+ } else if (statsState?.status === "pending") {
333
+ statsStr = theme.fg("dim", "…");
334
+ statsLen = 1;
335
+ } else if (statsState?.status === "unavailable") {
336
+ statsStr = theme.fg("warning", "?");
337
+ statsLen = 1;
321
338
  }
322
339
 
323
340
  const title = buildSessionLabel(session);
@@ -357,31 +374,20 @@ const renderPreview = (
357
374
  ): { lines: string[]; totalLines: number; maxScroll: number } => {
358
375
  const raw: string[] = [];
359
376
 
377
+ // Left-aligned, like every other OpenPI heading: centred titles read as a
378
+ // different application than the panel they sit inside, and they wander as
379
+ // the pane resizes.
360
380
  if (!preview) {
361
- raw.push(
362
- " ".repeat(Math.max(0, Math.floor((width - 7) / 2))) +
363
- themeText(theme, "title", "Preview"),
364
- );
365
- raw.push(
366
- " ".repeat(Math.max(0, Math.floor((width - 25) / 2))) +
367
- themeText(theme, "subtitle", "Loading selected session…"),
368
- );
381
+ raw.push(themeText(theme, "title", "Preview"));
382
+ raw.push(themeText(theme, "subtitle", "Loading selected session…"));
369
383
  } else {
370
- const titleStr = "Thread Preview";
371
- const titleColor =
384
+ const titleStr = "Thread preview";
385
+ raw.push(
372
386
  focus === "preview"
373
387
  ? themeText(theme, "title", titleStr)
374
- : themeText(theme, "subtitle", titleStr);
375
- const titlePadding = " ".repeat(
376
- Math.max(0, Math.floor((width - titleStr.length) / 2)),
388
+ : themeText(theme, "subtitle", titleStr),
377
389
  );
378
- raw.push(`${titlePadding}${titleColor}`);
379
-
380
- const subStr = preview.subtitle;
381
- const subPadding = " ".repeat(
382
- Math.max(0, Math.floor((width - visibleWidth(subStr)) / 2)),
383
- );
384
- raw.push(`${subPadding}${themeText(theme, "subtitle", subStr)}`);
390
+ raw.push(themeText(theme, "subtitle", preview.subtitle));
385
391
 
386
392
  raw.push(themeText(theme, "rule", "─".repeat(Math.max(0, width))));
387
393
 
@@ -438,10 +444,10 @@ const renderPreview = (
438
444
  if (i >= thumbStart && i < thumbStart + thumbSize) {
439
445
  scrollChar = theme.fg("text", "█");
440
446
  } else {
441
- scrollChar = theme.fg("border", "│");
447
+ scrollChar = theme.fg("borderMuted", "│");
442
448
  }
443
449
  } else {
444
- scrollChar = theme.fg("border", "│");
450
+ scrollChar = theme.fg("borderMuted", "│");
445
451
  }
446
452
 
447
453
  visible.push(padAnsiRight(line, width - 1) + scrollChar);
@@ -452,14 +458,28 @@ const renderPreview = (
452
458
 
453
459
  const loadSessionPreview = async (
454
460
  session: SessionInfoLike,
461
+ cache: ReturnType<typeof createSessionPreviewCache>,
462
+ signal: AbortSignal,
455
463
  ): Promise<SessionPreview> => {
456
- try {
457
- const manager = SessionManager.open(session.path);
458
- const context = manager.buildSessionContext();
459
- return buildSessionPreview(session, context.messages as any[]);
460
- } catch (error) {
461
- return buildPreviewError(session, error);
462
- }
464
+ const initialIdentity = await readPreviewFileIdentity(session.path);
465
+ const cached = cache.get(previewCacheKey(initialIdentity));
466
+ if (cached) return cached;
467
+
468
+ const data = await loadSessionPreviewData(session.path, { signal });
469
+ const preview = buildSessionPreview(
470
+ session,
471
+ data.messages as PreviewMessageLike[],
472
+ {
473
+ totalMessages: data.totalMessages,
474
+ truncatedBytes: data.truncatedBytes,
475
+ },
476
+ );
477
+ cache.set(
478
+ previewCacheKey(data.identity),
479
+ preview,
480
+ measureSessionPreviewBytes(preview),
481
+ );
482
+ return preview;
463
483
  };
464
484
 
465
485
  async function listSessions(
@@ -474,7 +494,9 @@ async function listSessions(
474
494
  const sessions = await ctx.ui.custom<SessionInfoLike[] | null>(
475
495
  (tui, theme, _kb, done) => {
476
496
  const container = new Container();
477
- const borderColor = (text: string) => theme.fg("border", text);
497
+ // Same tone as the split-pane frame and every other OpenPI panel; this
498
+ // was the last `border` call left in the package.
499
+ const borderColor = (text: string) => theme.fg("borderMuted", text);
478
500
 
479
501
  const loader = new CancellableLoader(
480
502
  tui,
@@ -559,12 +581,17 @@ async function showSessionPicker(
559
581
  let previewScrollOffset = 0;
560
582
  let toolsExpanded = false;
561
583
  let thinkingVisible = false;
562
- const previewCache = new Map<string, SessionPreview>();
584
+ const previewCache = createSessionPreviewCache();
563
585
  let activePreview: SessionPreview | undefined;
564
586
  let previewTimer: ReturnType<typeof setTimeout> | undefined;
587
+ let previewController: AbortController | undefined;
565
588
  let previewSeq = 0;
566
- let statsController = new AbortController();
567
- let statsGeneration = 0;
589
+ // Stats use a picker-scoped, per-query first-observation cache. Rows that
590
+ // become visible later may observe later repository state; reopening the
591
+ // picker refreshes every row without polling repositories while idle.
592
+ const statsLoader = createSessionStatsLoader();
593
+ let statsUniverseVersion = 0;
594
+ let statsWindowSignature = "";
568
595
  let workspaceLoadSeq = 0;
569
596
  let disposed = false;
570
597
  let settled = false;
@@ -573,14 +600,22 @@ async function showSessionPicker(
573
600
  let showAllWorkspaces = false;
574
601
  let isLoading = false;
575
602
 
603
+ const cancelPreviewLoad = () => {
604
+ previewSeq++;
605
+ if (previewTimer) clearTimeout(previewTimer);
606
+ previewTimer = undefined;
607
+ previewController?.abort();
608
+ previewController = undefined;
609
+ };
610
+
576
611
  const disposePicker = () => {
577
612
  if (disposed) return;
578
613
  disposed = true;
579
614
  workspaceLoadSeq++;
580
- statsGeneration++;
581
- statsController.abort();
582
- previewSeq++;
583
- if (previewTimer) clearTimeout(previewTimer);
615
+ statsLoader.cancel();
616
+ cancelPreviewLoad();
617
+ previewCache.clear();
618
+ activePreview = undefined;
584
619
  };
585
620
  const finishPicker = (result: SessionInfoLike | null) => {
586
621
  if (settled) return;
@@ -588,30 +623,29 @@ async function showSessionPicker(
588
623
  disposePicker();
589
624
  done(result);
590
625
  };
591
- const previewKey = (session: SessionInfoLike): string =>
592
- `${session.path}:${session.modified.getTime()}`;
593
-
594
- const cancelStatsLoad = () => {
595
- statsGeneration++;
596
- statsController.abort();
597
- };
598
- const triggerStatsLoad = (list: SessionInfoLike[]) => {
599
- cancelStatsLoad();
600
- statsController = new AbortController();
601
- const generation = statsGeneration;
602
- const signal = statsController.signal;
603
- void statsLoader.load(list, signal, () => {
604
- if (disposed || signal.aborted || generation !== statsGeneration)
605
- return;
606
- tui.requestRender();
626
+ const ensureStatsWindowLoad = (visibleCount: number) => {
627
+ const targets = selectSessionStatsWindow(
628
+ filteredEntries.map((entry) => entry.session),
629
+ selectedPath,
630
+ visibleCount,
631
+ );
632
+ const signature = `${statsUniverseVersion}:${targets
633
+ .map((session) => session.path)
634
+ .join("\0")}`;
635
+ if (signature === statsWindowSignature) return;
636
+ statsWindowSignature = signature;
637
+ void statsLoader.reconcile(targets, sorted, () => {
638
+ if (!disposed) tui.requestRender();
607
639
  });
608
640
  };
609
641
 
610
- triggerStatsLoad(sorted);
611
-
612
642
  const loadWorkspaceSessions = async (allWorkspaces: boolean) => {
613
643
  const loadSeq = ++workspaceLoadSeq;
614
- cancelStatsLoad();
644
+ statsLoader.cancel();
645
+ statsWindowSignature = "";
646
+ cancelPreviewLoad();
647
+ previewCache.clear();
648
+ activePreview = undefined;
615
649
  isLoading = true;
616
650
  tui.requestRender();
617
651
  try {
@@ -624,11 +658,11 @@ async function showSessionPicker(
624
658
  if (disposed || loadSeq !== workspaceLoadSeq) return;
625
659
 
626
660
  sorted = sortSessions(results);
661
+ statsUniverseVersion++;
627
662
  for (const s of sorted) {
628
663
  sessionByPath.set(s.path, s);
629
664
  }
630
665
  entries = buildSessionSearchEntries(sorted);
631
- triggerStatsLoad(sorted);
632
666
 
633
667
  filteredEntries = filterSessionEntries(entries, filter);
634
668
  const matchedIndex = filteredEntries.findIndex(
@@ -656,6 +690,7 @@ async function showSessionPicker(
656
690
  };
657
691
 
658
692
  const schedulePreviewLoad = () => {
693
+ cancelPreviewLoad();
659
694
  previewScrollOffset = 0;
660
695
  const session = sessionByPath.get(selectedPath);
661
696
  if (!session) {
@@ -663,28 +698,45 @@ async function showSessionPicker(
663
698
  return;
664
699
  }
665
700
 
666
- const key = previewKey(session);
667
- const cached = previewCache.get(key);
668
- if (cached) {
669
- activePreview = cached;
670
- return;
671
- }
672
-
673
701
  activePreview = {
674
702
  title: buildSessionLabel(session),
675
703
  subtitle: `${session.modified.toLocaleString()} · loading preview`,
676
704
  blocks: [{ kind: "notice", text: "Loading selected session…" }],
677
705
  };
678
706
 
679
- if (previewTimer) clearTimeout(previewTimer);
680
- const seq = ++previewSeq;
707
+ const seq = previewSeq;
681
708
  previewTimer = setTimeout(() => {
682
- void loadSessionPreview(session).then((preview) => {
683
- if (seq !== previewSeq || selectedPath !== session.path) return;
684
- previewCache.set(key, preview);
685
- activePreview = preview;
686
- tui.requestRender();
687
- });
709
+ previewTimer = undefined;
710
+ const controller = new AbortController();
711
+ previewController = controller;
712
+ void loadSessionPreview(session, previewCache, controller.signal)
713
+ .then((preview) => {
714
+ if (
715
+ controller.signal.aborted ||
716
+ seq !== previewSeq ||
717
+ selectedPath !== session.path
718
+ ) {
719
+ return;
720
+ }
721
+ activePreview = preview;
722
+ tui.requestRender();
723
+ })
724
+ .catch((error) => {
725
+ if (
726
+ controller.signal.aborted ||
727
+ seq !== previewSeq ||
728
+ selectedPath !== session.path
729
+ ) {
730
+ return;
731
+ }
732
+ activePreview = buildPreviewError(session, error);
733
+ tui.requestRender();
734
+ })
735
+ .finally(() => {
736
+ if (previewController === controller) {
737
+ previewController = undefined;
738
+ }
739
+ });
688
740
  }, PREVIEW_LOAD_DEBOUNCE_MS);
689
741
  };
690
742
 
@@ -700,7 +752,12 @@ async function showSessionPicker(
700
752
  ): SelectItem[] =>
701
753
  current.map((entry) => ({
702
754
  value: entry.session.path,
703
- label: formatItemLabel(entry.session, listWidth, theme, statsCache),
755
+ label: formatItemLabel(
756
+ entry.session,
757
+ listWidth,
758
+ theme,
759
+ statsLoader.get(entry.session),
760
+ ),
704
761
  }));
705
762
 
706
763
  const rebuild = () => {
@@ -732,24 +789,25 @@ async function showSessionPicker(
732
789
  if (isLoading) {
733
790
  container.addChild(new Spacer(1));
734
791
  container.addChild(
735
- new Text(theme.fg("muted", " Loading sessions..."), 1, 0),
792
+ new Text(theme.fg("muted", " Loading sessions"), 1, 0),
736
793
  );
737
794
  container.addChild(new Spacer(1));
738
795
  } else {
739
- const items = buildItems(filteredEntries, width);
740
- selectList = new SelectList(
741
- items,
742
- Math.max(1, Math.min(maxVisible, Math.max(items.length, 1))),
743
- {
744
- selectedPrefix: (text) =>
745
- theme.fg(focus === "list" ? "accent" : "muted", text),
746
- selectedText: (text) =>
747
- theme.fg(focus === "list" ? "accent" : "muted", text),
748
- description: (text) => theme.fg("muted", text),
749
- scrollInfo: (text) => theme.fg("dim", text),
750
- noMatch: () => theme.fg("warning", " No matching sessions"),
751
- },
796
+ const effectiveVisible = Math.max(
797
+ 1,
798
+ Math.min(maxVisible, Math.max(filteredEntries.length, 1)),
752
799
  );
800
+ ensureStatsWindowLoad(effectiveVisible);
801
+ const items = buildItems(filteredEntries, width);
802
+ selectList = new SelectList(items, effectiveVisible, {
803
+ selectedPrefix: (text) =>
804
+ theme.fg(focus === "list" ? "accent" : "muted", text),
805
+ selectedText: (text) =>
806
+ theme.fg(focus === "list" ? "accent" : "muted", text),
807
+ description: (text) => theme.fg("muted", text),
808
+ scrollInfo: (text) => theme.fg("dim", text),
809
+ noMatch: () => theme.fg("warning", " No matching sessions"),
810
+ });
753
811
  const selectedIndex = filteredEntries.findIndex(
754
812
  (entry) => entry.session.path === selectedPath,
755
813
  );
@@ -762,7 +820,15 @@ async function showSessionPicker(
762
820
  }
763
821
  container.addChild(
764
822
  new Text(
765
- theme.fg("dim", "↑↓ navigate • enter open • esc cancel"),
823
+ hintLine(
824
+ theme,
825
+ [
826
+ ["↑↓", "navigate"],
827
+ ["enter", "open"],
828
+ ["esc", "cancel"],
829
+ ],
830
+ Math.max(1, width - 2),
831
+ ),
766
832
  1,
767
833
  0,
768
834
  ),
@@ -777,29 +843,29 @@ async function showSessionPicker(
777
843
  listWidth: number,
778
844
  previewWidth: number,
779
845
  ): string => {
780
- const leftTitle = " Switch Thread ";
846
+ const leftTitle = " sessions ";
847
+ const rightTitle = " preview ";
781
848
  const left = `┌─${leftTitle}${"─".repeat(Math.max(0, listWidth - leftTitle.length - 2))}`;
782
- const right = "─".repeat(Math.max(0, previewWidth - 1)) + "┐";
783
- return `${theme.fg("border", left)}${theme.fg("border", "─┬─")}${theme.fg("border", right)}`;
849
+ const right =
850
+ `─${rightTitle}${"".repeat(Math.max(0, previewWidth - rightTitle.length - 2))}` +
851
+ "┐";
852
+ return `${theme.fg("borderMuted", left)}${theme.fg("borderMuted", "─┬─")}${theme.fg("borderMuted", right)}`;
784
853
  };
785
854
 
786
855
  const buildBottomBorder = (
787
856
  listWidth: number,
788
857
  previewWidth: number,
789
- previewStats: string,
790
858
  ): string => {
791
- const help = showAllWorkspaces
792
- ? " Opt+W/Ctrl+T current workspace · Esc close "
793
- : " Opt+W/Ctrl+T all workspaces · Esc close ";
794
859
  const left = `└${"─".repeat(Math.max(0, listWidth - 1))}`;
795
- const right = `${"─".repeat(Math.max(0, previewWidth - help.length - 1))}${help}┘`;
796
- return `${theme.fg("border", left)}${theme.fg("border", "─┴─")}${theme.fg("border", right)}`;
860
+ const right = `${"─".repeat(Math.max(0, previewWidth - 1))}┘`;
861
+ return `${theme.fg("borderMuted", left)}${theme.fg("borderMuted", "─┴─")}${theme.fg("borderMuted", right)}`;
797
862
  };
798
863
 
799
864
  const renderSplitPane = (width: number): string[] => {
800
865
  const layout = getSessionPaneLayout(width);
801
866
  const termRows = Math.max(12, tui.terminal?.rows ?? 24);
802
- const contentHeight = Math.max(8, termRows - 2);
867
+ // Frame (2) + hint line (1).
868
+ const contentHeight = Math.max(8, termRows - 3);
803
869
  const filterLine = filter.length
804
870
  ? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}`
805
871
  : `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`;
@@ -811,23 +877,21 @@ async function showSessionPicker(
811
877
  leftLines.push("");
812
878
  leftLines.push(theme.fg("muted", " Loading sessions..."));
813
879
  } else {
814
- const items = buildItems(filteredEntries, layout.listWidth);
815
- selectList = new SelectList(
816
- items,
817
- Math.max(
818
- listHeight,
819
- Math.min(maxVisible, Math.max(items.length, 1)),
820
- ),
821
- {
822
- selectedPrefix: (text) =>
823
- theme.fg(focus === "list" ? "accent" : "muted", text),
824
- selectedText: (text) =>
825
- theme.fg(focus === "list" ? "accent" : "muted", text),
826
- description: (text) => theme.fg("muted", text),
827
- scrollInfo: (text) => theme.fg("dim", text),
828
- noMatch: () => theme.fg("warning", " No matching sessions"),
829
- },
880
+ const effectiveVisible = Math.max(
881
+ listHeight,
882
+ Math.min(maxVisible, Math.max(filteredEntries.length, 1)),
830
883
  );
884
+ ensureStatsWindowLoad(effectiveVisible);
885
+ const items = buildItems(filteredEntries, layout.listWidth);
886
+ selectList = new SelectList(items, effectiveVisible, {
887
+ selectedPrefix: (text) =>
888
+ theme.fg(focus === "list" ? "accent" : "muted", text),
889
+ selectedText: (text) =>
890
+ theme.fg(focus === "list" ? "accent" : "muted", text),
891
+ description: (text) => theme.fg("muted", text),
892
+ scrollInfo: (text) => theme.fg("dim", text),
893
+ noMatch: () => theme.fg("warning", " No matching sessions"),
894
+ });
831
895
  const selectedIndex = filteredEntries.findIndex(
832
896
  (entry) => entry.session.path === selectedPath,
833
897
  );
@@ -861,26 +925,41 @@ async function showSessionPicker(
861
925
  previewScrollOffset,
862
926
  renderedPreview.maxScroll,
863
927
  );
864
- const modeHints = `t ${toolsExpanded ? "compact" : "tools"} h ${thinkingVisible ? "hide thinking" : "thinking"}`;
865
- const previewStats =
866
- renderedPreview.maxScroll > 0
867
- ? ` ${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines} • pgup/pgdn • ${modeHints} `
868
- : ` esc/enter • ${modeHints} `;
928
+ // Hints live on their own line under the frame instead of being packed
929
+ // into the bottom border, where they had to compete with the border for
930
+ // the same row and lost the keys in a wall of dim text.
931
+ const hints = hintLine(
932
+ theme,
933
+ [
934
+ renderedPreview.maxScroll > 0
935
+ ? ([
936
+ "",
937
+ `${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines}`,
938
+ ] as const)
939
+ : undefined,
940
+ renderedPreview.maxScroll > 0
941
+ ? (["pgup/pgdn", "scroll"] as const)
942
+ : undefined,
943
+ ["t", toolsExpanded ? "compact" : "tools"],
944
+ ["h", thinkingVisible ? "hide thinking" : "thinking"],
945
+ [
946
+ "opt+w/ctrl+t",
947
+ showAllWorkspaces ? "current workspace" : "all workspaces",
948
+ ],
949
+ ["esc", "close"],
950
+ ],
951
+ width,
952
+ );
869
953
 
870
954
  const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)];
871
955
  for (let i = 0; i < contentHeight; i++) {
872
956
  const left = padAnsiRight(leftLines[i] ?? "", layout.listWidth);
873
957
  const right =
874
958
  renderedPreview.lines[i] ?? " ".repeat(layout.previewWidth);
875
- lines.push(`${left}${theme.fg("border", " │ ")}${right}`);
959
+ lines.push(`${left}${theme.fg("borderMuted", " │ ")}${right}`);
876
960
  }
877
- lines.push(
878
- buildBottomBorder(
879
- layout.listWidth,
880
- layout.previewWidth,
881
- previewStats,
882
- ),
883
- );
961
+ lines.push(buildBottomBorder(layout.listWidth, layout.previewWidth));
962
+ lines.push(hints);
884
963
  return lines.map((line) => truncateToWidth(line, width, "", true));
885
964
  };
886
965
 
@@ -894,8 +973,11 @@ async function showSessionPicker(
894
973
  return renderSplitPane(width);
895
974
  },
896
975
  invalidate: () => {
976
+ cancelPreviewLoad();
897
977
  previewCache.clear();
978
+ activePreview = undefined;
898
979
  rebuild();
980
+ schedulePreviewLoad();
899
981
  },
900
982
  dispose: disposePicker,
901
983
  handleInput: (data) => {