@narumitw/pi-btw 0.42.1 → 0.46.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 +45 -24
- package/package.json +1 -1
- package/src/btw.ts +196 -159
- package/src/menu.ts +256 -0
- package/src/settings.ts +237 -0
- package/src/text.ts +10 -0
- package/src/transcript-pager.ts +88 -11
package/src/menu.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
KeybindingsManager,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
7
|
+
import { defineMenu, type MenuContext, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
|
|
8
|
+
import {
|
|
9
|
+
type BtwSettings,
|
|
10
|
+
btwSettingsPath,
|
|
11
|
+
effectiveRememberThinkingLevelChanges,
|
|
12
|
+
readBtwSettings,
|
|
13
|
+
type UpdateBtwSettingsOptions,
|
|
14
|
+
updateBtwSettings,
|
|
15
|
+
} from "./settings.js";
|
|
16
|
+
import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
|
|
17
|
+
import { sanitizeSingleLine } from "./text.js";
|
|
18
|
+
|
|
19
|
+
interface BtwMenuState {
|
|
20
|
+
kind: "valid" | "invalid";
|
|
21
|
+
settings: BtwSettings;
|
|
22
|
+
reason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ShowBtwCommandMenuOptions {
|
|
26
|
+
currentThinkingLevel: BtwThinkingLevel;
|
|
27
|
+
availableThinkingLevels: readonly BtwThinkingLevel[];
|
|
28
|
+
settingsPath?: string;
|
|
29
|
+
readSettings?: typeof readBtwSettings;
|
|
30
|
+
updateSettings?: (
|
|
31
|
+
patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
|
|
32
|
+
options: UpdateBtwSettingsOptions,
|
|
33
|
+
) => Promise<BtwSettings>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type BtwCommandMenuResult = "start" | "closed";
|
|
37
|
+
|
|
38
|
+
type BtwMenuScreen = "main" | "settings" | "invalid";
|
|
39
|
+
type BtwMenuAction = "start" | "set-thinking" | "set-remember";
|
|
40
|
+
type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
|
|
41
|
+
|
|
42
|
+
type BtwCustomFactory<T> = (
|
|
43
|
+
tui: TUI,
|
|
44
|
+
theme: Theme,
|
|
45
|
+
keybindings: KeybindingsManager,
|
|
46
|
+
done: (result: T) => void,
|
|
47
|
+
) => Component;
|
|
48
|
+
|
|
49
|
+
export async function showBtwCommandMenu(
|
|
50
|
+
ctx: ExtensionCommandContext,
|
|
51
|
+
options: ShowBtwCommandMenuOptions,
|
|
52
|
+
): Promise<BtwCommandMenuResult> {
|
|
53
|
+
if (ctx.mode !== "tui") return "closed";
|
|
54
|
+
const settingsPath = options.settingsPath ?? btwSettingsPath();
|
|
55
|
+
const readSettings = options.readSettings ?? readBtwSettings;
|
|
56
|
+
const updateSettings = options.updateSettings ?? updateBtwSettings;
|
|
57
|
+
const levels =
|
|
58
|
+
options.availableThinkingLevels.length > 0
|
|
59
|
+
? [...options.availableThinkingLevels]
|
|
60
|
+
: (["off"] satisfies BtwThinkingLevel[]);
|
|
61
|
+
const displaySettingsPath = sanitizeSingleLine(settingsPath);
|
|
62
|
+
let startSelected = false;
|
|
63
|
+
|
|
64
|
+
const loadState = async (): Promise<BtwMenuState> => {
|
|
65
|
+
const loaded = await readSettings(settingsPath);
|
|
66
|
+
if (loaded.kind === "invalid") {
|
|
67
|
+
return { kind: "invalid", settings: {}, reason: loaded.reason };
|
|
68
|
+
}
|
|
69
|
+
return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
|
|
70
|
+
};
|
|
71
|
+
const displayThinkingLevel = (settings: BtwSettings): BtwThinkingLevel =>
|
|
72
|
+
clampToAvailableThinkingLevel(settings.thinkingLevel ?? options.currentThinkingLevel, levels);
|
|
73
|
+
|
|
74
|
+
const menu = defineMenu<BtwMenuState, BtwMenuScreen, BtwMenuAction, MenuContext>({
|
|
75
|
+
start: "main",
|
|
76
|
+
screens: {
|
|
77
|
+
main: ({ state }) => ({
|
|
78
|
+
kind: "actions",
|
|
79
|
+
title: "Pi BTW",
|
|
80
|
+
lines: [
|
|
81
|
+
`Thinking: ${displayThinkingLevel(state.settings)} · Remember changes: ${effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off"}`,
|
|
82
|
+
],
|
|
83
|
+
items: [
|
|
84
|
+
{
|
|
85
|
+
id: "start",
|
|
86
|
+
label: "Start side thread",
|
|
87
|
+
description: "Open an empty side thread",
|
|
88
|
+
action: "start",
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "settings",
|
|
92
|
+
label: "Settings",
|
|
93
|
+
description: "Choose pi-btw thinking and whether shortcut changes are remembered",
|
|
94
|
+
to: state.kind === "invalid" ? "invalid" : "settings",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
hint: "close",
|
|
98
|
+
}),
|
|
99
|
+
settings: ({ state }) => ({
|
|
100
|
+
kind: "settings",
|
|
101
|
+
title: "Pi BTW Settings",
|
|
102
|
+
lines: [`User settings · ${displaySettingsPath}`],
|
|
103
|
+
items: [
|
|
104
|
+
{
|
|
105
|
+
id: "thinkingLevel",
|
|
106
|
+
label: "Thinking level",
|
|
107
|
+
description: "Set the starting level for future pi-btw side threads.",
|
|
108
|
+
currentValue: displayThinkingLevel(state.settings),
|
|
109
|
+
values: levels,
|
|
110
|
+
action: "set-thinking",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "rememberThinkingLevelChanges",
|
|
114
|
+
label: "Remember thinking level changes",
|
|
115
|
+
description: "Save side-thread shortcut changes to pi-btw.json for next time.",
|
|
116
|
+
currentValue: effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off",
|
|
117
|
+
values: ["On", "Off"],
|
|
118
|
+
action: "set-remember",
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}),
|
|
122
|
+
invalid: ({ state }) => ({
|
|
123
|
+
kind: "detail",
|
|
124
|
+
title: "Pi BTW Settings · Read only",
|
|
125
|
+
lines: [
|
|
126
|
+
`Invalid settings file. Fix ${displaySettingsPath} before saving.`,
|
|
127
|
+
sanitizeSingleLine(state.reason ?? "The settings file is invalid."),
|
|
128
|
+
],
|
|
129
|
+
hint: "back",
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
actions: {
|
|
133
|
+
start: async () => {
|
|
134
|
+
startSelected = true;
|
|
135
|
+
return { kind: "close" };
|
|
136
|
+
},
|
|
137
|
+
"set-thinking": async ({ value, signal }) => {
|
|
138
|
+
if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
|
|
139
|
+
try {
|
|
140
|
+
await updateSettings(
|
|
141
|
+
{ thinkingLevel: value as BtwThinkingLevel },
|
|
142
|
+
{ settingsPath, signal },
|
|
143
|
+
);
|
|
144
|
+
if (signal.aborted) return { kind: "rejected" };
|
|
145
|
+
notifySafely(ctx, `Pi BTW thinking level: ${value}.`, "info");
|
|
146
|
+
return { kind: "stay" };
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!signal.aborted) notifySaveFailure(ctx, error);
|
|
149
|
+
return { kind: "rejected" };
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"set-remember": async ({ value, signal }) => {
|
|
153
|
+
if (value !== "On" && value !== "Off") return { kind: "rejected" };
|
|
154
|
+
try {
|
|
155
|
+
await updateSettings(
|
|
156
|
+
{ rememberThinkingLevelChanges: value === "On" },
|
|
157
|
+
{ settingsPath, signal },
|
|
158
|
+
);
|
|
159
|
+
if (signal.aborted) return { kind: "rejected" };
|
|
160
|
+
notifySafely(ctx, `Remember thinking level changes: ${value}.`, "info");
|
|
161
|
+
return { kind: "stay" };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!signal.aborted) notifySaveFailure(ctx, error);
|
|
164
|
+
return { kind: "rejected" };
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
|
|
171
|
+
runMenu(menuContext, menu, { getState: loadState }),
|
|
172
|
+
);
|
|
173
|
+
return startSelected && result.kind === "closed" && result.reason === "close"
|
|
174
|
+
? "start"
|
|
175
|
+
: "closed";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function runBtwMenuPreservingEditor(
|
|
179
|
+
ctx: ExtensionCommandContext,
|
|
180
|
+
run: (menuContext: MenuContext) => Promise<RunMenuResult>,
|
|
181
|
+
): Promise<RunMenuResult> {
|
|
182
|
+
let liveEditorText = ctx.ui.getEditorText();
|
|
183
|
+
let completed = false;
|
|
184
|
+
const ui = new Proxy(ctx.ui, {
|
|
185
|
+
get(target, property) {
|
|
186
|
+
if (property === "custom") {
|
|
187
|
+
return <Value>(factory: BtwCustomFactory<Value>, customOptions?: BtwCustomOptions) =>
|
|
188
|
+
target.custom<Value>(
|
|
189
|
+
(tui, theme, keybindings, done) =>
|
|
190
|
+
factory(tui, theme, keybindings, (value) => {
|
|
191
|
+
try {
|
|
192
|
+
liveEditorText = target.getEditorText();
|
|
193
|
+
} catch {
|
|
194
|
+
// Keep completion finite if session replacement invalidates the editor context.
|
|
195
|
+
}
|
|
196
|
+
completed = true;
|
|
197
|
+
done(value);
|
|
198
|
+
}),
|
|
199
|
+
customOptions,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const value = Reflect.get(target, property, target) as unknown;
|
|
203
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
const result = await run({ mode: ctx.mode, hasUI: ctx.hasUI, ui });
|
|
207
|
+
if (result.kind !== "stale" && completed) {
|
|
208
|
+
try {
|
|
209
|
+
if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
|
|
210
|
+
} catch {
|
|
211
|
+
// A replaced context owns a different editor and must not receive stale restoration.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function clampToAvailableThinkingLevel(
|
|
218
|
+
requested: BtwThinkingLevel,
|
|
219
|
+
available: readonly BtwThinkingLevel[],
|
|
220
|
+
): BtwThinkingLevel {
|
|
221
|
+
if (available.includes(requested)) return requested;
|
|
222
|
+
const requestedIndex = BTW_THINKING_LEVELS.indexOf(requested);
|
|
223
|
+
for (let index = requestedIndex; index < BTW_THINKING_LEVELS.length; index += 1) {
|
|
224
|
+
const candidate = BTW_THINKING_LEVELS[index];
|
|
225
|
+
if (candidate && available.includes(candidate)) return candidate;
|
|
226
|
+
}
|
|
227
|
+
for (let index = requestedIndex - 1; index >= 0; index -= 1) {
|
|
228
|
+
const candidate = BTW_THINKING_LEVELS[index];
|
|
229
|
+
if (candidate && available.includes(candidate)) return candidate;
|
|
230
|
+
}
|
|
231
|
+
return available[0] ?? "off";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function notifySaveFailure(ctx: ExtensionCommandContext, error: unknown): void {
|
|
235
|
+
notifySafely(
|
|
236
|
+
ctx,
|
|
237
|
+
`Pi BTW settings were not saved; the previous value remains active: ${formatError(error)}`,
|
|
238
|
+
"error",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function notifySafely(
|
|
243
|
+
ctx: ExtensionCommandContext,
|
|
244
|
+
message: string,
|
|
245
|
+
level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
|
|
246
|
+
): void {
|
|
247
|
+
try {
|
|
248
|
+
ctx.ui.notify(sanitizeSingleLine(message), level);
|
|
249
|
+
} catch {
|
|
250
|
+
// A completed save remains valid if its command context was replaced before notification.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatError(error: unknown): string {
|
|
255
|
+
return error instanceof Error ? error.message : String(error);
|
|
256
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
|
|
7
|
+
|
|
8
|
+
export const BTW_SETTINGS_FILE = "pi-btw.json";
|
|
9
|
+
export const DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
|
|
10
|
+
const MAX_SETTINGS_BYTES = 64 * 1024;
|
|
11
|
+
|
|
12
|
+
export interface BtwSettings {
|
|
13
|
+
model?: string;
|
|
14
|
+
thinkingLevel?: BtwThinkingLevel;
|
|
15
|
+
rememberThinkingLevelChanges?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BtwSettingsLoadResult =
|
|
19
|
+
| { kind: "missing" }
|
|
20
|
+
| { kind: "invalid"; reason: string }
|
|
21
|
+
| { kind: "loaded"; settings: BtwSettings };
|
|
22
|
+
|
|
23
|
+
export interface UpdateBtwSettingsOptions {
|
|
24
|
+
settingsPath?: string;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type SettingsDocument = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
const mutationQueues = new Map<string, Promise<void>>();
|
|
32
|
+
|
|
33
|
+
export function btwSettingsPath(): string {
|
|
34
|
+
return join(getAgentDir(), BTW_SETTINGS_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
|
|
38
|
+
if (!isSettingsDocument(value)) return undefined;
|
|
39
|
+
|
|
40
|
+
const settings: BtwSettings = {};
|
|
41
|
+
if (Object.hasOwn(value, "model")) {
|
|
42
|
+
const model = Reflect.get(value, "model");
|
|
43
|
+
if (typeof model !== "string" || !parseBtwModelReference(model)) return undefined;
|
|
44
|
+
settings.model = model;
|
|
45
|
+
}
|
|
46
|
+
if (Object.hasOwn(value, "thinkingLevel")) {
|
|
47
|
+
const thinkingLevel = Reflect.get(value, "thinkingLevel");
|
|
48
|
+
if (!isBtwThinkingLevel(thinkingLevel)) return undefined;
|
|
49
|
+
settings.thinkingLevel = thinkingLevel;
|
|
50
|
+
}
|
|
51
|
+
if (Object.hasOwn(value, "rememberThinkingLevelChanges")) {
|
|
52
|
+
const remember = Reflect.get(value, "rememberThinkingLevelChanges");
|
|
53
|
+
if (typeof remember !== "boolean") return undefined;
|
|
54
|
+
settings.rememberThinkingLevelChanges = remember;
|
|
55
|
+
}
|
|
56
|
+
return settings;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseBtwModelReference(
|
|
60
|
+
reference: string,
|
|
61
|
+
): { provider: string; modelId: string } | undefined {
|
|
62
|
+
if (/[\s\p{Cc}]/u.test(reference)) return undefined;
|
|
63
|
+
const separator = reference.indexOf("/");
|
|
64
|
+
if (separator <= 0 || separator === reference.length - 1) return undefined;
|
|
65
|
+
return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function effectiveRememberThinkingLevelChanges(settings: BtwSettings): boolean {
|
|
69
|
+
return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readBtwSettings(
|
|
73
|
+
settingsPath = btwSettingsPath(),
|
|
74
|
+
): Promise<BtwSettingsLoadResult> {
|
|
75
|
+
await awaitBtwSettingsWrites(settingsPath);
|
|
76
|
+
return readBtwSettingsUncoordinated(settingsPath);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function updateBtwSettings(
|
|
80
|
+
patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
|
|
81
|
+
options: UpdateBtwSettingsOptions = {},
|
|
82
|
+
): Promise<BtwSettings> {
|
|
83
|
+
const settingsPath = options.settingsPath ?? btwSettingsPath();
|
|
84
|
+
return enqueueMutation(settingsPath, async () => {
|
|
85
|
+
options.signal?.throwIfAborted();
|
|
86
|
+
const current = await readSettingsDocumentForUpdate(settingsPath);
|
|
87
|
+
const updated: SettingsDocument = { ...current, ...patch };
|
|
88
|
+
const settings = normalizeBtwSettings(updated);
|
|
89
|
+
if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
|
|
90
|
+
await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
|
|
91
|
+
return settings;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function awaitBtwSettingsWrites(settingsPath = btwSettingsPath()): Promise<void> {
|
|
96
|
+
await mutationQueues.get(settingsPath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): Promise<T> {
|
|
100
|
+
const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
|
|
101
|
+
const result = previous.then(mutation, mutation);
|
|
102
|
+
const settled = result.then(
|
|
103
|
+
() => undefined,
|
|
104
|
+
() => undefined,
|
|
105
|
+
);
|
|
106
|
+
mutationQueues.set(settingsPath, settled);
|
|
107
|
+
void settled.finally(() => {
|
|
108
|
+
if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
|
|
109
|
+
});
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readBtwSettingsUncoordinated(settingsPath: string): Promise<BtwSettingsLoadResult> {
|
|
114
|
+
let contents: string;
|
|
115
|
+
try {
|
|
116
|
+
contents = await readSettingsContents(settingsPath);
|
|
117
|
+
} catch (error: unknown) {
|
|
118
|
+
if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
|
|
119
|
+
return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const settings = normalizeBtwSettings(JSON.parse(contents) as unknown);
|
|
124
|
+
return settings
|
|
125
|
+
? { kind: "loaded", settings }
|
|
126
|
+
: { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
|
|
127
|
+
} catch {
|
|
128
|
+
return { kind: "invalid", reason: `${settingsPath}: invalid JSON` };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readSettingsDocumentForUpdate(settingsPath: string): Promise<SettingsDocument> {
|
|
133
|
+
let contents: string;
|
|
134
|
+
try {
|
|
135
|
+
contents = await readSettingsContents(settingsPath);
|
|
136
|
+
} catch (error: unknown) {
|
|
137
|
+
if (isNodeError(error) && error.code === "ENOENT") return {};
|
|
138
|
+
throw invalidSettingsError(settingsPath, formatError(error));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let parsed: unknown;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(contents) as unknown;
|
|
144
|
+
} catch {
|
|
145
|
+
throw invalidSettingsError(settingsPath, "invalid JSON");
|
|
146
|
+
}
|
|
147
|
+
if (!isSettingsDocument(parsed) || !normalizeBtwSettings(parsed)) {
|
|
148
|
+
throw invalidSettingsError(settingsPath, "invalid settings shape");
|
|
149
|
+
}
|
|
150
|
+
return parsed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function readSettingsContents(settingsPath: string): Promise<string> {
|
|
154
|
+
const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
155
|
+
const handle = await open(settingsPath, flags);
|
|
156
|
+
try {
|
|
157
|
+
const descriptorStats = await handle.stat();
|
|
158
|
+
if (!descriptorStats.isFile()) throw new Error("settings path is not a regular file");
|
|
159
|
+
if (descriptorStats.size > MAX_SETTINGS_BYTES) {
|
|
160
|
+
throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
|
|
164
|
+
let offset = 0;
|
|
165
|
+
while (offset < buffer.byteLength) {
|
|
166
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
|
|
167
|
+
if (bytesRead === 0) break;
|
|
168
|
+
offset += bytesRead;
|
|
169
|
+
}
|
|
170
|
+
if (offset > MAX_SETTINGS_BYTES) {
|
|
171
|
+
throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
|
175
|
+
buffer.subarray(0, offset),
|
|
176
|
+
);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error("settings file is not valid UTF-8");
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
await handle.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function publishSettings(
|
|
186
|
+
settingsPath: string,
|
|
187
|
+
document: SettingsDocument,
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
const contents = `${JSON.stringify(document, null, 2)}\n`;
|
|
193
|
+
if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
|
|
194
|
+
throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
195
|
+
}
|
|
196
|
+
const directory = dirname(settingsPath);
|
|
197
|
+
await mkdir(directory, { recursive: true });
|
|
198
|
+
signal?.throwIfAborted();
|
|
199
|
+
const temporaryPath = join(
|
|
200
|
+
directory,
|
|
201
|
+
`.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
202
|
+
);
|
|
203
|
+
try {
|
|
204
|
+
await writeFile(temporaryPath, contents, {
|
|
205
|
+
encoding: "utf8",
|
|
206
|
+
flag: "wx",
|
|
207
|
+
mode: 0o600,
|
|
208
|
+
signal,
|
|
209
|
+
});
|
|
210
|
+
await beforeRename?.(temporaryPath, settingsPath);
|
|
211
|
+
signal?.throwIfAborted();
|
|
212
|
+
await rename(temporaryPath, settingsPath);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isSettingsDocument(value: unknown): value is SettingsDocument {
|
|
220
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
|
|
224
|
+
return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function invalidSettingsError(settingsPath: string, reason: string): Error {
|
|
228
|
+
return new Error(`pi-btw settings at ${settingsPath} are invalid: ${reason}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
232
|
+
return error instanceof Error && "code" in error;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function formatError(error: unknown): string {
|
|
236
|
+
return error instanceof Error ? error.message : String(error);
|
|
237
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function sanitizeSingleLine(text: string): string {
|
|
2
|
+
return [...text.replace(/[\r\n\t]/gu, " ")]
|
|
3
|
+
.filter((character) => {
|
|
4
|
+
const code = character.charCodeAt(0);
|
|
5
|
+
return code > 31 && (code < 127 || code > 159);
|
|
6
|
+
})
|
|
7
|
+
.join("")
|
|
8
|
+
.replace(/ +/gu, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
package/src/transcript-pager.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import {
|
|
3
3
|
AssistantMessageComponent,
|
|
4
4
|
getMarkdownTheme,
|
|
5
|
+
type KeybindingsManager,
|
|
5
6
|
type Theme,
|
|
6
7
|
UserMessageComponent,
|
|
7
8
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -18,7 +19,8 @@ import {
|
|
|
18
19
|
truncateToWidth,
|
|
19
20
|
visibleWidth,
|
|
20
21
|
} from "@earendil-works/pi-tui";
|
|
21
|
-
import type { SideThreadTurn } from "./side-thread.js";
|
|
22
|
+
import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
|
|
23
|
+
import { sanitizeSingleLine } from "./text.js";
|
|
22
24
|
|
|
23
25
|
const TRANSCRIPT_CHROME_LINES = 2;
|
|
24
26
|
const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
|
|
@@ -30,6 +32,13 @@ export type TranscriptPagerAction =
|
|
|
30
32
|
| { kind: "bringToMain"; questionDraft: string }
|
|
31
33
|
| { kind: "close" };
|
|
32
34
|
|
|
35
|
+
export interface BtwThinkingControl {
|
|
36
|
+
level: BtwThinkingLevel;
|
|
37
|
+
levels: readonly BtwThinkingLevel[];
|
|
38
|
+
keybindings: KeybindingsManager;
|
|
39
|
+
onChange: (level: BtwThinkingLevel) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
33
42
|
export class BtwTranscriptPager implements Component {
|
|
34
43
|
private readonly transcriptComponents: Component[];
|
|
35
44
|
private readonly editor: Editor;
|
|
@@ -41,17 +50,23 @@ export class BtwTranscriptPager implements Component {
|
|
|
41
50
|
private warning: string | undefined;
|
|
42
51
|
private finished = false;
|
|
43
52
|
private isFocused = false;
|
|
53
|
+
private thinkingLevel: BtwThinkingLevel | undefined;
|
|
44
54
|
|
|
45
55
|
constructor(
|
|
46
56
|
private readonly tui: TUI,
|
|
47
57
|
private readonly theme: Theme,
|
|
48
58
|
turns: readonly SideThreadTurn[],
|
|
49
59
|
private readonly onAction: (action: TranscriptPagerAction) => void,
|
|
50
|
-
options: {
|
|
60
|
+
private readonly options: {
|
|
61
|
+
startAtBottom?: boolean;
|
|
62
|
+
initialQuestion?: string;
|
|
63
|
+
thinking?: BtwThinkingControl;
|
|
64
|
+
} = {},
|
|
51
65
|
) {
|
|
52
66
|
this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
|
|
53
67
|
this.canBringToMain = turns.some((turn) => turn.kind === "answered");
|
|
54
68
|
this.followBottom = options.startAtBottom ?? false;
|
|
69
|
+
this.thinkingLevel = options.thinking?.level;
|
|
55
70
|
const editorTheme: EditorTheme = {
|
|
56
71
|
borderColor: (text) => this.theme.fg("accent", text),
|
|
57
72
|
selectList: {
|
|
@@ -102,7 +117,7 @@ export class BtwTranscriptPager implements Component {
|
|
|
102
117
|
this.clampScrollOffset();
|
|
103
118
|
|
|
104
119
|
return fitComposerLayout(
|
|
105
|
-
renderSideThreadHeader(safeWidth, this.theme),
|
|
120
|
+
renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
|
|
106
121
|
contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
|
|
107
122
|
this.renderFooter(safeWidth),
|
|
108
123
|
editorLines,
|
|
@@ -122,6 +137,22 @@ export class BtwTranscriptPager implements Component {
|
|
|
122
137
|
this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
|
|
123
138
|
return;
|
|
124
139
|
}
|
|
140
|
+
const thinking = this.options.thinking;
|
|
141
|
+
if (
|
|
142
|
+
thinking &&
|
|
143
|
+
thinking.levels.length > 1 &&
|
|
144
|
+
thinking.keybindings.matches(data, "app.thinking.cycle")
|
|
145
|
+
) {
|
|
146
|
+
const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
|
|
147
|
+
const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
|
|
148
|
+
if (nextLevel) {
|
|
149
|
+
this.thinkingLevel = nextLevel;
|
|
150
|
+
thinking.onChange(nextLevel);
|
|
151
|
+
this.warning = undefined;
|
|
152
|
+
this.tui.requestRender();
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
125
156
|
if (matchesKey(data, Key.pageUp)) {
|
|
126
157
|
const previousOffset = this.scrollOffset;
|
|
127
158
|
this.scrollBy(-this.lastViewportHeight);
|
|
@@ -144,23 +175,38 @@ export class BtwTranscriptPager implements Component {
|
|
|
144
175
|
this.editor.invalidate();
|
|
145
176
|
}
|
|
146
177
|
|
|
178
|
+
dispose(): void {
|
|
179
|
+
if (this.finished) return;
|
|
180
|
+
this.finished = true;
|
|
181
|
+
this.onAction({ kind: "close" });
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
private renderFooter(width: number): string {
|
|
148
185
|
if (this.warning) {
|
|
149
186
|
const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
|
|
150
187
|
return truncateToWidth(this.theme.fg("warning", warning), width);
|
|
151
188
|
}
|
|
152
189
|
const scrollable = this.getMaxScrollOffset() > 0;
|
|
153
|
-
const
|
|
190
|
+
const thinking = this.options.thinking;
|
|
191
|
+
const cycleHint =
|
|
192
|
+
thinking && thinking.levels.length > 1 && this.thinkingLevel
|
|
193
|
+
? ` • thinking ${this.thinkingLevel} • ${thinkingKeyLabel(thinking.keybindings)} cycle`
|
|
194
|
+
: "";
|
|
195
|
+
const base = this.canBringToMain
|
|
154
196
|
? "btw • Enter send • Ctrl+R bring to main • Ctrl+C exit"
|
|
155
197
|
: "btw • Enter send • Ctrl+C exit";
|
|
198
|
+
const fullBase = `${base}${cycleHint}`;
|
|
156
199
|
const fallbackBase = "btw • Enter • Ctrl+C";
|
|
157
200
|
const compactBase = this.canBringToMain ? "btw • Enter • Ctrl+R • Ctrl+C" : fallbackBase;
|
|
201
|
+
const compactWithThinking = `${compactBase}${cycleHint}`;
|
|
158
202
|
let hints =
|
|
159
203
|
visibleWidth(fullBase) <= width
|
|
160
204
|
? fullBase
|
|
161
|
-
: visibleWidth(
|
|
162
|
-
?
|
|
163
|
-
:
|
|
205
|
+
: visibleWidth(compactWithThinking) <= width
|
|
206
|
+
? compactWithThinking
|
|
207
|
+
: visibleWidth(compactBase) <= width
|
|
208
|
+
? compactBase
|
|
209
|
+
: fallbackBase;
|
|
164
210
|
if (scrollable) {
|
|
165
211
|
const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
|
|
166
212
|
const compactHistory = " • PgUp/PgDn";
|
|
@@ -212,6 +258,7 @@ export class BtwAnsweringView implements Component {
|
|
|
212
258
|
turns: readonly SideThreadTurn[],
|
|
213
259
|
pendingQuestion: string,
|
|
214
260
|
private readonly onCancel: () => void,
|
|
261
|
+
private readonly thinkingLevel?: BtwThinkingLevel,
|
|
215
262
|
) {
|
|
216
263
|
this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
|
|
217
264
|
this.loader = new Loader(
|
|
@@ -239,7 +286,7 @@ export class BtwAnsweringView implements Component {
|
|
|
239
286
|
const loaderWidth = Math.max(1, safeWidth - visibleWidth(cancelHint) - 3);
|
|
240
287
|
const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
|
|
241
288
|
const lines = [
|
|
242
|
-
renderSideThreadHeader(safeWidth, this.theme),
|
|
289
|
+
renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
|
|
243
290
|
...contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
|
|
244
291
|
truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", cancelHint)}`, safeWidth),
|
|
245
292
|
];
|
|
@@ -278,8 +325,15 @@ export class BtwAnsweringView implements Component {
|
|
|
278
325
|
}
|
|
279
326
|
|
|
280
327
|
dispose(): void {
|
|
281
|
-
this.
|
|
328
|
+
if (this.finished) {
|
|
329
|
+
this.loader.stop();
|
|
330
|
+
this.controller.abort();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
this.finished = true;
|
|
334
|
+
this.loader.stop();
|
|
282
335
|
this.controller.abort();
|
|
336
|
+
this.onCancel();
|
|
283
337
|
}
|
|
284
338
|
|
|
285
339
|
private scrollBy(delta: number): void {
|
|
@@ -350,12 +404,35 @@ function renderTranscriptLines(components: readonly Component[], width: number):
|
|
|
350
404
|
.map(stripShellIntegrationMarkers);
|
|
351
405
|
}
|
|
352
406
|
|
|
353
|
-
function renderSideThreadHeader(
|
|
354
|
-
|
|
407
|
+
function renderSideThreadHeader(
|
|
408
|
+
width: number,
|
|
409
|
+
theme: Theme,
|
|
410
|
+
thinkingLevel?: BtwThinkingLevel,
|
|
411
|
+
): string {
|
|
412
|
+
const thinking = thinkingLevel ? ` · thinking ${thinkingLevel}` : "";
|
|
413
|
+
const title = truncateToWidth(`─ btw · side thread${thinking} `, width);
|
|
355
414
|
const ruleWidth = Math.max(0, width - visibleWidth(title));
|
|
356
415
|
return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
|
|
357
416
|
}
|
|
358
417
|
|
|
418
|
+
function thinkingKeyLabel(keybindings: KeybindingsManager): string {
|
|
419
|
+
const key =
|
|
420
|
+
sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
|
|
421
|
+
"Shift+Tab";
|
|
422
|
+
return key
|
|
423
|
+
.split("+")
|
|
424
|
+
.map((part) => {
|
|
425
|
+
const lower = part.toLowerCase();
|
|
426
|
+
if (lower === "shift") return "Shift";
|
|
427
|
+
if (lower === "ctrl") return "Ctrl";
|
|
428
|
+
if (lower === "alt") return "Alt";
|
|
429
|
+
return part.length === 1
|
|
430
|
+
? part.toUpperCase()
|
|
431
|
+
: `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
|
|
432
|
+
})
|
|
433
|
+
.join("+");
|
|
434
|
+
}
|
|
435
|
+
|
|
359
436
|
function fitComposerLayout(
|
|
360
437
|
header: string,
|
|
361
438
|
contentLines: string[],
|