@tt-a1i/openpi 0.1.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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,1092 @@
1
+ // Adapted from pi-agent-extensions (MIT); see THIRD_PARTY_NOTICES.md.
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ DynamicBorder,
8
+ SessionManager,
9
+ keyHint,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ CancellableLoader,
13
+ Container,
14
+ Key,
15
+ SelectList,
16
+ type SelectItem,
17
+ Spacer,
18
+ Text,
19
+ matchesKey,
20
+ truncateToWidth,
21
+ visibleWidth,
22
+ wrapTextWithAnsi,
23
+ } from "@earendil-works/pi-tui";
24
+ import {
25
+ buildPreviewError,
26
+ buildSessionDescription,
27
+ buildSessionLabel,
28
+ buildSessionPreview,
29
+ buildSessionSearchEntries,
30
+ filterSessionEntries,
31
+ getSessionPaneLayout,
32
+ parseLimit,
33
+ type PreviewBlock,
34
+ type SessionInfoLike,
35
+ type SessionPreview,
36
+ formatRelativeTime,
37
+ } from "./sessions.js";
38
+ import { createSessionStatsLoader, type SessionStats } from "./git-stats.js";
39
+
40
+ const DEFAULT_VISIBLE = 12;
41
+ const SNIPPET_MAX = 60;
42
+ const PREVIEW_LOAD_DEBOUNCE_MS = 50;
43
+
44
+ const isPrintable = (data: string): boolean => {
45
+ if (data.length !== 1) return false;
46
+ const code = data.charCodeAt(0);
47
+ return code >= 32 && code !== 127;
48
+ };
49
+
50
+ const sortSessions = (sessions: SessionInfoLike[]): SessionInfoLike[] =>
51
+ [...sessions].sort((a, b) => b.modified.getTime() - a.modified.getTime());
52
+
53
+ const formatPlainLine = (session: SessionInfoLike): string => {
54
+ const label = buildSessionLabel(session);
55
+ const description = buildSessionDescription(session, SNIPPET_MAX);
56
+ return `${label}\t${description}`;
57
+ };
58
+
59
+ const padAnsiRight = (text: string, width: number): string => {
60
+ const truncated = truncateToWidth(text, width, "");
61
+ return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
62
+ };
63
+
64
+ const themeText = (
65
+ theme: {
66
+ fg: (color: any, text: string) => string;
67
+ bold: (text: string) => string;
68
+ italic?: (text: string) => string;
69
+ },
70
+ kind:
71
+ | "title"
72
+ | "subtitle"
73
+ | "rule"
74
+ | "user"
75
+ | "assistant"
76
+ | "tool"
77
+ | "error"
78
+ | "muted"
79
+ | "text"
80
+ | "thinking",
81
+ text: string,
82
+ ): string => {
83
+ if (kind === "title") return theme.fg("accent", theme.bold(text));
84
+ if (kind === "subtitle") return theme.fg("dim", text);
85
+ if (kind === "rule") return theme.fg("border", text);
86
+ if (kind === "user") return theme.fg("accent", theme.bold(text));
87
+ if (kind === "assistant") return theme.fg("warning", theme.bold(text));
88
+ if (kind === "tool") return theme.fg("muted", theme.bold(text));
89
+ if (kind === "error") return theme.fg("error", text);
90
+ if (kind === "muted") return theme.fg("muted", text);
91
+ if (kind === "thinking")
92
+ return theme.italic
93
+ ? theme.italic(theme.fg("dim", text))
94
+ : theme.fg("dim", text);
95
+ return theme.fg("text", text);
96
+ };
97
+
98
+ interface PreviewRenderOptions {
99
+ toolsExpanded: boolean;
100
+ thinkingVisible: boolean;
101
+ }
102
+
103
+ const splitLines = (text: string): string[] =>
104
+ text.replace(/\r\n/g, "\n").split("\n");
105
+
106
+ const compactText = (
107
+ text: string,
108
+ maxLines: number,
109
+ ): { lines: string[]; hidden: number } => {
110
+ const lines = splitLines(text)
111
+ .map((line) => line.replace(/\s+$/g, ""))
112
+ .filter((line) => line.trim().length > 0);
113
+ if (lines.length <= maxLines) return { lines, hidden: 0 };
114
+ return { lines: lines.slice(0, maxLines), hidden: lines.length - maxLines };
115
+ };
116
+
117
+ const wrapStyled = (
118
+ prefix: string,
119
+ text: string,
120
+ width: number,
121
+ color: (line: string) => string,
122
+ ): string[] => {
123
+ const contentWidth = Math.max(1, width - visibleWidth(prefix));
124
+ const wrapped = wrapTextWithAnsi(color(text), contentWidth);
125
+ return wrapped.map((line) => `${prefix}${line}`);
126
+ };
127
+
128
+ const renderTextBlock = (
129
+ label: string,
130
+ text: string,
131
+ width: number,
132
+ theme: {
133
+ fg: (color: any, text: string) => string;
134
+ bold: (text: string) => string;
135
+ },
136
+ labelKind: "user" | "assistant" | "tool" | "muted" | "error" | "thinking",
137
+ ): string[] => {
138
+ const lines = [themeText(theme, labelKind, label)];
139
+ for (const rawLine of splitLines(text)) {
140
+ if (!rawLine.trim()) {
141
+ lines.push("");
142
+ continue;
143
+ }
144
+ lines.push(
145
+ ...wrapStyled(" ", rawLine.trimEnd(), width, (line) =>
146
+ theme.fg("text", line),
147
+ ),
148
+ );
149
+ }
150
+ return lines;
151
+ };
152
+
153
+ const isToolBlock = (block: PreviewBlock): boolean =>
154
+ block.kind === "toolCall" ||
155
+ block.kind === "toolResult" ||
156
+ block.kind === "bash";
157
+
158
+ const summarizeToolRun = (blocks: PreviewBlock[]): string => {
159
+ const names = new Set<string>();
160
+ let outputLines = 0;
161
+ let hasError = false;
162
+
163
+ for (const block of blocks) {
164
+ if (block.kind === "toolCall") names.add(block.name);
165
+ if (block.kind === "toolResult") {
166
+ if (block.name) names.add(block.name);
167
+ outputLines += compactText(block.text, Number.MAX_SAFE_INTEGER).lines
168
+ .length;
169
+ hasError ||= !!block.isError;
170
+ }
171
+ if (block.kind === "bash") {
172
+ names.add("bash");
173
+ outputLines += compactText(block.output ?? "", Number.MAX_SAFE_INTEGER)
174
+ .lines.length;
175
+ hasError ||= !!block.isError;
176
+ }
177
+ }
178
+
179
+ const nameList = Array.from(names).slice(0, 4).join(", ");
180
+ const moreNames = names.size > 4 ? ` +${names.size - 4}` : "";
181
+ const output = outputLines > 0 ? ` · ${outputLines} output lines` : "";
182
+ return `${hasError ? "✖" : "▸"} Tool activity · ${blocks.length} events${nameList ? ` · ${nameList}${moreNames}` : ""}${output} (press t)`;
183
+ };
184
+
185
+ const renderPreviewBlock = (
186
+ block: PreviewBlock,
187
+ width: number,
188
+ theme: {
189
+ fg: (color: any, text: string) => string;
190
+ bold: (text: string) => string;
191
+ italic?: (text: string) => string;
192
+ },
193
+ options: PreviewRenderOptions,
194
+ ): string[] => {
195
+ if (block.kind === "notice") {
196
+ return wrapTextWithAnsi(themeText(theme, "muted", block.text), width);
197
+ }
198
+
199
+ if (block.kind === "user") {
200
+ return renderTextBlock("◆ User", block.text, width, theme, "user");
201
+ }
202
+
203
+ if (block.kind === "assistant") {
204
+ return renderTextBlock(
205
+ "● Assistant",
206
+ block.text,
207
+ width,
208
+ theme,
209
+ "assistant",
210
+ );
211
+ }
212
+
213
+ if (block.kind === "thinking") {
214
+ if (!options.thinkingVisible)
215
+ return [themeText(theme, "thinking", "◌ Thinking hidden")];
216
+ return renderTextBlock("◌ Thinking", block.text, width, theme, "thinking");
217
+ }
218
+
219
+ if (block.kind === "toolCall") {
220
+ const header = `▸ Tool call · ${block.name}`;
221
+ if (!options.toolsExpanded || !block.args)
222
+ return [themeText(theme, "tool", header)];
223
+ return [
224
+ themeText(theme, "tool", header),
225
+ ...wrapStyled(" ", block.args, width, (line) => theme.fg("muted", line)),
226
+ ];
227
+ }
228
+
229
+ if (block.kind === "toolResult") {
230
+ const label = `${block.isError ? "✖" : "▸"} Tool result${block.name ? ` · ${block.name}` : ""}`;
231
+ const compact = compactText(block.text, options.toolsExpanded ? 200 : 4);
232
+ const lines = [themeText(theme, block.isError ? "error" : "tool", label)];
233
+ for (const line of compact.lines) {
234
+ lines.push(
235
+ ...wrapStyled(" ", line, width, (value) =>
236
+ theme.fg(block.isError ? "error" : "muted", value),
237
+ ),
238
+ );
239
+ }
240
+ if (compact.hidden > 0)
241
+ lines.push(
242
+ theme.fg(
243
+ "dim",
244
+ ` … ${compact.hidden} output lines collapsed (press t to expand tools)`,
245
+ ),
246
+ );
247
+ return lines;
248
+ }
249
+
250
+ if (block.kind === "bash") {
251
+ const label = `${block.isError ? "✖" : "▸"} Bash`;
252
+ const lines = [themeText(theme, block.isError ? "error" : "tool", label)];
253
+ if (block.command)
254
+ lines.push(
255
+ ...wrapStyled(" ", `$ ${block.command}`, width, (line) =>
256
+ theme.fg("accent", line),
257
+ ),
258
+ );
259
+ const compact = compactText(
260
+ block.output ?? "",
261
+ options.toolsExpanded ? 200 : 4,
262
+ );
263
+ for (const line of compact.lines) {
264
+ lines.push(
265
+ ...wrapStyled(" ", line, width, (value) =>
266
+ theme.fg(block.isError ? "error" : "muted", value),
267
+ ),
268
+ );
269
+ }
270
+ if (compact.hidden > 0)
271
+ lines.push(
272
+ theme.fg(
273
+ "dim",
274
+ ` … ${compact.hidden} output lines collapsed (press t to expand tools)`,
275
+ ),
276
+ );
277
+ return lines;
278
+ }
279
+
280
+ if (block.kind === "summary") {
281
+ return renderTextBlock(
282
+ `◇ ${block.label}`,
283
+ block.text,
284
+ width,
285
+ theme,
286
+ "muted",
287
+ );
288
+ }
289
+
290
+ return renderTextBlock(`◇ ${block.label}`, block.text, width, theme, "muted");
291
+ };
292
+
293
+ /** Cached across picker openings, while the loader bounds process-wide Git work. */
294
+ const statsLoader = createSessionStatsLoader();
295
+ const statsCache = statsLoader.cache;
296
+
297
+ const formatItemLabel = (
298
+ session: SessionInfoLike,
299
+ width: number,
300
+ theme: any,
301
+ statsCache: Map<string, SessionStats>,
302
+ ): string => {
303
+ const itemWidth = width - 4;
304
+ const timeStr = formatRelativeTime(session.modified);
305
+
306
+ const stats = statsCache.get(session.path);
307
+ let statsStr = "";
308
+ let statsLen = 0;
309
+ if (stats && (stats.add > 0 || stats.mod > 0 || stats.del > 0)) {
310
+ const parts = [];
311
+ if (stats.add > 0) parts.push(theme.fg("success", `+${stats.add}`));
312
+ if (stats.mod > 0) parts.push(theme.fg("warning", `~${stats.mod}`));
313
+ if (stats.del > 0) parts.push(theme.fg("error", `-${stats.del}`));
314
+ statsStr = parts.join(" ");
315
+ statsLen =
316
+ (stats.add > 0 ? `${stats.add}`.length + 1 : 0) +
317
+ (stats.mod > 0 ? `${stats.mod}`.length + 1 : 0) +
318
+ (stats.del > 0 ? `${stats.del}`.length + 1 : 0) +
319
+ parts.length -
320
+ 1;
321
+ }
322
+
323
+ const title = buildSessionLabel(session);
324
+ const reserved = 1 + timeStr.length + (statsLen > 0 ? statsLen + 2 : 0);
325
+ const titleWidth = Math.max(5, itemWidth - reserved);
326
+ const truncatedTitle = truncateToWidth(title, titleWidth, "…");
327
+
328
+ const visibleTitleLen = visibleWidth(truncatedTitle);
329
+ const paddingSize =
330
+ itemWidth -
331
+ visibleTitleLen -
332
+ (statsLen > 0 ? statsLen : 0) -
333
+ timeStr.length;
334
+
335
+ if (statsStr) {
336
+ const pad1 = " ".repeat(Math.max(1, paddingSize - 2));
337
+ const pad2 = " ";
338
+ return `${truncatedTitle}${pad1}${statsStr}${pad2}${theme.fg("dim", timeStr)}`;
339
+ } else {
340
+ const padding = " ".repeat(Math.max(1, paddingSize));
341
+ return `${truncatedTitle}${padding}${theme.fg("dim", timeStr)}`;
342
+ }
343
+ };
344
+
345
+ const renderPreview = (
346
+ preview: SessionPreview | undefined,
347
+ width: number,
348
+ height: number,
349
+ scrollOffset: number,
350
+ theme: {
351
+ fg: (color: any, text: string) => string;
352
+ bold: (text: string) => string;
353
+ italic?: (text: string) => string;
354
+ },
355
+ options: PreviewRenderOptions,
356
+ focus: "list" | "preview",
357
+ ): { lines: string[]; totalLines: number; maxScroll: number } => {
358
+ const raw: string[] = [];
359
+
360
+ 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
+ );
369
+ } else {
370
+ const titleStr = "Thread Preview";
371
+ const titleColor =
372
+ focus === "preview"
373
+ ? themeText(theme, "title", titleStr)
374
+ : themeText(theme, "subtitle", titleStr);
375
+ const titlePadding = " ".repeat(
376
+ Math.max(0, Math.floor((width - titleStr.length) / 2)),
377
+ );
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)}`);
385
+
386
+ raw.push(themeText(theme, "rule", "─".repeat(Math.max(0, width))));
387
+
388
+ for (let i = 0; i < preview.blocks.length; i++) {
389
+ const block = preview.blocks[i]!;
390
+ if (!options.toolsExpanded && isToolBlock(block)) {
391
+ const run: PreviewBlock[] = [];
392
+ while (i < preview.blocks.length && isToolBlock(preview.blocks[i]!)) {
393
+ run.push(preview.blocks[i]!);
394
+ i++;
395
+ }
396
+ i--;
397
+ if (raw.length > 3) raw.push("");
398
+ raw.push(
399
+ ...wrapTextWithAnsi(
400
+ themeText(theme, "tool", summarizeToolRun(run)),
401
+ width,
402
+ ),
403
+ );
404
+ continue;
405
+ }
406
+
407
+ if (raw.length > 3) raw.push("");
408
+ raw.push(...renderPreviewBlock(block, width, theme, options));
409
+ }
410
+ }
411
+
412
+ const maxScroll = Math.max(0, raw.length - height);
413
+ const boundedOffset = Math.max(0, Math.min(scrollOffset, maxScroll));
414
+
415
+ // Render scrollbar on the right border
416
+ const visible: string[] = [];
417
+ const thumbSize = Math.max(
418
+ 1,
419
+ Math.min(height, Math.round((height / raw.length) * height)),
420
+ );
421
+ const thumbStart =
422
+ maxScroll > 0
423
+ ? Math.max(
424
+ 0,
425
+ Math.min(
426
+ height - thumbSize,
427
+ Math.round((boundedOffset / maxScroll) * (height - thumbSize)),
428
+ ),
429
+ )
430
+ : 0;
431
+
432
+ for (let i = 0; i < height; i++) {
433
+ const lineIndex = boundedOffset + i;
434
+ const line = lineIndex < raw.length ? raw[lineIndex]! : "";
435
+
436
+ let scrollChar = "";
437
+ if (maxScroll > 0) {
438
+ if (i >= thumbStart && i < thumbStart + thumbSize) {
439
+ scrollChar = theme.fg("text", "█");
440
+ } else {
441
+ scrollChar = theme.fg("border", "│");
442
+ }
443
+ } else {
444
+ scrollChar = theme.fg("border", "│");
445
+ }
446
+
447
+ visible.push(padAnsiRight(line, width - 1) + scrollChar);
448
+ }
449
+
450
+ return { lines: visible, totalLines: raw.length, maxScroll };
451
+ };
452
+
453
+ const loadSessionPreview = async (
454
+ session: SessionInfoLike,
455
+ ): 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
+ }
463
+ };
464
+
465
+ async function listSessions(
466
+ ctx: ExtensionCommandContext,
467
+ ): Promise<SessionInfoLike[] | null> {
468
+ if (!ctx.hasUI) {
469
+ const sessions = await SessionManager.list(ctx.cwd);
470
+ return sortSessions(sessions);
471
+ }
472
+
473
+ let loadError: string | undefined;
474
+ const sessions = await ctx.ui.custom<SessionInfoLike[] | null>(
475
+ (tui, theme, _kb, done) => {
476
+ const container = new Container();
477
+ const borderColor = (text: string) => theme.fg("border", text);
478
+
479
+ const loader = new CancellableLoader(
480
+ tui,
481
+ (text) => theme.fg("accent", text),
482
+ (text) => theme.fg("muted", text),
483
+ "Loading sessions...",
484
+ );
485
+
486
+ let settled = false;
487
+ const finish = (result: SessionInfoLike[] | null) => {
488
+ if (settled) return;
489
+ settled = true;
490
+ done(result);
491
+ };
492
+
493
+ loader.onAbort = () => finish(null);
494
+
495
+ container.addChild(new DynamicBorder(borderColor));
496
+ container.addChild(loader);
497
+ container.addChild(new Spacer(1));
498
+ container.addChild(
499
+ new Text(keyHint("tui.select.cancel", "cancel"), 1, 0),
500
+ );
501
+ container.addChild(new Spacer(1));
502
+ container.addChild(new DynamicBorder(borderColor));
503
+
504
+ const load = async () => {
505
+ try {
506
+ const results = await SessionManager.list(
507
+ ctx.cwd,
508
+ undefined,
509
+ (loaded, total) => {
510
+ if (total > 0) {
511
+ loader.setMessage(`Loading sessions (${loaded}/${total})...`);
512
+ }
513
+ },
514
+ );
515
+ finish(sortSessions(results));
516
+ } catch (error) {
517
+ loadError = error instanceof Error ? error.message : "Unknown error";
518
+ finish(null);
519
+ }
520
+ };
521
+
522
+ void load();
523
+
524
+ return {
525
+ render: (width) => container.render(width),
526
+ invalidate: () => container.invalidate(),
527
+ handleInput: (data) => {
528
+ loader.handleInput(data);
529
+ tui.requestRender();
530
+ },
531
+ };
532
+ },
533
+ );
534
+
535
+ if (loadError) {
536
+ ctx.ui.notify(`Failed to load sessions: ${loadError}`, "error");
537
+ }
538
+
539
+ return sessions;
540
+ }
541
+
542
+ async function showSessionPicker(
543
+ ctx: ExtensionCommandContext,
544
+ sessions: SessionInfoLike[],
545
+ maxVisible: number,
546
+ ): Promise<SessionInfoLike | null> {
547
+ let sorted = sessions;
548
+ let entries = buildSessionSearchEntries(sorted);
549
+ const sessionByPath = new Map(
550
+ sorted.map((session) => [session.path, session]),
551
+ );
552
+
553
+ return ctx.ui.custom<SessionInfoLike | null>(
554
+ (tui, theme, kb, done) => {
555
+ let filter = "";
556
+ let selectList: SelectList;
557
+ let filteredEntries = entries;
558
+ let selectedPath = sorted[0]?.path ?? "";
559
+ let previewScrollOffset = 0;
560
+ let toolsExpanded = false;
561
+ let thinkingVisible = false;
562
+ const previewCache = new Map<string, SessionPreview>();
563
+ let activePreview: SessionPreview | undefined;
564
+ let previewTimer: ReturnType<typeof setTimeout> | undefined;
565
+ let previewSeq = 0;
566
+ let statsController = new AbortController();
567
+ let statsGeneration = 0;
568
+ let workspaceLoadSeq = 0;
569
+ let disposed = false;
570
+ let settled = false;
571
+
572
+ let focus: "list" | "preview" = "list";
573
+ let showAllWorkspaces = false;
574
+ let isLoading = false;
575
+
576
+ const disposePicker = () => {
577
+ if (disposed) return;
578
+ disposed = true;
579
+ workspaceLoadSeq++;
580
+ statsGeneration++;
581
+ statsController.abort();
582
+ previewSeq++;
583
+ if (previewTimer) clearTimeout(previewTimer);
584
+ };
585
+ const finishPicker = (result: SessionInfoLike | null) => {
586
+ if (settled) return;
587
+ settled = true;
588
+ disposePicker();
589
+ done(result);
590
+ };
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();
607
+ });
608
+ };
609
+
610
+ triggerStatsLoad(sorted);
611
+
612
+ const loadWorkspaceSessions = async (allWorkspaces: boolean) => {
613
+ const loadSeq = ++workspaceLoadSeq;
614
+ cancelStatsLoad();
615
+ isLoading = true;
616
+ tui.requestRender();
617
+ try {
618
+ let results: SessionInfoLike[];
619
+ if (allWorkspaces) {
620
+ results = await SessionManager.listAll();
621
+ } else {
622
+ results = await SessionManager.list(ctx.cwd);
623
+ }
624
+ if (disposed || loadSeq !== workspaceLoadSeq) return;
625
+
626
+ sorted = sortSessions(results);
627
+ for (const s of sorted) {
628
+ sessionByPath.set(s.path, s);
629
+ }
630
+ entries = buildSessionSearchEntries(sorted);
631
+ triggerStatsLoad(sorted);
632
+
633
+ filteredEntries = filterSessionEntries(entries, filter);
634
+ const matchedIndex = filteredEntries.findIndex(
635
+ (entry) => entry.session.path === selectedPath,
636
+ );
637
+ if (matchedIndex >= 0) {
638
+ // Keep selection
639
+ } else {
640
+ selectedPath = filteredEntries[0]?.session.path ?? "";
641
+ }
642
+ rebuild();
643
+ schedulePreviewLoad();
644
+ } catch (error) {
645
+ if (disposed || loadSeq !== workspaceLoadSeq) return;
646
+ ctx.ui.notify(
647
+ `Failed to load sessions: ${error instanceof Error ? error.message : error}`,
648
+ "error",
649
+ );
650
+ } finally {
651
+ if (!disposed && loadSeq === workspaceLoadSeq) {
652
+ isLoading = false;
653
+ tui.requestRender();
654
+ }
655
+ }
656
+ };
657
+
658
+ const schedulePreviewLoad = () => {
659
+ previewScrollOffset = 0;
660
+ const session = sessionByPath.get(selectedPath);
661
+ if (!session) {
662
+ activePreview = undefined;
663
+ return;
664
+ }
665
+
666
+ const key = previewKey(session);
667
+ const cached = previewCache.get(key);
668
+ if (cached) {
669
+ activePreview = cached;
670
+ return;
671
+ }
672
+
673
+ activePreview = {
674
+ title: buildSessionLabel(session),
675
+ subtitle: `${session.modified.toLocaleString()} · loading preview`,
676
+ blocks: [{ kind: "notice", text: "Loading selected session…" }],
677
+ };
678
+
679
+ if (previewTimer) clearTimeout(previewTimer);
680
+ const seq = ++previewSeq;
681
+ 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
+ });
688
+ }, PREVIEW_LOAD_DEBOUNCE_MS);
689
+ };
690
+
691
+ const setSelectedPath = (path: string | undefined) => {
692
+ if (!path || path === selectedPath) return;
693
+ selectedPath = path;
694
+ schedulePreviewLoad();
695
+ };
696
+
697
+ const buildItems = (
698
+ current: typeof entries,
699
+ listWidth: number,
700
+ ): SelectItem[] =>
701
+ current.map((entry) => ({
702
+ value: entry.session.path,
703
+ label: formatItemLabel(entry.session, listWidth, theme, statsCache),
704
+ }));
705
+
706
+ const rebuild = () => {
707
+ filteredEntries = filterSessionEntries(entries, filter);
708
+ const matchedIndex = filteredEntries.findIndex(
709
+ (entry) => entry.session.path === selectedPath,
710
+ );
711
+ if (matchedIndex >= 0) {
712
+ // Keep selection
713
+ } else {
714
+ selectedPath = filteredEntries[0]?.session.path ?? "";
715
+ schedulePreviewLoad();
716
+ }
717
+ };
718
+
719
+ const renderSinglePane = (width: number): string[] => {
720
+ const container = new Container();
721
+ const filterLine = filter.length
722
+ ? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}`
723
+ : `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`;
724
+
725
+ container.addChild(
726
+ new DynamicBorder((text: string) => theme.fg("accent", text)),
727
+ );
728
+ container.addChild(
729
+ new Text(theme.fg("accent", theme.bold("Sessions")), 1, 0),
730
+ );
731
+ container.addChild(new Text(filterLine, 1, 0));
732
+ if (isLoading) {
733
+ container.addChild(new Spacer(1));
734
+ container.addChild(
735
+ new Text(theme.fg("muted", " Loading sessions..."), 1, 0),
736
+ );
737
+ container.addChild(new Spacer(1));
738
+ } 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
+ },
752
+ );
753
+ const selectedIndex = filteredEntries.findIndex(
754
+ (entry) => entry.session.path === selectedPath,
755
+ );
756
+ selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0);
757
+ selectList.onSelect = (item) =>
758
+ finishPicker(sessionByPath.get(item.value) ?? null);
759
+ selectList.onCancel = () => finishPicker(null);
760
+ selectList.onSelectionChange = (item) => setSelectedPath(item.value);
761
+ container.addChild(selectList);
762
+ }
763
+ container.addChild(
764
+ new Text(
765
+ theme.fg("dim", "↑↓ navigate • enter open • esc cancel"),
766
+ 1,
767
+ 0,
768
+ ),
769
+ );
770
+ container.addChild(
771
+ new DynamicBorder((text: string) => theme.fg("accent", text)),
772
+ );
773
+ return container.render(width);
774
+ };
775
+
776
+ const buildTopBorder = (
777
+ listWidth: number,
778
+ previewWidth: number,
779
+ ): string => {
780
+ const leftTitle = " Switch Thread ";
781
+ 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)}`;
784
+ };
785
+
786
+ const buildBottomBorder = (
787
+ listWidth: number,
788
+ previewWidth: number,
789
+ previewStats: string,
790
+ ): string => {
791
+ const help = showAllWorkspaces
792
+ ? " Opt+W/Ctrl+T current workspace · Esc close "
793
+ : " Opt+W/Ctrl+T all workspaces · Esc close ";
794
+ 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)}`;
797
+ };
798
+
799
+ const renderSplitPane = (width: number): string[] => {
800
+ const layout = getSessionPaneLayout(width);
801
+ const termRows = Math.max(12, tui.terminal?.rows ?? 24);
802
+ const contentHeight = Math.max(8, termRows - 2);
803
+ const filterLine = filter.length
804
+ ? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}`
805
+ : `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`;
806
+ const listHeight = Math.max(1, contentHeight - 1);
807
+
808
+ let leftLines: string[] = [];
809
+ if (isLoading) {
810
+ leftLines.push(filterLine);
811
+ leftLines.push("");
812
+ leftLines.push(theme.fg("muted", " Loading sessions..."));
813
+ } 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
+ },
830
+ );
831
+ const selectedIndex = filteredEntries.findIndex(
832
+ (entry) => entry.session.path === selectedPath,
833
+ );
834
+ selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0);
835
+ selectList.onSelect = (item) =>
836
+ finishPicker(sessionByPath.get(item.value) ?? null);
837
+ selectList.onCancel = () => finishPicker(null);
838
+ selectList.onSelectionChange = (item) => setSelectedPath(item.value);
839
+
840
+ leftLines = [
841
+ filterLine,
842
+ ...selectList.render(layout.listWidth),
843
+ ].slice(0, contentHeight);
844
+ }
845
+
846
+ while (leftLines.length < contentHeight) leftLines.push("");
847
+
848
+ const renderedPreview = renderPreview(
849
+ activePreview,
850
+ layout.previewWidth,
851
+ contentHeight,
852
+ previewScrollOffset,
853
+ theme,
854
+ {
855
+ toolsExpanded,
856
+ thinkingVisible,
857
+ },
858
+ focus,
859
+ );
860
+ previewScrollOffset = Math.min(
861
+ previewScrollOffset,
862
+ renderedPreview.maxScroll,
863
+ );
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} `;
869
+
870
+ const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)];
871
+ for (let i = 0; i < contentHeight; i++) {
872
+ const left = padAnsiRight(leftLines[i] ?? "", layout.listWidth);
873
+ const right =
874
+ renderedPreview.lines[i] ?? " ".repeat(layout.previewWidth);
875
+ lines.push(`${left}${theme.fg("border", " │ ")}${right}`);
876
+ }
877
+ lines.push(
878
+ buildBottomBorder(
879
+ layout.listWidth,
880
+ layout.previewWidth,
881
+ previewStats,
882
+ ),
883
+ );
884
+ return lines.map((line) => truncateToWidth(line, width, "", true));
885
+ };
886
+
887
+ rebuild();
888
+ schedulePreviewLoad();
889
+
890
+ return {
891
+ render: (width) => {
892
+ const layout = getSessionPaneLayout(width);
893
+ if (layout.mode === "single") return renderSinglePane(width);
894
+ return renderSplitPane(width);
895
+ },
896
+ invalidate: () => {
897
+ previewCache.clear();
898
+ rebuild();
899
+ },
900
+ dispose: disposePicker,
901
+ handleInput: (data) => {
902
+ if (data === "\u0014" || data === "\u001bw") {
903
+ showAllWorkspaces = !showAllWorkspaces;
904
+ void loadWorkspaceSessions(showAllWorkspaces);
905
+ return;
906
+ }
907
+
908
+ if (data === "\t") {
909
+ focus = focus === "list" ? "preview" : "list";
910
+ tui.requestRender();
911
+ return;
912
+ }
913
+ if (matchesKey(data, Key.left)) {
914
+ if (focus === "preview") {
915
+ focus = "list";
916
+ tui.requestRender();
917
+ }
918
+ return;
919
+ }
920
+ if (matchesKey(data, Key.right)) {
921
+ if (focus === "list") {
922
+ focus = "preview";
923
+ tui.requestRender();
924
+ }
925
+ return;
926
+ }
927
+
928
+ if (isLoading) return;
929
+
930
+ if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
931
+ if (filter.length > 0) {
932
+ filter = filter.slice(0, -1);
933
+ rebuild();
934
+ tui.requestRender();
935
+ }
936
+ return;
937
+ }
938
+
939
+ const currentLayout = getSessionPaneLayout(
940
+ tui.terminal?.columns ?? 80,
941
+ );
942
+ if (currentLayout.mode === "split" && focus === "preview") {
943
+ // t/h toggle the preview's tool/thinking visibility. They only
944
+ // belong to the preview pane; when the list is focused they must
945
+ // fall through to the filter (so queries containing t/h work).
946
+ if (data === "t") {
947
+ toolsExpanded = !toolsExpanded;
948
+ previewScrollOffset = 0;
949
+ tui.requestRender();
950
+ return;
951
+ }
952
+ if (data === "h") {
953
+ thinkingVisible = !thinkingVisible;
954
+ previewScrollOffset = 0;
955
+ tui.requestRender();
956
+ return;
957
+ }
958
+
959
+ const pageSize = Math.max(4, (tui.terminal?.rows ?? 24) - 4);
960
+ if (
961
+ matchesKey(data, Key.pageDown) ||
962
+ matchesKey(data, Key.ctrl("d"))
963
+ ) {
964
+ previewScrollOffset += pageSize;
965
+ tui.requestRender();
966
+ return;
967
+ }
968
+ if (
969
+ matchesKey(data, Key.pageUp) ||
970
+ matchesKey(data, Key.ctrl("u"))
971
+ ) {
972
+ previewScrollOffset = Math.max(0, previewScrollOffset - pageSize);
973
+ tui.requestRender();
974
+ return;
975
+ }
976
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.ctrl("j"))) {
977
+ previewScrollOffset++;
978
+ tui.requestRender();
979
+ return;
980
+ }
981
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.ctrl("k"))) {
982
+ previewScrollOffset = Math.max(0, previewScrollOffset - 1);
983
+ tui.requestRender();
984
+ return;
985
+ }
986
+ // Selection keys must work regardless of which pane has focus, so a
987
+ // user who scrolled the preview can still press Enter to open the
988
+ // highlighted session or Escape to cancel without tabbing back.
989
+ selectList.handleInput(data);
990
+ tui.requestRender();
991
+ return;
992
+ }
993
+
994
+ if (focus === "list") {
995
+ if (isPrintable(data)) {
996
+ filter += data;
997
+ rebuild();
998
+ tui.requestRender();
999
+ return;
1000
+ }
1001
+
1002
+ if (kb.matches(data, "tui.select.pageUp")) {
1003
+ const currentIndex = Math.max(
1004
+ 0,
1005
+ filteredEntries.findIndex(
1006
+ (entry) => entry.session.path === selectedPath,
1007
+ ),
1008
+ );
1009
+ const nextIndex = Math.max(0, currentIndex - maxVisible);
1010
+ selectList.setSelectedIndex(nextIndex);
1011
+ setSelectedPath(filteredEntries[nextIndex]?.session.path);
1012
+ tui.requestRender();
1013
+ return;
1014
+ }
1015
+
1016
+ if (kb.matches(data, "tui.select.pageDown")) {
1017
+ const currentIndex = Math.max(
1018
+ 0,
1019
+ filteredEntries.findIndex(
1020
+ (entry) => entry.session.path === selectedPath,
1021
+ ),
1022
+ );
1023
+ const nextIndex = Math.min(
1024
+ filteredEntries.length - 1,
1025
+ currentIndex + maxVisible,
1026
+ );
1027
+ selectList.setSelectedIndex(nextIndex);
1028
+ setSelectedPath(filteredEntries[nextIndex]?.session.path);
1029
+ tui.requestRender();
1030
+ return;
1031
+ }
1032
+
1033
+ selectList.handleInput(data);
1034
+ tui.requestRender();
1035
+ }
1036
+ },
1037
+ };
1038
+ },
1039
+ {
1040
+ overlay: true,
1041
+ overlayOptions: {
1042
+ anchor: "top-left",
1043
+ row: 0,
1044
+ col: 0,
1045
+ width: "100%",
1046
+ maxHeight: "100%",
1047
+ margin: 0,
1048
+ },
1049
+ },
1050
+ );
1051
+ }
1052
+
1053
+ async function runSessionsCommand(
1054
+ args: string | undefined,
1055
+ ctx: ExtensionCommandContext,
1056
+ ): Promise<void> {
1057
+ const limit = parseLimit(args, DEFAULT_VISIBLE);
1058
+ const sessions = await listSessions(ctx);
1059
+
1060
+ if (!sessions || sessions.length === 0) {
1061
+ if (ctx.hasUI) {
1062
+ ctx.ui.notify("No sessions found for this project.", "info");
1063
+ } else {
1064
+ console.log("No sessions found for this project.");
1065
+ }
1066
+ return;
1067
+ }
1068
+
1069
+ if (!ctx.hasUI) {
1070
+ for (const session of sessions) {
1071
+ console.log(formatPlainLine(session));
1072
+ }
1073
+ return;
1074
+ }
1075
+
1076
+ const selection = await showSessionPicker(ctx, sessions, limit);
1077
+ if (!selection) return;
1078
+
1079
+ const result = await ctx.switchSession(selection.path);
1080
+ if (result.cancelled) {
1081
+ ctx.ui.notify("Session switch cancelled.", "info");
1082
+ }
1083
+ }
1084
+
1085
+ export default function sessionsExtension(pi: ExtensionAPI) {
1086
+ pi.registerCommand("sessions", {
1087
+ description: "Pick a session from the current project",
1088
+ handler: async (args, ctx) => {
1089
+ await runSessionsCommand(args, ctx);
1090
+ },
1091
+ });
1092
+ }