@narumitw/pi-btw 0.58.1 → 0.59.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.
@@ -1,390 +1,381 @@
1
1
  import {
2
- copyToClipboard,
3
- type ExtensionAPI,
4
- type ExtensionCommandContext,
5
- type SessionEntry,
6
- type SessionTreeNode,
7
- TreeSelectorComponent,
2
+ copyToClipboard,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ type SessionEntry,
6
+ type SessionTreeNode,
7
+ TreeSelectorComponent,
8
8
  } from "@earendil-works/pi-coding-agent";
9
9
  import { type Component, type Focusable, Key, matchesKey } from "@earendil-works/pi-tui";
10
10
  import { showBtwCustomPreservingEditor } from "./menu.js";
11
11
  import { sanitizeSingleLine } from "./text.js";
12
12
 
13
- export type MainEntryPickResult =
14
- | { kind: "selected"; entryId: string }
15
- | { kind: "back" }
16
- | { kind: "closed" };
13
+ export type MainEntryPickResult = { kind: "selected"; entryId: string } | { kind: "back" } | { kind: "closed" };
17
14
 
18
15
  export interface MainThreadTreeSelector extends Component, Focusable {
19
- onCopy?: (text: string | undefined) => void;
20
- setViewLabel?(entryId: string, label: string | undefined, labelTimestamp?: string): void;
21
- dispose?(): void;
16
+ onCopy?: (text: string | undefined) => void;
17
+ setViewLabel?(entryId: string, label: string | undefined, labelTimestamp?: string): void;
18
+ dispose?(): void;
22
19
  }
23
20
 
24
21
  export interface MainThreadTreeSelectorOptions {
25
- tree: SessionTreeNode[];
26
- currentLeafId: string | null;
27
- terminalRows: number;
28
- onSelect: (entryId: string) => void;
29
- onCancel: () => void;
30
- onCopy: (entryId: string | undefined, displayText: string | undefined) => void;
31
- onLabelChange: (entryId: string, label: string | undefined) => void;
22
+ tree: SessionTreeNode[];
23
+ currentLeafId: string | null;
24
+ terminalRows: number;
25
+ onSelect: (entryId: string) => void;
26
+ onCancel: () => void;
27
+ onCopy: (entryId: string | undefined, displayText: string | undefined) => void;
28
+ onLabelChange: (entryId: string, label: string | undefined) => void;
32
29
  }
33
30
 
34
31
  export interface MainThreadTreePickerDependencies {
35
- createSelector?: (options: MainThreadTreeSelectorOptions) => MainThreadTreeSelector;
36
- copyToClipboard?: (text: string, signal: AbortSignal) => Promise<void>;
32
+ createSelector?: (options: MainThreadTreeSelectorOptions) => MainThreadTreeSelector;
33
+ copyToClipboard?: (text: string, signal: AbortSignal) => Promise<void>;
37
34
  }
38
35
 
39
36
  class MainThreadTreePickerComponent implements Component, Focusable {
40
- constructor(
41
- private readonly selector: MainThreadTreeSelector,
42
- private readonly onClose: () => void,
43
- ) {}
37
+ constructor(
38
+ private readonly selector: MainThreadTreeSelector,
39
+ private readonly onClose: () => void,
40
+ ) {}
44
41
 
45
- get focused(): boolean {
46
- return this.selector.focused;
47
- }
42
+ get focused(): boolean {
43
+ return this.selector.focused;
44
+ }
48
45
 
49
- set focused(value: boolean) {
50
- this.selector.focused = value;
51
- }
46
+ set focused(value: boolean) {
47
+ this.selector.focused = value;
48
+ }
52
49
 
53
- get wantsKeyRelease(): boolean | undefined {
54
- return this.selector.wantsKeyRelease;
55
- }
50
+ get wantsKeyRelease(): boolean | undefined {
51
+ return this.selector.wantsKeyRelease;
52
+ }
56
53
 
57
- render(width: number): string[] {
58
- return this.selector.render(width);
59
- }
54
+ render(width: number): string[] {
55
+ return this.selector.render(width);
56
+ }
60
57
 
61
- handleInput(data: string): void {
62
- if (matchesKey(data, Key.ctrl("c"))) {
63
- this.onClose();
64
- return;
65
- }
66
- this.selector.handleInput?.(data);
67
- }
58
+ handleInput(data: string): void {
59
+ if (matchesKey(data, Key.ctrl("c"))) {
60
+ this.onClose();
61
+ return;
62
+ }
63
+ this.selector.handleInput?.(data);
64
+ }
68
65
 
69
- invalidate(): void {
70
- this.selector.invalidate();
71
- }
66
+ invalidate(): void {
67
+ this.selector.invalidate();
68
+ }
72
69
 
73
- dispose(): void {
74
- this.selector.dispose?.();
75
- this.onClose();
76
- }
70
+ dispose(): void {
71
+ this.selector.dispose?.();
72
+ this.onClose();
73
+ }
77
74
  }
78
75
 
79
76
  export async function pickMainEntry(
80
- pi: ExtensionAPI,
81
- ctx: ExtensionCommandContext,
82
- dependencies: MainThreadTreePickerDependencies = {},
77
+ pi: ExtensionAPI,
78
+ ctx: ExtensionCommandContext,
79
+ dependencies: MainThreadTreePickerDependencies = {},
83
80
  ): Promise<MainEntryPickResult> {
84
- let rawTree: SessionTreeNode[];
85
- let currentLeafId: string | null;
86
- try {
87
- rawTree = ctx.sessionManager.getTree();
88
- currentLeafId = ctx.sessionManager.getLeafId();
89
- } catch {
90
- return { kind: "closed" };
91
- }
81
+ let rawTree: SessionTreeNode[];
82
+ let currentLeafId: string | null;
83
+ try {
84
+ rawTree = ctx.sessionManager.getTree();
85
+ currentLeafId = ctx.sessionManager.getLeafId();
86
+ } catch {
87
+ return { kind: "closed" };
88
+ }
92
89
 
93
- if (rawTree.length === 0) {
94
- notifySafely(ctx, "No main-thread entries are available", "warning");
95
- return { kind: "back" };
96
- }
90
+ if (rawTree.length === 0) {
91
+ notifySafely(ctx, "No main-thread entries are available", "warning");
92
+ return { kind: "back" };
93
+ }
97
94
 
98
- const tree = sanitizeTreeForDisplay(rawTree);
99
- const rawCopyText = collectRawCopyText(rawTree);
100
- const savedLabels = collectSavedLabels(rawTree);
101
- const createSelector = dependencies.createSelector ?? createNativeTreeSelector;
102
- const copy = dependencies.copyToClipboard ?? copyText;
103
- const copyControllers = new Set<AbortController>();
104
- const copyTasks = new Set<Promise<void>>();
105
- const abortCopies = () => {
106
- for (const controller of copyControllers) {
107
- controller.abort(new Error("The main-thread tree picker closed"));
108
- }
109
- };
110
- const result = await showBtwCustomPreservingEditor<MainEntryPickResult>(
111
- ctx,
112
- (tui, _theme, _keybindings, done) => {
113
- let settled = false;
114
- let selector: MainThreadTreeSelector | undefined;
115
- const finish = (value: MainEntryPickResult) => {
116
- if (settled) return;
117
- settled = true;
118
- abortCopies();
119
- done(value);
120
- };
121
- const onCopy = (entryId: string | undefined, displayText: string | undefined) => {
122
- if (settled) return;
123
- const text = entryId ? rawCopyText.get(entryId) : displayText;
124
- if (!text) {
125
- notifySafely(ctx, "Selected entry has no text to copy", "warning");
126
- return;
127
- }
128
- const controller = new AbortController();
129
- copyControllers.add(controller);
130
- let operation: Promise<void>;
131
- try {
132
- operation = copy(text, controller.signal);
133
- } catch (error: unknown) {
134
- operation = Promise.reject(error);
135
- }
136
- let task!: Promise<void>;
137
- task = operation
138
- .then(() => {
139
- if (!settled) notifySafely(ctx, "Copied selected message", "info");
140
- })
141
- .catch((error: unknown) => {
142
- if (!settled && !controller.signal.aborted) {
143
- notifySafely(ctx, `Could not copy selected message: ${formatError(error)}`, "error");
144
- }
145
- })
146
- .finally(() => {
147
- copyControllers.delete(controller);
148
- copyTasks.delete(task);
149
- });
150
- copyTasks.add(task);
151
- };
152
- const restoreLabel = (entryId: string) => {
153
- const previous = savedLabels.get(entryId);
154
- selector?.setViewLabel?.(entryId, previous?.label, previous?.labelTimestamp);
155
- tui.requestRender();
156
- };
157
- const onLabelChange = (entryId: string, label: string | undefined) => {
158
- if (settled) return;
159
- try {
160
- if (!ctx.sessionManager.getEntry(entryId)) {
161
- restoreLabel(entryId);
162
- notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
163
- return;
164
- }
165
- const persistedLabel = label === undefined ? undefined : sanitizeSingleLine(label);
166
- pi.setLabel(entryId, persistedLabel);
167
- savedLabels.set(entryId, { label: persistedLabel });
168
- selector?.setViewLabel?.(entryId, persistedLabel);
169
- tui.requestRender();
170
- } catch (error: unknown) {
171
- restoreLabel(entryId);
172
- notifySafely(ctx, `Could not update tree label: ${formatError(error)}`, "error");
173
- }
174
- };
175
- selector = createSelector({
176
- tree,
177
- currentLeafId,
178
- terminalRows: tui.terminal.rows,
179
- onSelect: (entryId) => finish({ kind: "selected", entryId }),
180
- onCancel: () => finish({ kind: "back" }),
181
- onCopy,
182
- onLabelChange,
183
- });
184
- return new MainThreadTreePickerComponent(selector, () => finish({ kind: "closed" }));
185
- },
186
- );
187
- abortCopies();
188
- await Promise.allSettled([...copyTasks]);
95
+ const tree = sanitizeTreeForDisplay(rawTree);
96
+ const rawCopyText = collectRawCopyText(rawTree);
97
+ const savedLabels = collectSavedLabels(rawTree);
98
+ const createSelector = dependencies.createSelector ?? createNativeTreeSelector;
99
+ const copy = dependencies.copyToClipboard ?? copyText;
100
+ const copyControllers = new Set<AbortController>();
101
+ const copyTasks = new Set<Promise<void>>();
102
+ const abortCopies = () => {
103
+ for (const controller of copyControllers) {
104
+ controller.abort(new Error("The main-thread tree picker closed"));
105
+ }
106
+ };
107
+ const result = await showBtwCustomPreservingEditor<MainEntryPickResult>(ctx, (tui, _theme, _keybindings, done) => {
108
+ let settled = false;
109
+ let selector: MainThreadTreeSelector | undefined;
110
+ const finish = (value: MainEntryPickResult) => {
111
+ if (settled) return;
112
+ settled = true;
113
+ abortCopies();
114
+ done(value);
115
+ };
116
+ const onCopy = (entryId: string | undefined, displayText: string | undefined) => {
117
+ if (settled) return;
118
+ const text = entryId ? rawCopyText.get(entryId) : displayText;
119
+ if (!text) {
120
+ notifySafely(ctx, "Selected entry has no text to copy", "warning");
121
+ return;
122
+ }
123
+ const controller = new AbortController();
124
+ copyControllers.add(controller);
125
+ let operation: Promise<void>;
126
+ try {
127
+ operation = copy(text, controller.signal);
128
+ } catch (error: unknown) {
129
+ operation = Promise.reject(error);
130
+ }
131
+ let task!: Promise<void>;
132
+ task = operation
133
+ .then(() => {
134
+ if (!settled) notifySafely(ctx, "Copied selected message", "info");
135
+ })
136
+ .catch((error: unknown) => {
137
+ if (!settled && !controller.signal.aborted) {
138
+ notifySafely(ctx, `Could not copy selected message: ${formatError(error)}`, "error");
139
+ }
140
+ })
141
+ .finally(() => {
142
+ copyControllers.delete(controller);
143
+ copyTasks.delete(task);
144
+ });
145
+ copyTasks.add(task);
146
+ };
147
+ const restoreLabel = (entryId: string) => {
148
+ const previous = savedLabels.get(entryId);
149
+ selector?.setViewLabel?.(entryId, previous?.label, previous?.labelTimestamp);
150
+ tui.requestRender();
151
+ };
152
+ const onLabelChange = (entryId: string, label: string | undefined) => {
153
+ if (settled) return;
154
+ try {
155
+ if (!ctx.sessionManager.getEntry(entryId)) {
156
+ restoreLabel(entryId);
157
+ notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
158
+ return;
159
+ }
160
+ const persistedLabel = label === undefined ? undefined : sanitizeSingleLine(label);
161
+ pi.setLabel(entryId, persistedLabel);
162
+ savedLabels.set(entryId, { label: persistedLabel });
163
+ selector?.setViewLabel?.(entryId, persistedLabel);
164
+ tui.requestRender();
165
+ } catch (error: unknown) {
166
+ restoreLabel(entryId);
167
+ notifySafely(ctx, `Could not update tree label: ${formatError(error)}`, "error");
168
+ }
169
+ };
170
+ selector = createSelector({
171
+ tree,
172
+ currentLeafId,
173
+ terminalRows: tui.terminal.rows,
174
+ onSelect: (entryId) => finish({ kind: "selected", entryId }),
175
+ onCancel: () => finish({ kind: "back" }),
176
+ onCopy,
177
+ onLabelChange,
178
+ });
179
+ return new MainThreadTreePickerComponent(selector, () => finish({ kind: "closed" }));
180
+ });
181
+ abortCopies();
182
+ await Promise.allSettled([...copyTasks]);
189
183
 
190
- return result ?? { kind: "closed" };
184
+ return result ?? { kind: "closed" };
191
185
  }
192
186
 
193
187
  function createNativeTreeSelector(options: MainThreadTreeSelectorOptions): MainThreadTreeSelector {
194
- const selector = new TreeSelectorComponent(
195
- options.tree,
196
- options.currentLeafId,
197
- options.terminalRows,
198
- options.onSelect,
199
- options.onCancel,
200
- options.onLabelChange,
201
- );
202
- selector.onCopy = (displayText) =>
203
- options.onCopy(selector.getTreeList().getSelectedNode()?.entry.id, displayText);
204
- const result = selector as MainThreadTreeSelector;
205
- result.setViewLabel = (entryId, label, labelTimestamp) =>
206
- selector.getTreeList().updateNodeLabel(entryId, label, labelTimestamp);
207
- return result;
188
+ const selector = new TreeSelectorComponent(
189
+ options.tree,
190
+ options.currentLeafId,
191
+ options.terminalRows,
192
+ options.onSelect,
193
+ options.onCancel,
194
+ options.onLabelChange,
195
+ );
196
+ selector.onCopy = (displayText) => options.onCopy(selector.getTreeList().getSelectedNode()?.entry.id, displayText);
197
+ const result = selector as MainThreadTreeSelector;
198
+ result.setViewLabel = (entryId, label, labelTimestamp) =>
199
+ selector.getTreeList().updateNodeLabel(entryId, label, labelTimestamp);
200
+ return result;
208
201
  }
209
202
 
210
203
  function sanitizeTreeForDisplay(tree: readonly SessionTreeNode[]): SessionTreeNode[] {
211
- return tree.map((node) => {
212
- const result: SessionTreeNode = {
213
- entry: sanitizeEntryForDisplay(node.entry),
214
- children: sanitizeTreeForDisplay(node.children),
215
- };
216
- if (node.label !== undefined) result.label = sanitizeSingleLine(node.label);
217
- if (node.labelTimestamp !== undefined) {
218
- result.labelTimestamp = sanitizeSingleLine(node.labelTimestamp);
219
- }
220
- return result;
221
- });
204
+ return tree.map((node) => {
205
+ const result: SessionTreeNode = {
206
+ entry: sanitizeEntryForDisplay(node.entry),
207
+ children: sanitizeTreeForDisplay(node.children),
208
+ };
209
+ if (node.label !== undefined) result.label = sanitizeSingleLine(node.label);
210
+ if (node.labelTimestamp !== undefined) {
211
+ result.labelTimestamp = sanitizeSingleLine(node.labelTimestamp);
212
+ }
213
+ return result;
214
+ });
222
215
  }
223
216
 
224
217
  function sanitizeEntryForDisplay(entry: SessionEntry): SessionEntry {
225
- switch (entry.type) {
226
- case "message": {
227
- const message = { ...entry.message } as Record<string, unknown>;
228
- if ("content" in entry.message)
229
- message.content = sanitizeDisplayContent(entry.message.content);
230
- for (const key of ["role", "errorMessage", "command", "toolName"] as const) {
231
- const value = message[key];
232
- if (typeof value === "string") message[key] = sanitizeSingleLine(value);
233
- }
234
- return { ...entry, message } as unknown as SessionEntry;
235
- }
236
- case "custom_message":
237
- return {
238
- ...entry,
239
- customType: sanitizeSingleLine(entry.customType),
240
- content: sanitizeDisplayContent(entry.content) as typeof entry.content,
241
- };
242
- case "compaction":
243
- return { ...entry, summary: sanitizeSingleLine(entry.summary) };
244
- case "branch_summary":
245
- return { ...entry, summary: sanitizeSingleLine(entry.summary) };
246
- case "model_change":
247
- return {
248
- ...entry,
249
- provider: sanitizeSingleLine(entry.provider),
250
- modelId: sanitizeSingleLine(entry.modelId),
251
- };
252
- case "thinking_level_change":
253
- return { ...entry, thinkingLevel: sanitizeSingleLine(entry.thinkingLevel) };
254
- case "custom":
255
- return { ...entry, customType: sanitizeSingleLine(entry.customType) };
256
- case "label":
257
- return {
258
- ...entry,
259
- label: entry.label === undefined ? undefined : sanitizeSingleLine(entry.label),
260
- };
261
- case "session_info":
262
- return {
263
- ...entry,
264
- name: entry.name === undefined ? undefined : sanitizeSingleLine(entry.name),
265
- };
266
- }
218
+ switch (entry.type) {
219
+ case "message": {
220
+ const message = { ...entry.message } as Record<string, unknown>;
221
+ if ("content" in entry.message) message.content = sanitizeDisplayContent(entry.message.content);
222
+ for (const key of ["role", "errorMessage", "command", "toolName"] as const) {
223
+ const value = message[key];
224
+ if (typeof value === "string") message[key] = sanitizeSingleLine(value);
225
+ }
226
+ return { ...entry, message } as unknown as SessionEntry;
227
+ }
228
+ case "custom_message":
229
+ return {
230
+ ...entry,
231
+ customType: sanitizeSingleLine(entry.customType),
232
+ content: sanitizeDisplayContent(entry.content) as typeof entry.content,
233
+ };
234
+ case "compaction":
235
+ return { ...entry, summary: sanitizeSingleLine(entry.summary) };
236
+ case "branch_summary":
237
+ return { ...entry, summary: sanitizeSingleLine(entry.summary) };
238
+ case "model_change":
239
+ return {
240
+ ...entry,
241
+ provider: sanitizeSingleLine(entry.provider),
242
+ modelId: sanitizeSingleLine(entry.modelId),
243
+ };
244
+ case "thinking_level_change":
245
+ return { ...entry, thinkingLevel: sanitizeSingleLine(entry.thinkingLevel) };
246
+ case "custom":
247
+ return { ...entry, customType: sanitizeSingleLine(entry.customType) };
248
+ case "label":
249
+ return {
250
+ ...entry,
251
+ label: entry.label === undefined ? undefined : sanitizeSingleLine(entry.label),
252
+ };
253
+ case "session_info":
254
+ return {
255
+ ...entry,
256
+ name: entry.name === undefined ? undefined : sanitizeSingleLine(entry.name),
257
+ };
258
+ }
267
259
  }
268
260
 
269
261
  function sanitizeDisplayContent(content: unknown): unknown {
270
- if (typeof content === "string") return sanitizeSingleLine(content);
271
- if (!Array.isArray(content)) return content;
272
- return content.map((block) => {
273
- if (block === null || typeof block !== "object" || !("type" in block)) return block;
274
- if (block.type === "text" && "text" in block && typeof block.text === "string") {
275
- return { ...block, text: sanitizeSingleLine(block.text) };
276
- }
277
- if (block.type === "toolCall") {
278
- const copy = { ...block } as Record<string, unknown>;
279
- if (typeof copy.name === "string") copy.name = sanitizeSingleLine(copy.name);
280
- copy.arguments = sanitizeToolArguments(copy.arguments, new WeakMap());
281
- return copy;
282
- }
283
- return block;
284
- });
262
+ if (typeof content === "string") return sanitizeSingleLine(content);
263
+ if (!Array.isArray(content)) return content;
264
+ return content.map((block) => {
265
+ if (block === null || typeof block !== "object" || !("type" in block)) return block;
266
+ if (block.type === "text" && "text" in block && typeof block.text === "string") {
267
+ return { ...block, text: sanitizeSingleLine(block.text) };
268
+ }
269
+ if (block.type === "toolCall") {
270
+ const copy = { ...block } as Record<string, unknown>;
271
+ if (typeof copy.name === "string") copy.name = sanitizeSingleLine(copy.name);
272
+ copy.arguments = sanitizeToolArguments(copy.arguments, new WeakMap());
273
+ return copy;
274
+ }
275
+ return block;
276
+ });
285
277
  }
286
278
 
287
279
  function sanitizeToolArguments(value: unknown, seen: WeakMap<object, unknown>): unknown {
288
- if (typeof value === "string") return sanitizeSingleLine(value);
289
- if (value === null || typeof value !== "object") return value;
290
- const existing = seen.get(value);
291
- if (existing !== undefined) return existing;
292
- if (Array.isArray(value)) {
293
- const result: unknown[] = [];
294
- seen.set(value, result);
295
- for (const item of value) result.push(sanitizeToolArguments(item, seen));
296
- return result;
297
- }
298
- const result: Record<string, unknown> = {};
299
- seen.set(value, result);
300
- for (const [key, item] of Object.entries(value)) {
301
- result[key] = sanitizeToolArguments(item, seen);
302
- }
303
- return result;
280
+ if (typeof value === "string") return sanitizeSingleLine(value);
281
+ if (value === null || typeof value !== "object") return value;
282
+ const existing = seen.get(value);
283
+ if (existing !== undefined) return existing;
284
+ if (Array.isArray(value)) {
285
+ const result: unknown[] = [];
286
+ seen.set(value, result);
287
+ for (const item of value) result.push(sanitizeToolArguments(item, seen));
288
+ return result;
289
+ }
290
+ const result: Record<string, unknown> = {};
291
+ seen.set(value, result);
292
+ for (const [key, item] of Object.entries(value)) {
293
+ result[key] = sanitizeToolArguments(item, seen);
294
+ }
295
+ return result;
304
296
  }
305
297
 
306
298
  function collectRawCopyText(tree: readonly SessionTreeNode[]): Map<string, string> {
307
- const result = new Map<string, string>();
308
- const visit = (nodes: readonly SessionTreeNode[]) => {
309
- for (const node of nodes) {
310
- const text = getRawCopyText(node.entry);
311
- if (text !== undefined) result.set(node.entry.id, text);
312
- visit(node.children);
313
- }
314
- };
315
- visit(tree);
316
- return result;
299
+ const result = new Map<string, string>();
300
+ const visit = (nodes: readonly SessionTreeNode[]) => {
301
+ for (const node of nodes) {
302
+ const text = getRawCopyText(node.entry);
303
+ if (text !== undefined) result.set(node.entry.id, text);
304
+ visit(node.children);
305
+ }
306
+ };
307
+ visit(tree);
308
+ return result;
317
309
  }
318
310
 
319
311
  function getRawCopyText(entry: SessionEntry): string | undefined {
320
- let text: string | undefined;
321
- if (entry.type === "message") {
322
- if (entry.message.role === "bashExecution") text = entry.message.command;
323
- else if ("content" in entry.message) {
324
- text = extractRawText(entry.message.content);
325
- if (!text && entry.message.role === "assistant") text = entry.message.errorMessage;
326
- }
327
- } else if (entry.type === "custom_message") text = extractRawText(entry.content);
328
- else if (entry.type === "compaction" || entry.type === "branch_summary") text = entry.summary;
329
- return text?.trim() ? text : undefined;
312
+ let text: string | undefined;
313
+ if (entry.type === "message") {
314
+ if (entry.message.role === "bashExecution") text = entry.message.command;
315
+ else if ("content" in entry.message) {
316
+ text = extractRawText(entry.message.content);
317
+ if (!text && entry.message.role === "assistant") text = entry.message.errorMessage;
318
+ }
319
+ } else if (entry.type === "custom_message") text = extractRawText(entry.content);
320
+ else if (entry.type === "compaction" || entry.type === "branch_summary") text = entry.summary;
321
+ return text?.trim() ? text : undefined;
330
322
  }
331
323
 
332
324
  function extractRawText(content: unknown): string {
333
- if (typeof content === "string") return content;
334
- if (!Array.isArray(content)) return "";
335
- return content
336
- .filter(
337
- (block): block is { type: "text"; text: string } =>
338
- block !== null &&
339
- typeof block === "object" &&
340
- "type" in block &&
341
- block.type === "text" &&
342
- "text" in block &&
343
- typeof block.text === "string",
344
- )
345
- .map((block) => block.text)
346
- .join("");
325
+ if (typeof content === "string") return content;
326
+ if (!Array.isArray(content)) return "";
327
+ return content
328
+ .filter(
329
+ (block): block is { type: "text"; text: string } =>
330
+ block !== null &&
331
+ typeof block === "object" &&
332
+ "type" in block &&
333
+ block.type === "text" &&
334
+ "text" in block &&
335
+ typeof block.text === "string",
336
+ )
337
+ .map((block) => block.text)
338
+ .join("");
347
339
  }
348
340
 
349
341
  interface SavedLabel {
350
- label: string | undefined;
351
- labelTimestamp?: string;
342
+ label: string | undefined;
343
+ labelTimestamp?: string;
352
344
  }
353
345
 
354
346
  function collectSavedLabels(tree: readonly SessionTreeNode[]): Map<string, SavedLabel> {
355
- const result = new Map<string, SavedLabel>();
356
- const visit = (nodes: readonly SessionTreeNode[]) => {
357
- for (const node of nodes) {
358
- result.set(node.entry.id, {
359
- label: node.label === undefined ? undefined : sanitizeSingleLine(node.label),
360
- labelTimestamp:
361
- node.labelTimestamp === undefined ? undefined : sanitizeSingleLine(node.labelTimestamp),
362
- });
363
- visit(node.children);
364
- }
365
- };
366
- visit(tree);
367
- return result;
347
+ const result = new Map<string, SavedLabel>();
348
+ const visit = (nodes: readonly SessionTreeNode[]) => {
349
+ for (const node of nodes) {
350
+ result.set(node.entry.id, {
351
+ label: node.label === undefined ? undefined : sanitizeSingleLine(node.label),
352
+ labelTimestamp: node.labelTimestamp === undefined ? undefined : sanitizeSingleLine(node.labelTimestamp),
353
+ });
354
+ visit(node.children);
355
+ }
356
+ };
357
+ visit(tree);
358
+ return result;
368
359
  }
369
360
 
370
361
  async function copyText(text: string, signal: AbortSignal): Promise<void> {
371
- signal.throwIfAborted();
372
- await copyToClipboard(text);
373
- signal.throwIfAborted();
362
+ signal.throwIfAborted();
363
+ await copyToClipboard(text);
364
+ signal.throwIfAborted();
374
365
  }
375
366
 
376
367
  function notifySafely(
377
- ctx: ExtensionCommandContext,
378
- message: string,
379
- level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
368
+ ctx: ExtensionCommandContext,
369
+ message: string,
370
+ level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
380
371
  ): void {
381
- try {
382
- ctx.ui.notify(sanitizeSingleLine(message), level);
383
- } catch {
384
- // The command context may have been replaced while the selector was open.
385
- }
372
+ try {
373
+ ctx.ui.notify(sanitizeSingleLine(message), level);
374
+ } catch {
375
+ // The command context may have been replaced while the selector was open.
376
+ }
386
377
  }
387
378
 
388
379
  function formatError(error: unknown): string {
389
- return error instanceof Error ? error.message : String(error);
380
+ return error instanceof Error ? error.message : String(error);
390
381
  }