@narumitw/pi-btw 0.58.0 → 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.
- package/README.md +2 -0
- package/dist/index.ts +296 -367
- package/dist/index.ts.map +3 -3
- package/package.json +52 -53
- package/src/bring-to-main.ts +494 -547
- package/src/btw.ts +813 -844
- package/src/fullscreen-ui.ts +673 -674
- package/src/keybindings.ts +228 -270
- package/src/main-tree-picker.ts +309 -318
- package/src/menu.ts +416 -454
- package/src/settings.ts +203 -219
- package/src/side-thread.ts +173 -201
- package/src/text.ts +21 -23
- package/src/transcript-markdown.ts +65 -0
- package/src/transcript-pager.ts +611 -662
package/src/main-tree-picker.ts
CHANGED
|
@@ -1,390 +1,381 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly selector: MainThreadTreeSelector,
|
|
39
|
+
private readonly onClose: () => void,
|
|
40
|
+
) {}
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
get focused(): boolean {
|
|
43
|
+
return this.selector.focused;
|
|
44
|
+
}
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
set focused(value: boolean) {
|
|
47
|
+
this.selector.focused = value;
|
|
48
|
+
}
|
|
52
49
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
get wantsKeyRelease(): boolean | undefined {
|
|
51
|
+
return this.selector.wantsKeyRelease;
|
|
52
|
+
}
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
render(width: number): string[] {
|
|
55
|
+
return this.selector.render(width);
|
|
56
|
+
}
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
invalidate(): void {
|
|
67
|
+
this.selector.invalidate();
|
|
68
|
+
}
|
|
72
69
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
dispose(): void {
|
|
71
|
+
this.selector.dispose?.();
|
|
72
|
+
this.onClose();
|
|
73
|
+
}
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
export async function pickMainEntry(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
pi: ExtensionAPI,
|
|
78
|
+
ctx: ExtensionCommandContext,
|
|
79
|
+
dependencies: MainThreadTreePickerDependencies = {},
|
|
83
80
|
): Promise<MainEntryPickResult> {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
if (rawTree.length === 0) {
|
|
91
|
+
notifySafely(ctx, "No main-thread entries are available", "warning");
|
|
92
|
+
return { kind: "back" };
|
|
93
|
+
}
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
184
|
+
return result ?? { kind: "closed" };
|
|
191
185
|
}
|
|
192
186
|
|
|
193
187
|
function createNativeTreeSelector(options: MainThreadTreeSelectorOptions): MainThreadTreeSelector {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
351
|
-
|
|
342
|
+
label: string | undefined;
|
|
343
|
+
labelTimestamp?: string;
|
|
352
344
|
}
|
|
353
345
|
|
|
354
346
|
function collectSavedLabels(tree: readonly SessionTreeNode[]): Map<string, SavedLabel> {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
362
|
+
signal.throwIfAborted();
|
|
363
|
+
await copyToClipboard(text);
|
|
364
|
+
signal.throwIfAborted();
|
|
374
365
|
}
|
|
375
366
|
|
|
376
367
|
function notifySafely(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
368
|
+
ctx: ExtensionCommandContext,
|
|
369
|
+
message: string,
|
|
370
|
+
level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
|
|
380
371
|
): void {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
380
|
+
return error instanceof Error ? error.message : String(error);
|
|
390
381
|
}
|