pi-sessions 0.12.1 → 0.13.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.
Files changed (35) hide show
  1. package/README.md +6 -3
  2. package/extensions/pi-sessions.ts +20 -6
  3. package/extensions/session-ask/install.ts +24 -10
  4. package/extensions/session-auto-title/generate.ts +17 -14
  5. package/extensions/session-auto-title/install.ts +6 -29
  6. package/extensions/session-auto-title/retitle.ts +6 -19
  7. package/extensions/session-auto-title/wizard.ts +97 -31
  8. package/extensions/session-handoff/board-loading.ts +93 -0
  9. package/extensions/session-handoff/board.ts +239 -127
  10. package/extensions/session-handoff/install.ts +5 -5
  11. package/extensions/session-handoff/picker.ts +167 -19
  12. package/extensions/session-handoff/review.ts +56 -7
  13. package/extensions/session-handoff/ui.ts +32 -7
  14. package/extensions/session-hooks/install.ts +7 -20
  15. package/extensions/session-index/install.ts +8 -86
  16. package/extensions/session-index/loader.ts +80 -0
  17. package/extensions/session-index/panel.ts +103 -0
  18. package/extensions/session-messaging/broker/spawn.ts +35 -12
  19. package/extensions/session-messaging/pi/reachable-tool.ts +6 -1
  20. package/extensions/session-messaging/pi/tools.ts +79 -12
  21. package/extensions/session-search/extract.ts +120 -91
  22. package/extensions/session-search/hooks.ts +25 -203
  23. package/extensions/session-search/reindex.ts +13 -8
  24. package/extensions/shared/legend.ts +116 -0
  25. package/extensions/shared/session-index/scoring.ts +0 -1
  26. package/extensions/shared/session-index/search.ts +76 -49
  27. package/extensions/shared/settings.ts +22 -0
  28. package/extensions/subagents/context-limit.ts +58 -0
  29. package/extensions/subagents/install.ts +110 -30
  30. package/extensions/subagents/reconcile.ts +58 -19
  31. package/extensions/subagents/report.ts +0 -2
  32. package/extensions/subagents/settle.ts +31 -0
  33. package/extensions/subagents/should-message.ts +59 -0
  34. package/package.json +13 -13
  35. package/extensions/session-handoff/refs.ts +0 -145
package/README.md CHANGED
@@ -34,7 +34,7 @@
34
34
 
35
35
  ## Install
36
36
 
37
- Requires Pi `0.82.1` or newer and Node `>=24 <26`.
37
+ Requires Pi `0.87.1` or newer and Node `>=24 <26`.
38
38
 
39
39
  **From npm** (recommended):
40
40
 
@@ -157,18 +157,21 @@ If you want to override the shortcut, put this in your `~/.pi/agent/settings.jso
157
157
 
158
158
  `deferred.copyToClipboard` (default `true`) controls whether deferred handoffs copy the resume command to the clipboard. When off, the resume command is only shown in the tool call.
159
159
 
160
- Subagents require the handoff and messaging features. Limit recursive delegation depth with `sessions.subagents.maxDepth` (default `2`):
160
+ Subagents require the handoff and messaging features. Limit recursive delegation depth with `sessions.subagents.maxDepth` (default `2`), or cap the context a subagent runs in with `sessions.subagents.contextLimit`:
161
161
 
162
162
  ```json
163
163
  {
164
164
  "sessions": {
165
165
  "subagents": {
166
- "maxDepth": 2
166
+ "maxDepth": 2,
167
+ "contextLimit": 400000
167
168
  }
168
169
  }
169
170
  }
170
171
  ```
171
172
 
173
+ `contextLimit` sets a max context limit before compaction is triggered. It works identically to setting a global value in pi itself, but only applies to subagents.
174
+
172
175
  ## Session Index
173
176
 
174
177
  By default, `pi-sessions` will start indexing all conversations moving forward. If you want to backfill all prior conversations:
@@ -17,7 +17,7 @@ import {
17
17
  import { installSearch } from "./session-search/install.ts";
18
18
  import type { SessionLifecycle } from "./shared/composition.ts";
19
19
  import { createSessionModelRuntime, type ModelRuntimeProvider } from "./shared/model-runtime.ts";
20
- import { loadSettings } from "./shared/settings.ts";
20
+ import { loadSettings, readCompactionSettings } from "./shared/settings.ts";
21
21
  import { installSubagents } from "./subagents/install.ts";
22
22
 
23
23
  /**
@@ -55,7 +55,7 @@ export default function piSessions(pi: ExtensionAPI): void {
55
55
  }
56
56
  const subagents =
57
57
  settings.features.subagents && messaging
58
- ? installSubagents(pi, { settings, messaging })
58
+ ? installSubagents(pi, { settings, messaging, readCompactionSettings })
59
59
  : undefined;
60
60
  if (subagents) {
61
61
  lifecycles.push(subagents);
@@ -63,11 +63,17 @@ export default function piSessions(pi: ExtensionAPI): void {
63
63
  if (messaging) {
64
64
  pi.registerTool(
65
65
  createSessionSendMessageTool(subagents ?? messaging, {
66
- wakeCapable: Boolean(subagents),
66
+ role: subagents ? { kind: "wakeCapable" } : { kind: "plain" },
67
67
  getCachedRelationTo: messaging.getCachedRelationTo,
68
+ ...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
69
+ }),
70
+ );
71
+ pi.registerTool(
72
+ createSessionCancelTool(subagents ?? messaging, {
73
+ role: { kind: "plain" },
74
+ ...(subagents ? { getParentSessionId: () => subagents.getParentSessionId() } : {}),
68
75
  }),
69
76
  );
70
- pi.registerTool(createSessionCancelTool(subagents ?? messaging));
71
77
  pi.registerTool(
72
78
  createSessionReachableTool({
73
79
  indexPath: index.path,
@@ -76,6 +82,7 @@ export default function piSessions(pi: ExtensionAPI): void {
76
82
  ...(subagents
77
83
  ? {
78
84
  listSubagents: async (scope) => (await subagents.roster.resolve(scope)).entries,
85
+ getParentSessionId: () => subagents.getParentSessionId(),
79
86
  }
80
87
  : {}),
81
88
  }),
@@ -104,7 +111,14 @@ export default function piSessions(pi: ExtensionAPI): void {
104
111
  installSearch(pi, { settings, index });
105
112
  }
106
113
  if (settings.features.ask) {
107
- installAsk(pi, { settings, index, getModelRuntime });
114
+ installAsk(pi, {
115
+ settings,
116
+ index,
117
+ getModelRuntime,
118
+ ...(subagents
119
+ ? { shouldMessageSubagent: (sessionId) => subagents.shouldMessageSubagent(sessionId) }
120
+ : {}),
121
+ });
108
122
  }
109
123
  if (settings.features.autoTitle) {
110
124
  lifecycles.push(
@@ -116,7 +130,7 @@ export default function piSessions(pi: ExtensionAPI): void {
116
130
  );
117
131
  }
118
132
  if (settings.features.hooks) {
119
- lifecycles.push(installHooks(pi, { settings, index }));
133
+ lifecycles.push(installHooks(pi, { index }));
120
134
  }
121
135
  // Messaging leads every session_start so the broker connection is registered before any
122
136
  // other feature hook runs. A side effect is that messaging's first relation snapshot can
@@ -29,6 +29,7 @@ export function installAsk(
29
29
  settings: SessionSettings;
30
30
  index: IndexHandle;
31
31
  getModelRuntime: ModelRuntimeProvider;
32
+ shouldMessageSubagent?: (sessionId: string) => Promise<boolean>;
32
33
  },
33
34
  ): void {
34
35
  const { settings } = deps;
@@ -37,15 +38,22 @@ export function installAsk(
37
38
  pi.registerTool({
38
39
  name: "session_ask",
39
40
  label: "Session Ask",
40
- description: "Interrogate a Pi session",
41
+ description: "Interrogate a Pi session transcript",
41
42
  promptSnippet: "Recall information, decisions, or reasoning from a Pi session by id",
42
- promptGuidelines: ["Use session_ask with focused questions rather than broad recap requests."],
43
+ promptGuidelines: [
44
+ "Use session_ask with focused questions rather than broad recap requests.",
45
+ ...(deps.shouldMessageSubagent
46
+ ? [
47
+ "Use session_send_message for your unfinished subagents; session_ask can read completed subagent transcripts.",
48
+ ]
49
+ : []),
50
+ ],
43
51
  parameters: Type.Object({
44
52
  session: Type.String({
45
53
  description: "Bare UUID for the session",
46
54
  }),
47
55
  question: Type.String({
48
- description: "What to extract, verify, or explain from that session",
56
+ description: "What to find in that session's transcript",
49
57
  }),
50
58
  }),
51
59
  async execute(_toolCallId, params: SessionAskToolParams, signal, onUpdate, ctx) {
@@ -59,6 +67,19 @@ export function installAsk(
59
67
  throw new Error("session_ask requires a question.");
60
68
  }
61
69
 
70
+ if (!isExactSessionId(sessionId)) {
71
+ throw new Error(
72
+ "session_ask requires an exact session UUID. Use autocomplete or session_search to find it.",
73
+ );
74
+ }
75
+
76
+ // Unfinished children may not have reached the index yet.
77
+ if (await deps.shouldMessageSubagent?.(sessionId)) {
78
+ throw new Error(
79
+ "That session is your unfinished subagent. Use session_send_message to ask it directly.",
80
+ );
81
+ }
82
+
62
83
  const resolvedTarget = resolveSessionAskTarget(sessionId, indexPath);
63
84
  if (!resolvedTarget.resolved) {
64
85
  throw new Error(resolvedTarget.error ?? "Unable to resolve session id.");
@@ -182,13 +203,6 @@ function resolveSessionAskTarget(
182
203
  resolved?: SessionLineageRow | undefined;
183
204
  error?: string | undefined;
184
205
  } {
185
- if (!isExactSessionId(sessionId)) {
186
- return {
187
- error:
188
- "session_ask requires an exact session UUID. Use autocomplete or session_search to find it.",
189
- };
190
- }
191
-
192
206
  try {
193
207
  return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db }) => {
194
208
  const row = getSessionById(db, sessionId);
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Api, Model, TextContent, UserMessage } from "@earendil-works/pi-ai";
3
- import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
4
4
  import type { AutoTitleContext } from "./context.ts";
5
5
  import { type AutoTitleRun, startAutoTitleRun } from "./runs.ts";
6
6
  import type { AutoTitleTrigger } from "./state.ts";
@@ -33,7 +33,7 @@ export type AutoTitleGenerationResult =
33
33
  };
34
34
 
35
35
  export async function generateAutoTitle(
36
- modelRuntime: ModelRuntime,
36
+ modelRegistry: ModelRegistry,
37
37
  model: Model<Api>,
38
38
  context: AutoTitleContext,
39
39
  trigger: AutoTitleTrigger,
@@ -78,18 +78,21 @@ export async function generateAutoTitle(
78
78
  : undefined;
79
79
 
80
80
  try {
81
- const response = await modelRuntime.completeSimple(
82
- model,
83
- {
84
- systemPrompt: resolvedSystemPrompt,
85
- messages: [message],
86
- },
87
- {
88
- maxTokens: generation.tokenBudget,
89
- ...(thinkingLevel && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
90
- signal: abortController.signal,
91
- },
92
- );
81
+ const response = await modelRegistry
82
+ .streamSimple(
83
+ model,
84
+ {
85
+ systemPrompt: resolvedSystemPrompt,
86
+ messages: [message],
87
+ },
88
+ {
89
+ maxTokens: generation.tokenBudget,
90
+ ...(thinkingLevel && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
91
+ signal: abortController.signal,
92
+ cacheRetention: "none",
93
+ },
94
+ )
95
+ .result();
93
96
  run?.recordResponse(response);
94
97
 
95
98
  if (response.stopReason === "error" || response.stopReason === "aborted") {
@@ -3,7 +3,6 @@ import type {
3
3
  ExtensionAPI,
4
4
  ExtensionCommandContext,
5
5
  ExtensionContext,
6
- ModelRuntime,
7
6
  TurnEndEvent,
8
7
  } from "@earendil-works/pi-coding-agent";
9
8
  import type { SessionLifecycle } from "../shared/composition.ts";
@@ -82,7 +81,6 @@ export function installAutoTitle(
82
81
  },
83
82
  },
84
83
  ctx,
85
- modelRuntime,
86
84
  resolution?.model,
87
85
  invocation,
88
86
  buildGeneration(resolution),
@@ -105,7 +103,6 @@ export function installAutoTitle(
105
103
  pi,
106
104
  controller,
107
105
  ctx,
108
- modelRuntime,
109
106
  model: resolution?.model,
110
107
  isManual: false,
111
108
  existingPlan: result.plan,
@@ -147,7 +144,6 @@ async function handleTitleInvocation(
147
144
  pi: ExtensionAPI,
148
145
  state: TitleRunState,
149
146
  ctx: ExtensionCommandContext,
150
- modelRuntime: ModelRuntime,
151
147
  model: Model<Api> | undefined,
152
148
  invocation: RetitleCommandInvocation,
153
149
  generation: AutoTitleGeneration,
@@ -156,7 +152,6 @@ async function handleTitleInvocation(
156
152
  pi,
157
153
  controller: state.controller,
158
154
  ctx,
159
- modelRuntime,
160
155
  model,
161
156
  isManual: true,
162
157
  generation,
@@ -178,15 +173,7 @@ async function handleTitleInvocation(
178
173
  return retitleCurrentSession();
179
174
  }
180
175
 
181
- return showRetitleWizard(
182
- pi,
183
- state.controller,
184
- ctx,
185
- modelRuntime,
186
- model,
187
- state.getSessionEpoch,
188
- generation,
189
- );
176
+ return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch, generation);
190
177
  }
191
178
 
192
179
  if (invocation.scope === "this") {
@@ -194,21 +181,12 @@ async function handleTitleInvocation(
194
181
  }
195
182
 
196
183
  if (isTuiMode(ctx) && !invocation.force) {
197
- return showRetitleWizard(
198
- pi,
199
- state.controller,
200
- ctx,
201
- modelRuntime,
202
- model,
203
- state.getSessionEpoch,
204
- generation,
205
- {
206
- initialInvocation: {
207
- scope: invocation.scope,
208
- mode: invocation.mode ?? "backfill",
209
- },
184
+ return showRetitleWizard(pi, state.controller, ctx, model, state.getSessionEpoch, generation, {
185
+ initialInvocation: {
186
+ scope: invocation.scope,
187
+ mode: invocation.mode ?? "backfill",
210
188
  },
211
- );
189
+ });
212
190
  }
213
191
 
214
192
  const scan = await buildRetitleScopeScan(ctx, invocation.scope);
@@ -218,7 +196,6 @@ async function handleTitleInvocation(
218
196
  pi,
219
197
  state.controller,
220
198
  ctx,
221
- modelRuntime,
222
199
  model,
223
200
  scan,
224
201
  mode,
@@ -3,7 +3,6 @@ import type {
3
3
  ExtensionAPI,
4
4
  ExtensionCommandContext,
5
5
  ExtensionContext,
6
- ModelRuntime,
7
6
  SessionInfo,
8
7
  } from "@earendil-works/pi-coding-agent";
9
8
  import { SessionManager } from "@earendil-works/pi-coding-agent";
@@ -55,7 +54,6 @@ export async function runBulkRetitle(
55
54
  pi: ExtensionAPI,
56
55
  controller: SessionAutoTitleController,
57
56
  ctx: ExtensionCommandContext,
58
- modelRuntime: ModelRuntime,
59
57
  model: Model<Api> | undefined,
60
58
  scan: RetitleScopeScan,
61
59
  mode: RetitleMode,
@@ -85,7 +83,6 @@ export async function runBulkRetitle(
85
83
  pi,
86
84
  controller,
87
85
  ctx,
88
- modelRuntime,
89
86
  model,
90
87
  isManual: true,
91
88
  generation,
@@ -96,7 +93,7 @@ export async function runBulkRetitle(
96
93
  continue;
97
94
  }
98
95
 
99
- const outcome = await retitleStoredSession(modelRuntime, model, session.path, generation);
96
+ const outcome = await retitleStoredSession(ctx.modelRegistry, model, session.path, generation);
100
97
  result[outcome] += 1;
101
98
  }
102
99
 
@@ -166,7 +163,6 @@ export interface RetitlePlanOptions {
166
163
  pi: ExtensionAPI;
167
164
  controller: SessionAutoTitleController;
168
165
  ctx: ExtensionContext;
169
- modelRuntime: ModelRuntime;
170
166
  model: Model<Api> | undefined;
171
167
  isManual: boolean;
172
168
  generation: AutoTitleGeneration;
@@ -178,17 +174,8 @@ export interface RetitlePlanOptions {
178
174
  export async function runRetitlePlan(
179
175
  options: RetitlePlanOptions,
180
176
  ): Promise<AutoTitleGenerationResult> {
181
- const {
182
- pi,
183
- controller,
184
- ctx,
185
- modelRuntime,
186
- model,
187
- isManual,
188
- generation,
189
- existingPlan,
190
- getSessionEpoch,
191
- } = options;
177
+ const { pi, controller, ctx, model, isManual, generation, existingPlan, getSessionEpoch } =
178
+ options;
192
179
  const notifyOnSuccess = options.notifyOnSuccess ?? isManual;
193
180
 
194
181
  const plan = existingPlan ?? controller.handleManualRetitle(ctx);
@@ -220,7 +207,7 @@ export async function runRetitlePlan(
220
207
  },
221
208
  );
222
209
  const generatedTitle = await generateAutoTitle(
223
- modelRuntime,
210
+ ctx.modelRegistry,
224
211
  model,
225
212
  titleContext,
226
213
  plan.reason,
@@ -267,7 +254,7 @@ export function persistAutoTitleState(
267
254
  }
268
255
 
269
256
  async function retitleStoredSession(
270
- modelRuntime: ModelRuntime,
257
+ modelRegistry: ExtensionContext["modelRegistry"],
271
258
  model: Model<Api>,
272
259
  sessionPath: string,
273
260
  generation: AutoTitleGeneration,
@@ -288,7 +275,7 @@ async function retitleStoredSession(
288
275
  currentTitle,
289
276
  };
290
277
  const generatedTitle = await generateAutoTitle(
291
- modelRuntime,
278
+ modelRegistry,
292
279
  model,
293
280
  titleContext,
294
281
  plan.reason,
@@ -3,11 +3,11 @@ import {
3
3
  copyToClipboard,
4
4
  type ExtensionAPI,
5
5
  type ExtensionCommandContext,
6
- type ModelRuntime,
7
6
  type Theme,
8
7
  } from "@earendil-works/pi-coding-agent";
9
- import type { Focusable, TUI } from "@earendil-works/pi-tui";
8
+ import type { Focusable, TUI, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
10
9
  import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { LegendPointer } from "../shared/legend.ts";
11
11
  import type { RetitleCommandOutcome, RetitleMode, RetitleScope } from "./command.ts";
12
12
  import type { AutoRetitleStatus, SessionAutoTitleController } from "./controller.ts";
13
13
  import type { AutoTitleFailure, AutoTitleGeneration } from "./generate.ts";
@@ -55,7 +55,6 @@ export async function showRetitleWizard(
55
55
  pi: ExtensionAPI,
56
56
  controller: SessionAutoTitleController,
57
57
  ctx: ExtensionCommandContext,
58
- modelRuntime: ModelRuntime,
59
58
  model: Model<Api> | undefined,
60
59
  getSessionEpoch: () => number,
61
60
  generation: AutoTitleGeneration,
@@ -69,7 +68,6 @@ export async function showRetitleWizard(
69
68
  pi,
70
69
  controller,
71
70
  ctx,
72
- modelRuntime,
73
71
  model,
74
72
  getSessionEpoch,
75
73
  generation,
@@ -90,6 +88,16 @@ export async function showRetitleWizard(
90
88
  class RetitleWizardPanel implements Focusable {
91
89
  focused = false;
92
90
 
91
+ private mouseTargets: Array<{
92
+ id: string;
93
+ row: number;
94
+ start: number;
95
+ end: number;
96
+ select: (() => void) | undefined;
97
+ run: () => void;
98
+ }> = [];
99
+ private readonly pointer: LegendPointer;
100
+ private pressedStep: RetitleWizardStep | undefined;
93
101
  private selectedIndex = 0;
94
102
  private step: RetitleWizardStep;
95
103
 
@@ -99,13 +107,13 @@ class RetitleWizardPanel implements Focusable {
99
107
  private readonly pi: ExtensionAPI,
100
108
  private readonly controller: SessionAutoTitleController,
101
109
  private readonly ctx: ExtensionCommandContext,
102
- private readonly modelRuntime: ModelRuntime,
103
110
  private readonly model: Model<Api> | undefined,
104
111
  private readonly getSessionEpoch: () => number,
105
112
  private readonly generation: AutoTitleGeneration,
106
113
  private readonly done: (result: RetitleCommandOutcome) => void,
107
114
  options?: RetitleWizardOptions,
108
115
  ) {
116
+ this.pointer = new LegendPointer(() => this.requestRender());
109
117
  this.step = options?.initialInvocation
110
118
  ? { kind: "scan", scope: options.initialInvocation.scope }
111
119
  : { kind: "scope" };
@@ -146,7 +154,29 @@ class RetitleWizardPanel implements Focusable {
146
154
  }
147
155
  }
148
156
 
157
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
158
+ const target = this.mouseTargets.find(
159
+ (target) => target.row === event.y && event.x >= target.start && event.x < target.end,
160
+ );
161
+ const result = this.pointer.handleMouse(event, target);
162
+ if (event.type === "press") {
163
+ this.pressedStep = this.step;
164
+ if (result?.handled) target?.select?.();
165
+ }
166
+ if (result) return result;
167
+ if (event.type === "wheel" && this.step.kind === "scope" && event.wheelDelta) {
168
+ this.selectedIndex = Math.max(
169
+ 0,
170
+ Math.min(2, this.selectedIndex + Math.sign(event.wheelDelta)),
171
+ );
172
+ this.requestRender();
173
+ return { handled: true };
174
+ }
175
+ return undefined;
176
+ }
177
+
149
178
  render(width: number): string[] {
179
+ this.mouseTargets = [];
150
180
  const innerWidth = Math.max(1, width - 2);
151
181
  const lines: string[] = [this.theme.fg("border", `╭${"─".repeat(innerWidth)}╮`)];
152
182
 
@@ -251,7 +281,6 @@ class RetitleWizardPanel implements Focusable {
251
281
  pi: this.pi,
252
282
  controller: this.controller,
253
283
  ctx: this.ctx,
254
- modelRuntime: this.modelRuntime,
255
284
  model: this.model,
256
285
  isManual: true,
257
286
  generation: this.generation,
@@ -310,7 +339,6 @@ class RetitleWizardPanel implements Focusable {
310
339
  this.pi,
311
340
  this.controller,
312
341
  this.ctx,
313
- this.modelRuntime,
314
342
  this.model,
315
343
  scan,
316
344
  mode,
@@ -361,20 +389,23 @@ class RetitleWizardPanel implements Focusable {
361
389
  );
362
390
  lines.push(this.renderRow(innerWidth, ""));
363
391
 
364
- for (const option of options) {
392
+ for (const [index, option] of options.entries()) {
365
393
  const prefix = option.selected ? this.theme.fg("accent", "›") : " ";
366
394
  const text = `${this.theme.bold(option.key)}${option.suffix}`;
367
395
  const label = option.selected ? this.theme.fg("accent", text) : text;
368
- lines.push(this.renderRow(innerWidth, ` ${prefix} ${label}`));
396
+ this.pushActionRow(lines, innerWidth, ` ${prefix} ${label}`, option.key, 2, () => {
397
+ this.selectedIndex = index;
398
+ this.requestRender();
399
+ });
369
400
  }
370
401
 
371
402
  if (failure) {
372
403
  lines.push(this.renderRow(innerWidth, ""));
373
- lines.push(
374
- this.renderRow(
375
- innerWidth,
376
- ` ${this.theme.fg("dim", this.theme.bold("y"))}${this.theme.fg("dim", " Copy last auto-title error")}`,
377
- ),
404
+ this.pushActionRow(
405
+ lines,
406
+ innerWidth,
407
+ ` ${this.theme.fg("dim", this.theme.bold("y"))}${this.theme.fg("dim", " Copy last auto-title error")}`,
408
+ "y",
378
409
  );
379
410
  }
380
411
  }
@@ -409,27 +440,27 @@ class RetitleWizardPanel implements Focusable {
409
440
  lines.push(this.renderRow(innerWidth, ""));
410
441
  lines.push(this.renderRow(innerWidth, ` ${message}`));
411
442
  lines.push(this.renderRow(innerWidth, ""));
412
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Enter")} close`));
413
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`));
443
+ this.pushActionRow(lines, innerWidth, ` ${this.theme.fg("dim", "Enter")} close`, "\r");
444
+ this.pushActionRow(lines, innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`, "\u001b");
414
445
  }
415
446
 
416
447
  private renderConfirmModeStep(lines: string[], innerWidth: number, scan: RetitleScopeScan): void {
417
448
  const title = scan.scope === "folder" ? "In This Folder" : "All of Pi";
418
449
  lines.push(this.renderRow(innerWidth, ` ${this.theme.bold(this.theme.fg("accent", title))}`));
419
450
  lines.push(this.renderRow(innerWidth, ""));
420
- lines.push(
421
- this.renderRow(
422
- innerWidth,
423
- ` ${this.theme.bold("Enter")} Generate missing titles (${scan.untitledCount})`,
424
- ),
451
+ this.pushActionRow(
452
+ lines,
453
+ innerWidth,
454
+ ` ${this.theme.bold("Enter")} Generate missing titles (${scan.untitledCount})`,
455
+ "\r",
425
456
  );
426
- lines.push(
427
- this.renderRow(
428
- innerWidth,
429
- ` ${this.theme.bold("a")} Regenerate all sessions, even ones that already have titles (${scan.totalCount})`,
430
- ),
457
+ this.pushActionRow(
458
+ lines,
459
+ innerWidth,
460
+ ` ${this.theme.bold("a")} Regenerate all sessions, even ones that already have titles (${scan.totalCount})`,
461
+ "a",
431
462
  );
432
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`));
463
+ this.pushActionRow(lines, innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`, "\u001b");
433
464
  }
434
465
 
435
466
  private renderWarningStep(lines: string[], innerWidth: number, sessionCount: number): void {
@@ -442,8 +473,8 @@ class RetitleWizardPanel implements Focusable {
442
473
  );
443
474
  lines.push(this.renderRow(innerWidth, " This may take some time and may be expensive."));
444
475
  lines.push(this.renderRow(innerWidth, ""));
445
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("accent", "Enter")} continue`));
446
- lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`));
476
+ this.pushActionRow(lines, innerWidth, ` ${this.theme.fg("accent", "Enter")} continue`, "\r");
477
+ this.pushActionRow(lines, innerWidth, ` ${this.theme.fg("dim", "Esc")} cancel`, "\u001b");
447
478
  }
448
479
 
449
480
  private renderRunningStep(lines: string[], innerWidth: number, message: string): void {
@@ -481,9 +512,44 @@ class RetitleWizardPanel implements Focusable {
481
512
  return this.renderRow(innerWidth, `${left}${" ".repeat(gap)}${right}`);
482
513
  }
483
514
 
515
+ private pushActionRow(
516
+ lines: string[],
517
+ innerWidth: number,
518
+ content: string,
519
+ key: string,
520
+ start = 1,
521
+ select?: () => void,
522
+ ): void {
523
+ const step = this.step;
524
+ const visible = visibleWidth(content) <= innerWidth;
525
+ if (visible) {
526
+ this.mouseTargets.push({
527
+ id: key,
528
+ row: lines.length,
529
+ start: start + 1,
530
+ end: visibleWidth(content) + 1,
531
+ select,
532
+ run: () => {
533
+ if (this.step === step) this.handleInput(key);
534
+ },
535
+ });
536
+ }
537
+ const pressed =
538
+ visible && !select && this.pressedStep === step && this.pointer.pressedId === key;
539
+ lines.push(
540
+ this.renderRow(
541
+ innerWidth,
542
+ pressed
543
+ ? content.slice(0, start) + this.theme.bg("selectedBg", content.slice(start))
544
+ : content,
545
+ ),
546
+ );
547
+ }
548
+
484
549
  private renderRow(innerWidth: number, content: string): string {
485
- const pad = Math.max(0, innerWidth - visibleWidth(content));
486
- return `${this.theme.fg("border", "│")}${content}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
550
+ const text = truncateToWidth(content, innerWidth, "…");
551
+ const pad = Math.max(0, innerWidth - visibleWidth(text));
552
+ return `${this.theme.fg("border", "│")}${text}${" ".repeat(pad)}${this.theme.fg("border", "│")}`;
487
553
  }
488
554
 
489
555
  private requestRender(): void {