@herbertgao/pi-extensions 2026.9.12 → 2026.9.13

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 (42) hide show
  1. package/README.md +4 -4
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +8 -0
  4. package/node_modules/@herbertgao/pi-subagents/package.json +1 -1
  5. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +5 -4
  6. package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +39 -16
  7. package/node_modules/@narumitw/pi-btw/README.md +21 -4
  8. package/node_modules/@narumitw/pi-btw/dist/index.ts +1668 -958
  9. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  10. package/node_modules/@narumitw/pi-btw/docs/workflows.md +10 -2
  11. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  12. package/node_modules/@narumitw/pi-btw/src/btw.ts +17 -79
  13. package/node_modules/@narumitw/pi-btw/src/conversation-context.ts +74 -0
  14. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +196 -7
  15. package/node_modules/@narumitw/pi-btw/src/main-thread-updates.ts +40 -0
  16. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  17. package/node_modules/@narumitw/pi-btw/src/settings.ts +49 -0
  18. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +9 -1
  19. package/node_modules/@narumitw/pi-btw/src/workspace-layout.ts +559 -0
  20. package/node_modules/pi-multi-account/CHANGELOG.md +23 -0
  21. package/node_modules/pi-multi-account/README.md +38 -11
  22. package/node_modules/pi-multi-account/index.ts +371 -96
  23. package/node_modules/pi-multi-account/package.json +5 -4
  24. package/node_modules/pi-multi-account/provider-payload-stream.ts +36 -28
  25. package/node_modules/pi-multi-account/usage.ts +31 -2
  26. package/node_modules/pi-typesafe/README.md +3 -1
  27. package/node_modules/pi-typesafe/dist/auth.d.ts +10 -3
  28. package/node_modules/pi-typesafe/dist/auth.js +13 -7
  29. package/node_modules/pi-typesafe/dist/backends.d.ts +31 -0
  30. package/node_modules/pi-typesafe/dist/backends.js +33 -0
  31. package/node_modules/pi-typesafe/dist/client.d.ts +3 -9
  32. package/node_modules/pi-typesafe/dist/client.js +63 -39
  33. package/node_modules/pi-typesafe/dist/credentials.d.ts +11 -5
  34. package/node_modules/pi-typesafe/dist/credentials.js +15 -8
  35. package/node_modules/pi-typesafe/dist/extension.js +4 -1
  36. package/node_modules/pi-typesafe/dist/index.d.ts +1 -1
  37. package/node_modules/pi-typesafe/dist/index.js +1 -1
  38. package/node_modules/pi-typesafe/dist/login.d.ts +13 -7
  39. package/node_modules/pi-typesafe/dist/login.js +17 -8
  40. package/node_modules/pi-typesafe/dist/schema.js +19 -13
  41. package/node_modules/pi-typesafe/package.json +1 -1
  42. package/package.json +5 -5
@@ -440,6 +440,53 @@ function escapeTerminalControls(text) {
440
440
  }).join("");
441
441
  }
442
442
 
443
+ // src/conversation-context.ts
444
+ var MAX_CONTEXT_CHARS = 4e4;
445
+ function buildConversationContext(entries) {
446
+ const sections = [];
447
+ for (const entry of entries) {
448
+ if (entry.type !== "message" || !entry.message?.role) continue;
449
+ const role = entry.message.role;
450
+ if (role !== "user" && role !== "assistant") continue;
451
+ const contentLines = extractContentLines(entry.message.content);
452
+ if (contentLines.length === 0) continue;
453
+ const label = role === "user" ? "User" : "Assistant";
454
+ const status = entry.message.stopReason && entry.message.stopReason !== "stop" ? ` (${entry.message.stopReason})` : "";
455
+ sections.push(`${label}${status}: ${contentLines.join("\n")}`);
456
+ }
457
+ return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
458
+ }
459
+ function extractContentLines(content) {
460
+ if (typeof content === "string") return [content.trim()].filter(Boolean);
461
+ if (!Array.isArray(content)) return [];
462
+ const lines = [];
463
+ for (const part of content) {
464
+ if (!part || typeof part !== "object") continue;
465
+ const block = part;
466
+ if (block.type === "text" && typeof block.text === "string") {
467
+ lines.push(block.text.trim());
468
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
469
+ lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
470
+ } else if (block.type === "toolResult" && typeof block.name === "string") {
471
+ lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
472
+ }
473
+ }
474
+ return lines.filter(Boolean);
475
+ }
476
+ function formatJson(value) {
477
+ if (value === void 0) return "";
478
+ try {
479
+ return JSON.stringify(value);
480
+ } catch {
481
+ return String(value);
482
+ }
483
+ }
484
+ function truncateFromStart(text, maxChars) {
485
+ if (text.length <= maxChars) return text;
486
+ return `[Earlier context omitted; showing the last ${maxChars} characters.]
487
+ ${text.slice(-maxChars)}`;
488
+ }
489
+
443
490
  // src/fullscreen-ui.ts
444
491
  import { spawn } from "node:child_process";
445
492
  import {
@@ -451,7 +498,7 @@ import {
451
498
  Key as Key2,
452
499
  parseKey,
453
500
  TuiAltScreen,
454
- truncateToWidth as truncateToWidth2
501
+ truncateToWidth as truncateToWidth3
455
502
  } from "@earendil-works/pi-tui";
456
503
 
457
504
  // src/keybindings.ts
@@ -689,977 +736,1653 @@ var BtwPasteGuard = class {
689
736
  }
690
737
  };
691
738
 
692
- // src/fullscreen-ui.ts
693
- var FullscreenUiDisposedError = class extends Error {
694
- constructor() {
695
- super("The dedicated pi-btw UI was disposed.");
696
- this.name = "FullscreenUiDisposedError";
697
- }
698
- };
699
- async function runBtwFullscreen(ctx, run, options = {}, dependencies = {}) {
700
- const createTui = dependencies.createTui ?? ((parent, theme, keybindings, fullscreenOptions) => createBtwFullscreenTui(
701
- parent,
702
- theme,
703
- keybindings,
704
- fullscreenOptions.copyOnSelect ?? true,
705
- dependencies.manualSelectionCopySupported ?? hasManualSelectionCopyApi(),
706
- dependencies.openUrl ?? openUrlInBrowser,
707
- dependencies.copyToClipboard ?? copyToHostClipboard
708
- ));
709
- let liveEditorText = ctx.ui.getEditorText();
710
- let restoreEditor = false;
711
- let host;
712
- const outcome = await ctx.ui.custom(
713
- (parent, theme, keybindings, done) => {
714
- host = new BtwFullscreenHost(
715
- parent,
716
- theme,
717
- keybindings,
718
- ctx,
719
- run,
720
- (value) => {
721
- try {
722
- liveEditorText = ctx.ui.getEditorText();
723
- restoreEditor = true;
724
- } catch {
725
- }
726
- done(value);
727
- },
728
- createTui,
729
- options
730
- );
731
- return host;
732
- },
733
- {
734
- overlay: true,
735
- onHandle: (handle) => host?.setParentOverlay(handle)
736
- }
739
+ // src/workspace-layout.ts
740
+ import {
741
+ HStack,
742
+ isFocusable,
743
+ ScrollView,
744
+ truncateToWidth as truncateToWidth2,
745
+ visibleWidth as visibleWidth2
746
+ } from "@earendil-works/pi-tui";
747
+
748
+ // src/settings.ts
749
+ import { randomUUID } from "node:crypto";
750
+ import { constants } from "node:fs";
751
+ import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
752
+ import { basename, dirname, join } from "node:path";
753
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
754
+
755
+ // src/side-thread.ts
756
+ var BTW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
757
+ function createSideThread(conversationContext) {
758
+ return { conversationContext, turns: [] };
759
+ }
760
+ function buildSideThreadMessages(thread, question) {
761
+ const answeredTurns = thread.turns.filter(
762
+ (turn) => turn.kind === "answered"
737
763
  );
738
- if (restoreEditor) {
739
- try {
740
- if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
741
- } catch {
742
- }
764
+ const messages = [];
765
+ if (answeredTurns.length === 0) {
766
+ messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
767
+ return messages;
743
768
  }
744
- if (outcome.kind === "failed") throw outcome.error;
745
- return outcome.value;
746
- }
747
- var btwInputListeners = /* @__PURE__ */ new WeakMap();
748
- function dispatchBtwInput(listeners, data) {
749
- let current = data;
750
- for (const group of [listeners.beforeAll, listeners.beforeViewport, listeners.regular]) {
751
- for (const listener of group) {
752
- const result = listener(current);
753
- if (result?.consume) return result;
754
- if (result?.data !== void 0) current = result.data;
755
- }
769
+ const [first, ...rest] = answeredTurns;
770
+ messages.push(createUserMessage(buildUserPrompt(first.question, thread.conversationContext)), first.response);
771
+ for (const turn of rest) {
772
+ messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
756
773
  }
757
- return current === data ? void 0 : { data: current };
774
+ messages.push(createUserMessage(buildFollowUpPrompt(question)));
775
+ return messages;
758
776
  }
759
- var BtwTuiAltScreen = class extends TuiAltScreen {
760
- hasFocusedOverlay() {
761
- return this.isOverlayFocused();
762
- }
763
- addInputListener(listener) {
764
- let listeners = btwInputListeners.get(this);
765
- if (!listeners) {
766
- const registeredListeners = {
767
- beforeAll: /* @__PURE__ */ new Set(),
768
- beforeViewport: /* @__PURE__ */ new Set(),
769
- regular: /* @__PURE__ */ new Set()
770
- };
771
- btwInputListeners.set(this, registeredListeners);
772
- super.addInputListener((data) => dispatchBtwInput(registeredListeners, data));
773
- listeners = registeredListeners;
777
+ async function completeSideThreadTurn({
778
+ thread,
779
+ model,
780
+ question,
781
+ thinkingLevel,
782
+ auth,
783
+ signal,
784
+ completeSimple,
785
+ sessionId
786
+ }) {
787
+ if (signal?.aborted) return { kind: "aborted" };
788
+ try {
789
+ const response = await completeSimple(
790
+ model,
791
+ { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
792
+ buildStreamOptions(
793
+ auth,
794
+ { thinkingLevel, signal, model, sessionId },
795
+ completeSimple.appliesRequestHeaderTransforms === true
796
+ )
797
+ );
798
+ if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
799
+ if (!isAssistantMessage(response)) {
800
+ return { kind: "error", message: "The side model returned a malformed response." };
774
801
  }
775
- listeners.regular.add(listener);
776
- return () => listeners.regular.delete(listener);
777
- }
778
- addInputListenerBeforeAll(listener) {
779
- const listeners = btwInputListeners.get(this);
780
- if (!listeners) return super.addInputListener(listener);
781
- listeners.beforeAll.add(listener);
782
- return () => listeners.beforeAll.delete(listener);
783
- }
784
- addInputListenerBeforeViewport(listener) {
785
- const listeners = btwInputListeners.get(this);
786
- if (!listeners) return super.addInputListener(listener);
787
- listeners.beforeViewport.add(listener);
788
- return () => listeners.beforeViewport.delete(listener);
789
- }
790
- removeInputListener(listener) {
791
- const listeners = btwInputListeners.get(this);
792
- if (!listeners) {
793
- super.removeInputListener(listener);
794
- return;
802
+ if (response.stopReason === "error") {
803
+ return {
804
+ kind: "error",
805
+ message: response.errorMessage ?? "The side model returned an error."
806
+ };
795
807
  }
796
- listeners.beforeAll.delete(listener);
797
- listeners.beforeViewport.delete(listener);
798
- listeners.regular.delete(listener);
808
+ const answer = extractAssistantText(response) || "No response received.";
809
+ thread.turns.push({ kind: "answered", question, answer, response });
810
+ return { kind: "answered", response, answer };
811
+ } catch (error) {
812
+ if (signal?.aborted) return { kind: "aborted" };
813
+ return { kind: "error", message: formatError(error) };
799
814
  }
800
- };
801
- var BRACKETED_PASTE_START = "\x1B[200~";
802
- var BRACKETED_PASTE_END = "\x1B[201~";
803
- var ALT_SCREEN_ACTIONS_BEFORE_BOTTOM = [
804
- "tui.altScreen.search",
805
- "tui.altScreen.searchNext",
806
- "tui.altScreen.searchPrevious",
807
- "tui.altScreen.searchClose",
808
- "tui.altScreen.pageUp",
809
- "tui.altScreen.pageDown",
810
- "tui.altScreen.halfPageUp",
811
- "tui.altScreen.halfPageDown",
812
- "tui.altScreen.lineUp",
813
- "tui.altScreen.lineDown",
814
- "tui.altScreen.previousPrompt",
815
- "tui.altScreen.nextPrompt",
816
- "tui.altScreen.top"
817
- ];
818
- var KEY_MODIFIER_ORDER = ["shift", "ctrl", "alt", "super"];
819
- var MATCHABLE_SPECIAL_KEYS = /* @__PURE__ */ new Set([
820
- "space",
821
- "tab",
822
- "enter",
823
- "backspace",
824
- "delete",
825
- "insert",
826
- "home",
827
- "end",
828
- "pageup",
829
- "pagedown",
830
- "up",
831
- "down",
832
- "left",
833
- "right"
834
- ]);
835
- var MATCHABLE_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?");
836
- function normalizedKeyId(key) {
837
- const parts = key.toLowerCase().split("+");
838
- const base = parts.at(-1);
839
- if (!base) return "";
840
- const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
841
- const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
842
- return [...modifiers, normalizedBase].join("+");
843
815
  }
844
- function formatEffectiveKeyLabel(key) {
845
- const parts = key.split("+");
846
- const base = parts.at(-1);
847
- if (base === "pageup") parts[parts.length - 1] = "pageUp";
848
- if (base === "pagedown") parts[parts.length - 1] = "pageDown";
849
- return formatKeyLabel2(parts.join("+"));
816
+ function extractAssistantText(response) {
817
+ return response.content.filter(
818
+ (content) => content !== null && typeof content === "object" && content.type === "text" && typeof content.text === "string"
819
+ ).map((content) => content.text).join("\n").trim();
850
820
  }
851
- function canMatchKeyInput(key) {
852
- const parts = key.split("+");
853
- const base = parts.at(-1) ?? "";
854
- const modifiers = parts.slice(0, -1);
855
- if (base === "escape") return modifiers.length === 0;
856
- if (base === "clear") {
857
- return modifiers.length === 0 || modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl");
858
- }
859
- if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
860
- return MATCHABLE_SPECIAL_KEYS.has(base) || base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base));
821
+ function isAssistantMessage(value) {
822
+ if (value === null || typeof value !== "object") return false;
823
+ const candidate = value;
824
+ return candidate.role === "assistant" && Array.isArray(candidate.content) && typeof candidate.stopReason === "string";
861
825
  }
862
- function rawCtrlInput(base) {
863
- if (base.length !== 1) return void 0;
864
- const rawBase = base === "-" ? "_" : base;
865
- if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return void 0;
866
- return String.fromCharCode(rawBase.charCodeAt(0) & 31);
826
+ function buildUserPrompt(question, conversationContext) {
827
+ return [
828
+ "Answer this side question without modifying the main conversation.",
829
+ "",
830
+ "<side_question>",
831
+ question,
832
+ "</side_question>",
833
+ "",
834
+ "<conversation_context>",
835
+ conversationContext || "No prior conversation context was available.",
836
+ "</conversation_context>"
837
+ ].join("\n");
867
838
  }
868
- function legacyRawInput(key) {
869
- const parts = key.split("+");
870
- const base = parts.at(-1) ?? "";
871
- if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
872
- if (isKittyProtocolActive2()) return void 0;
873
- if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\x1B${base}`;
874
- if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
875
- const input = rawCtrlInput(base);
876
- return input ? `\x1B${input}` : void 0;
877
- }
878
- return void 0;
839
+ function buildFollowUpPrompt(question) {
840
+ return ["Continue the same side conversation.", "", "<side_question>", question, "</side_question>"].join("\n");
879
841
  }
880
- function keyInputIdentity(key) {
881
- const identity = normalizedKeyId(key);
882
- const input = legacyRawInput(identity);
883
- return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
842
+ function createUserMessage(text) {
843
+ return {
844
+ role: "user",
845
+ content: [{ type: "text", text }],
846
+ timestamp: Date.now()
847
+ };
884
848
  }
885
- function hasManualSelectionCopyApi() {
886
- return typeof TuiAltScreen.prototype.hasActiveSelection === "function" && typeof TuiAltScreen.prototype.copyActiveSelectionToClipboard === "function";
849
+ function getOpencodeSessionHeaders(model, sessionId) {
850
+ if (!sessionId || model.provider !== "opencode" && model.provider !== "opencode-go") return void 0;
851
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
887
852
  }
888
- function createBtwFullscreenTui(parent, theme, keybindings, copyOnSelect, manualSelectionCopySupported, openUrl, copyToClipboard2) {
889
- if (!copyOnSelect && !manualSelectionCopySupported) {
890
- throw new Error(
891
- "Manual fullscreen selection copying is unavailable in this Pi version; update Pi or enable automatic selection copying."
892
- );
893
- }
894
- const styleSearchMatch = (text) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
895
- const fullscreen = new BtwTuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), void 0, {
896
- mouse: true,
897
- copyOnSelect,
898
- searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
899
- scrollToEndIndicator: () => {
900
- const unavailableKeyIdentities = /* @__PURE__ */ new Set([keyInputIdentity(Key2.ctrl("c"))]);
901
- for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
902
- for (const actionKey of keybindings.getKeys(action)) {
903
- unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
904
- }
905
- }
906
- if (!copyOnSelect) {
907
- for (const copyKey of keybindings.getKeys("app.message.copy")) {
908
- unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
909
- }
910
- }
911
- const key = keybindings.getKeys("tui.altScreen.bottom").map((candidate) => keyInputIdentity(String(candidate))).find(
912
- (identity) => identity && canMatchKeyInput(identity) && !unavailableKeyIdentities.has(identity) && formatEffectiveKeyLabel(identity)
913
- );
914
- const label = theme.fg("text", " \u2193 Jump to latest message");
915
- const shortcut = key ? theme.fg("muted", ` \xB7 ${formatEffectiveKeyLabel(key)}`) : "";
916
- return theme.bg("selectedBg", `${label}${shortcut} `);
917
- },
918
- searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
919
- openUrl,
920
- copySelection: async (text) => {
921
- try {
922
- await copyToClipboard2(text);
923
- return true;
924
- } catch {
925
- return false;
926
- }
927
- }
928
- });
929
- if (!copyOnSelect) {
930
- let isInBracketedPaste = false;
931
- fullscreen.addInputListenerBeforeViewport((data) => {
932
- const wasInBracketedPaste = isInBracketedPaste;
933
- const startsBracketedPaste = data.includes(BRACKETED_PASTE_START);
934
- if (startsBracketedPaste) isInBracketedPaste = true;
935
- if (isInBracketedPaste && data.includes(BRACKETED_PASTE_END)) {
936
- isInBracketedPaste = false;
937
- }
938
- if (wasInBracketedPaste || startsBracketedPaste || fullscreen.hasFocusedOverlay() || isKeyRelease2(data) || !keybindings.matches(data, "app.message.copy")) {
939
- return void 0;
940
- }
941
- if (!fullscreen.hasActiveSelection()) {
942
- fullscreen.flash("No selection to copy");
943
- return { consume: true };
944
- }
945
- void fullscreen.copyActiveSelectionToClipboard().catch(() => fullscreen.flash("Copy failed"));
946
- return { consume: true };
947
- });
853
+ function mergeSessionHeaders(authHeaders, sessionHeaders) {
854
+ if (!sessionHeaders && !authHeaders) return void 0;
855
+ return { ...sessionHeaders, ...authHeaders };
856
+ }
857
+ function buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId }, applyRequestHeaderTransforms) {
858
+ const sessionHeaders = model ? getOpencodeSessionHeaders(model, sessionId) : void 0;
859
+ const options = {
860
+ apiKey: auth?.apiKey,
861
+ headers: applyRequestHeaderTransforms ? auth?.headers : mergeSessionHeaders(auth?.headers, sessionHeaders),
862
+ env: auth?.env,
863
+ signal
864
+ };
865
+ if (applyRequestHeaderTransforms && sessionHeaders) {
866
+ options.transformHeaders = (headers) => mergeSessionHeaders(headers, sessionHeaders) ?? {};
948
867
  }
949
- return fullscreen;
868
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
869
+ return options;
950
870
  }
951
- function openUrlInBrowser(target) {
952
- const [command, args] = process.platform === "darwin" ? ["open", [target]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", target]] : ["xdg-open", [target]];
953
- spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => {
954
- }).unref();
871
+ function formatError(error) {
872
+ return error instanceof Error ? error.message : String(error);
955
873
  }
956
- var BtwFullscreenHost = class {
957
- constructor(parent, theme, keybindings, ctx, run, done, createTui, options) {
958
- this.parent = parent;
959
- this.theme = theme;
960
- this.keybindings = keybindings;
961
- this.ctx = ctx;
962
- this.run = run;
963
- this.done = done;
964
- this.createTui = createTui;
965
- this.options = options;
966
- queueMicrotask(() => void this.start());
874
+ var SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
875
+
876
+ Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
877
+
878
+ // src/settings.ts
879
+ var BTW_SETTINGS_FILE = "pi-btw.json";
880
+ var BTW_LAYOUTS = ["fullscreen", "left-pane", "right-pane"];
881
+ var DEFAULT_BTW_LAYOUT = "fullscreen";
882
+ var DEFAULT_FULLSCREEN_COPY_ON_SELECT = true;
883
+ var DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
884
+ var DEFAULT_BTW_SIDE_PANE_RATIO = 0.5;
885
+ var MIN_BTW_SIDE_PANE_RATIO = 0.2;
886
+ var MAX_BTW_SIDE_PANE_RATIO = 0.8;
887
+ var MAX_SETTINGS_BYTES = 64 * 1024;
888
+ var mutationQueues = /* @__PURE__ */ new Map();
889
+ function btwSettingsPath() {
890
+ return join(getAgentDir(), BTW_SETTINGS_FILE);
891
+ }
892
+ function normalizeBtwSettings(value) {
893
+ if (!isSettingsDocument(value)) return void 0;
894
+ const settings = {};
895
+ if (Object.hasOwn(value, "keybindings")) {
896
+ const keys = value.keybindings;
897
+ if (!isSettingsDocument(keys)) return void 0;
898
+ settings.keybindings = {};
899
+ for (const action of BTW_SHORTCUT_ACTIONS) {
900
+ if (!Object.hasOwn(keys, action)) continue;
901
+ const key = normalizeBtwKey(keys[action]);
902
+ if (!key) return void 0;
903
+ settings.keybindings[action] = key;
904
+ }
967
905
  }
968
- parent;
969
- theme;
970
- keybindings;
971
- ctx;
972
- run;
973
- done;
974
- createTui;
975
- options;
976
- fullscreen;
977
- parentOverlay;
978
- cancelActiveCustom;
979
- hardCancelActiveCustom;
980
- removeHardCancelListener;
981
- removeUpstreamAbortListener;
982
- started = false;
983
- disposed = false;
984
- finished = false;
985
- parentStopped = false;
986
- parentRestoreAttempted = false;
987
- fullscreenCreated = false;
988
- fullscreenStopped = false;
989
- parentRestoreQueued = false;
990
- parentRestorePromise;
991
- cleanupError;
992
- lifetimeController = new AbortController();
993
- setParentOverlay(overlay) {
994
- this.parentOverlay = overlay;
906
+ if (Object.hasOwn(value, "model")) {
907
+ const model = Reflect.get(value, "model");
908
+ if (typeof model !== "string" || !parseBtwModelReference(model)) return void 0;
909
+ settings.model = model;
995
910
  }
996
- render(width) {
997
- return [truncateToWidth2(this.theme.fg("muted", "Opening btw side thread\u2026"), width)];
911
+ if (Object.hasOwn(value, "thinkingLevel")) {
912
+ const thinkingLevel = Reflect.get(value, "thinkingLevel");
913
+ if (!isBtwThinkingLevel(thinkingLevel)) return void 0;
914
+ settings.thinkingLevel = thinkingLevel;
998
915
  }
999
- invalidate() {
916
+ if (Object.hasOwn(value, "rememberThinkingLevelChanges")) {
917
+ const remember = Reflect.get(value, "rememberThinkingLevelChanges");
918
+ if (typeof remember !== "boolean") return void 0;
919
+ settings.rememberThinkingLevelChanges = remember;
1000
920
  }
1001
- dispose() {
1002
- if (this.disposed || this.finished) return;
1003
- this.disposed = true;
1004
- this.lifetimeController.abort();
1005
- this.cancelActiveCustom?.();
921
+ if (Object.hasOwn(value, "fullscreenCopyOnSelect")) {
922
+ const copyOnSelect = Reflect.get(value, "fullscreenCopyOnSelect");
923
+ if (typeof copyOnSelect !== "boolean") return void 0;
924
+ settings.fullscreenCopyOnSelect = copyOnSelect;
1006
925
  }
1007
- async start() {
1008
- if (this.started || this.finished) return;
1009
- this.started = true;
1010
- this.watchUpstreamCancellation();
1011
- let outcome;
1012
- try {
1013
- if (this.disposed) throw new FullscreenUiDisposedError();
1014
- this.parent.stop({ preserveScreen: true });
1015
- this.parentStopped = true;
1016
- if (this.disposed) throw new FullscreenUiDisposedError();
1017
- this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
1018
- this.fullscreenCreated = true;
1019
- this.fullscreen.start();
1020
- const shortcuts = resolveBtwShortcuts(
1021
- this.options.keybindings,
1022
- this.keybindings,
1023
- this.options.copyOnSelect ?? true
1024
- );
1025
- setBtwShortcuts(this.fullscreen, shortcuts);
1026
- let previousWarnings = [];
1027
- const reportWarnings = () => {
1028
- const warnings = shortcuts.warnings;
1029
- for (const warning of warnings) {
1030
- if (previousWarnings.includes(warning)) continue;
1031
- try {
1032
- this.ctx.ui.notify(`Pi BTW: ${warning}`, "warning");
1033
- } catch {
1034
- }
1035
- }
1036
- previousWarnings = warnings;
1037
- };
1038
- const pasteGuard = new BtwPasteGuard();
1039
- const addHardCancelListener = this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ?? this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ?? this.fullscreen.addInputListener.bind(this.fullscreen);
1040
- this.removeHardCancelListener = addHardCancelListener((data) => {
1041
- reportWarnings();
1042
- if (pasteGuard.consume(data) || !shortcuts.matches(data, "exit")) return void 0;
1043
- this.disposed = true;
1044
- this.lifetimeController.abort();
1045
- try {
1046
- this.hardCancelActiveCustom?.();
1047
- } finally {
1048
- this.queueParentRestore();
1049
- }
1050
- return { consume: true };
1051
- });
1052
- outcome = { kind: "completed", value: await this.run(this.createContext()) };
1053
- } catch (error) {
1054
- outcome = { kind: "failed", error };
1055
- }
1056
- try {
1057
- this.cancelActiveCustom?.();
1058
- } catch (error) {
1059
- this.cleanupError ??= error;
1060
- }
1061
- if (this.parentRestorePromise) await this.parentRestorePromise;
1062
- else this.restoreParent();
1063
- if (this.cleanupError !== void 0) outcome = { kind: "failed", error: this.cleanupError };
1064
- this.finished = true;
1065
- this.done(outcome);
926
+ if (Object.hasOwn(value, "layout")) {
927
+ const layout = Reflect.get(value, "layout");
928
+ if (!isBtwLayout(layout)) return void 0;
929
+ settings.layout = layout;
1066
930
  }
1067
- watchUpstreamCancellation() {
1068
- const signal = this.ctx.signal;
1069
- if (!signal) return;
1070
- const onAbort = () => this.dispose();
1071
- signal.addEventListener("abort", onAbort, { once: true });
1072
- this.removeUpstreamAbortListener = () => signal.removeEventListener("abort", onAbort);
1073
- if (signal.aborted) onAbort();
931
+ if (Object.hasOwn(value, "sidePaneRatio")) {
932
+ const sidePaneRatio = Reflect.get(value, "sidePaneRatio");
933
+ if (!isBtwSidePaneRatio(sidePaneRatio)) return void 0;
934
+ settings.sidePaneRatio = sidePaneRatio;
1074
935
  }
1075
- queueParentRestore() {
1076
- if (this.parentRestoreQueued || this.parentRestoreAttempted) return;
1077
- this.parentRestoreQueued = true;
1078
- this.parentRestorePromise = Promise.resolve().then(async () => {
1079
- try {
1080
- await this.fullscreen?.terminal.drainInput?.();
1081
- } catch (error) {
1082
- this.cleanupError ??= error;
1083
- }
1084
- this.parentRestoreQueued = false;
1085
- this.restoreParent();
1086
- });
936
+ return settings;
937
+ }
938
+ function parseBtwModelReference(reference) {
939
+ if (/[\s\p{Cc}]/u.test(reference)) return void 0;
940
+ const separator = reference.indexOf("/");
941
+ if (separator <= 0 || separator === reference.length - 1) return void 0;
942
+ return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
943
+ }
944
+ function effectiveBtwLayout(settings) {
945
+ return settings.layout ?? DEFAULT_BTW_LAYOUT;
946
+ }
947
+ function effectiveFullscreenCopyOnSelect(settings) {
948
+ return settings.fullscreenCopyOnSelect ?? DEFAULT_FULLSCREEN_COPY_ON_SELECT;
949
+ }
950
+ function effectiveRememberThinkingLevelChanges(settings) {
951
+ return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
952
+ }
953
+ function effectiveBtwSidePaneRatio(settings) {
954
+ return isBtwSidePaneRatio(settings.sidePaneRatio) ? settings.sidePaneRatio : DEFAULT_BTW_SIDE_PANE_RATIO;
955
+ }
956
+ async function readBtwSettings(settingsPath = btwSettingsPath()) {
957
+ await awaitBtwSettingsWrites(settingsPath);
958
+ return readBtwSettingsUncoordinated(settingsPath);
959
+ }
960
+ function updateBtwSettings(patch, options = {}) {
961
+ const settingsPath = options.settingsPath ?? btwSettingsPath();
962
+ return enqueueMutation(settingsPath, async () => {
963
+ options.signal?.throwIfAborted();
964
+ const current = await readSettingsDocumentForUpdate(settingsPath);
965
+ options.signal?.throwIfAborted();
966
+ options.validateCurrent?.(normalizeBtwSettings(current) ?? {});
967
+ const updated = applyBtwSettingsPatch(current, patch);
968
+ const settings = normalizeBtwSettings(updated);
969
+ if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
970
+ await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
971
+ return settings;
972
+ });
973
+ }
974
+ async function awaitBtwSettingsWrites(settingsPath = btwSettingsPath()) {
975
+ await mutationQueues.get(settingsPath);
976
+ }
977
+ function enqueueMutation(settingsPath, mutation) {
978
+ const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
979
+ const result = previous.then(mutation, mutation);
980
+ const settled = result.then(
981
+ () => void 0,
982
+ () => void 0
983
+ );
984
+ mutationQueues.set(settingsPath, settled);
985
+ void settled.finally(() => {
986
+ if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
987
+ });
988
+ return result;
989
+ }
990
+ async function readBtwSettingsUncoordinated(settingsPath) {
991
+ let contents;
992
+ try {
993
+ contents = await readSettingsContents(settingsPath);
994
+ } catch (error) {
995
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
996
+ return { kind: "invalid", reason: `${settingsPath}: ${formatError2(error)}` };
1087
997
  }
1088
- restoreParent() {
1089
- const removeUpstreamAbortListener = this.removeUpstreamAbortListener;
1090
- this.removeUpstreamAbortListener = void 0;
1091
- try {
1092
- removeUpstreamAbortListener?.();
1093
- } catch (error) {
1094
- this.cleanupError ??= error;
1095
- }
1096
- const removeHardCancelListener = this.removeHardCancelListener;
1097
- this.removeHardCancelListener = void 0;
1098
- try {
1099
- removeHardCancelListener?.();
1100
- } catch (error) {
1101
- this.cleanupError ??= error;
998
+ try {
999
+ const settings = normalizeBtwSettings(JSON.parse(contents));
1000
+ return settings ? { kind: "loaded", settings } : { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
1001
+ } catch {
1002
+ return { kind: "invalid", reason: `${settingsPath}: invalid JSON` };
1003
+ }
1004
+ }
1005
+ async function readSettingsDocumentForUpdate(settingsPath) {
1006
+ let contents;
1007
+ try {
1008
+ contents = await readSettingsContents(settingsPath);
1009
+ } catch (error) {
1010
+ if (isNodeError(error) && error.code === "ENOENT") return {};
1011
+ throw invalidSettingsError(settingsPath, formatError2(error));
1012
+ }
1013
+ let parsed;
1014
+ try {
1015
+ parsed = JSON.parse(contents);
1016
+ } catch {
1017
+ throw invalidSettingsError(settingsPath, "invalid JSON");
1018
+ }
1019
+ if (!isSettingsDocument(parsed) || !normalizeBtwSettings(parsed)) {
1020
+ throw invalidSettingsError(settingsPath, "invalid settings shape");
1021
+ }
1022
+ return parsed;
1023
+ }
1024
+ async function readSettingsContents(settingsPath) {
1025
+ const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
1026
+ const handle = await open(settingsPath, flags);
1027
+ try {
1028
+ const descriptorStats = await handle.stat();
1029
+ if (!descriptorStats.isFile()) throw new Error("settings path is not a regular file");
1030
+ if (descriptorStats.size > MAX_SETTINGS_BYTES) {
1031
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
1102
1032
  }
1103
- if (this.fullscreenCreated && !this.fullscreenStopped) {
1104
- this.fullscreenStopped = true;
1105
- try {
1106
- this.fullscreen?.stop({ preserveScreen: true });
1107
- } catch (error) {
1108
- this.cleanupError ??= error;
1109
- }
1033
+ const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
1034
+ let offset = 0;
1035
+ while (offset < buffer.byteLength) {
1036
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
1037
+ if (bytesRead === 0) break;
1038
+ offset += bytesRead;
1110
1039
  }
1111
- if (!this.parentStopped || this.parentRestoreAttempted) return;
1112
- const parentOverlay = this.parentOverlay;
1113
- this.parentOverlay = void 0;
1114
- try {
1115
- parentOverlay?.setHidden(true);
1116
- } catch (error) {
1117
- this.cleanupError ??= error;
1040
+ if (offset > MAX_SETTINGS_BYTES) {
1041
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
1118
1042
  }
1119
1043
  try {
1120
- this.parentRestoreAttempted = true;
1121
- this.parent.start();
1122
- this.parent.renderNow(false);
1123
- } catch (error) {
1124
- this.cleanupError ??= error;
1044
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer.subarray(0, offset));
1045
+ } catch {
1046
+ throw new Error("settings file is not valid UTF-8");
1125
1047
  }
1048
+ } finally {
1049
+ await handle.close();
1126
1050
  }
1127
- createContext() {
1128
- const ui = new Proxy(this.ctx.ui, {
1129
- get: (target, property) => {
1130
- if (property === "custom") {
1131
- return (factory, options) => this.showCustom(factory, options);
1132
- }
1133
- if (property === "notify") {
1134
- return (message, level) => {
1135
- target.notify(message, level);
1136
- const display = sanitizeSingleLine(message);
1137
- if (display) this.fullscreen?.flash?.(display);
1138
- };
1139
- }
1140
- const value = Reflect.get(target, property, target);
1141
- return typeof value === "function" ? value.bind(target) : value;
1142
- }
1143
- });
1144
- const signal = this.ctx.signal ? AbortSignal.any([this.ctx.signal, this.lifetimeController.signal]) : this.lifetimeController.signal;
1145
- return new Proxy(this.ctx, {
1146
- get: (target, property) => {
1147
- if (property === "ui") return ui;
1148
- if (property === "signal") return signal;
1149
- return Reflect.get(target, property, target);
1150
- }
1051
+ }
1052
+ async function publishSettings(settingsPath, document, signal, beforeRename) {
1053
+ signal?.throwIfAborted();
1054
+ const contents = `${JSON.stringify(document, null, 2)}
1055
+ `;
1056
+ if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
1057
+ throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
1058
+ }
1059
+ const directory = dirname(settingsPath);
1060
+ await mkdir(directory, { recursive: true });
1061
+ signal?.throwIfAborted();
1062
+ const temporaryPath = join(directory, `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`);
1063
+ try {
1064
+ await writeFile(temporaryPath, contents, {
1065
+ encoding: "utf8",
1066
+ flag: "wx",
1067
+ mode: 384,
1068
+ signal
1151
1069
  });
1070
+ await beforeRename?.(temporaryPath, settingsPath);
1071
+ signal?.throwIfAborted();
1072
+ await rename(temporaryPath, settingsPath);
1073
+ } catch (error) {
1074
+ await rm(temporaryPath, { force: true }).catch(() => void 0);
1075
+ throw error;
1152
1076
  }
1153
- showCustom(factory, options) {
1154
- const fullscreen = this.fullscreen;
1155
- if (!fullscreen || this.disposed || this.finished) {
1156
- return Promise.reject(new FullscreenUiDisposedError());
1157
- }
1158
- if (this.cancelActiveCustom) {
1159
- return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
1077
+ }
1078
+ function applyBtwSettingsPatch(current, patch) {
1079
+ const updated = { ...current };
1080
+ if (patch.keybindings) {
1081
+ const keys = isSettingsDocument(current.keybindings) ? { ...current.keybindings } : {};
1082
+ for (const action of BTW_SHORTCUT_ACTIONS) {
1083
+ if (!Object.hasOwn(patch.keybindings, action)) continue;
1084
+ if (patch.keybindings[action] === void 0) delete keys[action];
1085
+ else keys[action] = patch.keybindings[action];
1160
1086
  }
1161
- return new Promise((resolve, reject) => {
1162
- let component;
1163
- let overlay;
1164
- let mounted = false;
1165
- let layoutMounted = false;
1166
- let factorySettled = false;
1167
- let closed = false;
1168
- let promiseSettled = false;
1169
- let componentDisposed = false;
1170
- let pendingValue;
1171
- let hasPendingValue = false;
1172
- const disposeComponent = () => {
1173
- if (!component || componentDisposed) return;
1174
- componentDisposed = true;
1175
- try {
1176
- component.dispose?.();
1177
- } catch {
1178
- }
1179
- };
1180
- const unmount = () => {
1181
- let cleanupError;
1182
- try {
1183
- if (overlay) overlay.hide();
1184
- else if (mounted && layoutMounted) fullscreen.setLayoutRoot(void 0);
1185
- else if (mounted && component) fullscreen.removeChild(component);
1186
- } catch (error) {
1187
- cleanupError = error;
1188
- }
1189
- if (overlay || mounted) {
1190
- try {
1191
- fullscreen.setFocus(null);
1192
- fullscreen.requestRender();
1193
- } catch (error) {
1194
- cleanupError ??= error;
1195
- }
1196
- }
1197
- disposeComponent();
1198
- if (cleanupError !== void 0) throw cleanupError;
1199
- };
1200
- const complete = () => {
1201
- if (promiseSettled || !hasPendingValue) return;
1202
- promiseSettled = true;
1203
- this.cancelActiveCustom = void 0;
1204
- this.hardCancelActiveCustom = void 0;
1205
- if (!factorySettled) {
1206
- resolve(pendingValue);
1207
- return;
1208
- }
1209
- try {
1210
- unmount();
1211
- resolve(pendingValue);
1212
- } catch (error) {
1213
- reject(error);
1214
- }
1215
- };
1216
- const close = (value) => {
1217
- if (closed || promiseSettled) return;
1218
- closed = true;
1219
- pendingValue = value;
1220
- hasPendingValue = true;
1221
- complete();
1222
- };
1223
- const fail = (error) => {
1224
- if (promiseSettled) return;
1225
- closed = true;
1226
- promiseSettled = true;
1227
- this.cancelActiveCustom = void 0;
1228
- this.hardCancelActiveCustom = void 0;
1229
- try {
1230
- unmount();
1231
- reject(error);
1232
- } catch (cleanupError) {
1233
- reject(cleanupError);
1234
- }
1235
- };
1236
- this.cancelActiveCustom = () => {
1237
- if (promiseSettled) return;
1238
- disposeComponent();
1239
- if (!promiseSettled) fail(new FullscreenUiDisposedError());
1240
- };
1241
- this.hardCancelActiveCustom = () => {
1242
- if (promiseSettled) return;
1243
- try {
1244
- component?.handleInput?.("");
1245
- } catch (error) {
1246
- fail(error);
1247
- return;
1248
- }
1249
- this.cancelActiveCustom?.();
1250
- };
1251
- let created;
1252
- try {
1253
- created = factory(fullscreen, this.theme, this.keybindings, close);
1254
- } catch (error) {
1255
- factorySettled = true;
1256
- fail(error);
1257
- return;
1258
- }
1259
- Promise.resolve(created).then((value) => {
1260
- component = value;
1261
- factorySettled = true;
1262
- if (promiseSettled) {
1263
- disposeComponent();
1264
- return;
1265
- }
1266
- if (closed) {
1267
- complete();
1268
- return;
1269
- }
1270
- if (options?.overlay) {
1271
- const overlayOptions = typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
1272
- overlay = fullscreen.showOverlay(component, overlayOptions);
1273
- options.onHandle?.(overlay);
1274
- } else {
1275
- fullscreen.clear();
1276
- mounted = true;
1277
- if (isFullscreenLayoutComponent(component)) {
1278
- layoutMounted = true;
1279
- fullscreen.setLayoutRoot(component.getFullscreenLayout());
1280
- } else {
1281
- fullscreen.addChild(component);
1282
- }
1283
- fullscreen.setFocus(component);
1284
- fullscreen.requestRender();
1285
- }
1286
- }).catch(fail);
1287
- });
1087
+ if (Object.keys(keys).length) updated.keybindings = keys;
1088
+ else delete updated.keybindings;
1288
1089
  }
1289
- };
1290
- function isFullscreenLayoutComponent(component) {
1291
- return "getFullscreenLayout" in component && typeof component.getFullscreenLayout === "function";
1090
+ if (Object.hasOwn(patch, "model")) {
1091
+ if (patch.model === void 0) delete updated.model;
1092
+ else updated.model = patch.model;
1093
+ }
1094
+ if (Object.hasOwn(patch, "thinkingLevel")) {
1095
+ if (patch.thinkingLevel === void 0) delete updated.thinkingLevel;
1096
+ else updated.thinkingLevel = patch.thinkingLevel;
1097
+ }
1098
+ if (Object.hasOwn(patch, "rememberThinkingLevelChanges")) {
1099
+ updated.rememberThinkingLevelChanges = patch.rememberThinkingLevelChanges;
1100
+ }
1101
+ if (Object.hasOwn(patch, "fullscreenCopyOnSelect")) {
1102
+ if (patch.fullscreenCopyOnSelect === void 0) delete updated.fullscreenCopyOnSelect;
1103
+ else updated.fullscreenCopyOnSelect = patch.fullscreenCopyOnSelect;
1104
+ }
1105
+ if (Object.hasOwn(patch, "layout")) {
1106
+ if (patch.layout === void 0) delete updated.layout;
1107
+ else updated.layout = patch.layout;
1108
+ }
1109
+ if (Object.hasOwn(patch, "sidePaneRatio")) {
1110
+ if (patch.sidePaneRatio === void 0) delete updated.sidePaneRatio;
1111
+ else updated.sidePaneRatio = patch.sidePaneRatio;
1112
+ }
1113
+ return updated;
1292
1114
  }
1293
-
1294
- // src/main-tree-picker.ts
1295
- import {
1296
- copyToClipboard,
1297
- TreeSelectorComponent
1298
- } from "@earendil-works/pi-coding-agent";
1299
- import { Key as Key3, matchesKey as matchesKey3 } from "@earendil-works/pi-tui";
1300
-
1301
- // src/menu.ts
1302
- import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
1303
- import {
1304
- BorderedLoader
1305
- } from "@earendil-works/pi-coding-agent";
1306
-
1307
- // src/settings.ts
1308
- import { randomUUID } from "node:crypto";
1309
- import { constants } from "node:fs";
1310
- import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
1311
- import { basename, dirname, join } from "node:path";
1312
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
1313
-
1314
- // src/side-thread.ts
1315
- var BTW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
1316
- function createSideThread(conversationContext) {
1317
- return { conversationContext, turns: [] };
1115
+ function isSettingsDocument(value) {
1116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1318
1117
  }
1319
- function buildSideThreadMessages(thread, question) {
1320
- const answeredTurns = thread.turns.filter(
1321
- (turn) => turn.kind === "answered"
1322
- );
1323
- const messages = [];
1324
- if (answeredTurns.length === 0) {
1325
- messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
1326
- return messages;
1118
+ function isBtwThinkingLevel(value) {
1119
+ return BTW_THINKING_LEVELS.includes(value);
1120
+ }
1121
+ function isBtwLayout(value) {
1122
+ return BTW_LAYOUTS.includes(value);
1123
+ }
1124
+ function isBtwSidePaneRatio(value) {
1125
+ return typeof value === "number" && Number.isFinite(value) && value >= MIN_BTW_SIDE_PANE_RATIO && value <= MAX_BTW_SIDE_PANE_RATIO;
1126
+ }
1127
+ function invalidSettingsError(settingsPath, reason) {
1128
+ return new Error(`pi-btw settings at ${settingsPath} are invalid: ${reason}`);
1129
+ }
1130
+ function isNodeError(error) {
1131
+ return error instanceof Error && "code" in error;
1132
+ }
1133
+ function formatError2(error) {
1134
+ return error instanceof Error ? error.message : String(error);
1135
+ }
1136
+
1137
+ // src/workspace-layout.ts
1138
+ var MIN_BTW_SPLIT_COLUMNS = 80;
1139
+ var PANE_DIVIDER_COLUMNS = 1;
1140
+ var SGR_MOUSE_PRESS_PATTERN = new RegExp("^\\u001b\\[<(\\d+);(\\d+);\\d+M$");
1141
+ var BtwMainThreadInput = class {
1142
+ constructor(initialTarget, resolveTarget, requestRender) {
1143
+ this.resolveTarget = resolveTarget;
1144
+ this.requestRender = requestRender;
1145
+ this.target = hasInput(initialTarget) ? initialTarget : void 0;
1146
+ }
1147
+ resolveTarget;
1148
+ requestRender;
1149
+ target;
1150
+ _focused = false;
1151
+ disposed = false;
1152
+ get focused() {
1153
+ return this._focused;
1154
+ }
1155
+ set focused(value) {
1156
+ this._focused = value;
1157
+ if (this.target && isFocusable(this.target)) this.target.focused = value;
1158
+ }
1159
+ get wantsKeyRelease() {
1160
+ return this.target?.wantsKeyRelease ?? false;
1161
+ }
1162
+ render() {
1163
+ return [];
1164
+ }
1165
+ handleInput(data) {
1166
+ if (this.disposed) return;
1167
+ this.refreshTarget();
1168
+ this.target?.handleInput?.(data);
1169
+ this.refreshTarget();
1170
+ this.requestRender();
1171
+ }
1172
+ refreshTarget() {
1173
+ if (this.disposed) return;
1174
+ const next = this.resolveTarget();
1175
+ if (!hasInput(next) || next === this.target) return;
1176
+ if (this._focused && this.target && isFocusable(this.target)) this.target.focused = false;
1177
+ this.target = next;
1178
+ if (this._focused && isFocusable(next)) next.focused = true;
1179
+ }
1180
+ invalidate() {
1181
+ }
1182
+ dispose() {
1183
+ if (this.disposed) return;
1184
+ this.disposed = true;
1185
+ if (this._focused && this.target && isFocusable(this.target)) this.target.focused = false;
1186
+ this.target = void 0;
1187
+ this._focused = false;
1188
+ }
1189
+ };
1190
+ var BtwSplitPane = class {
1191
+ constructor(options) {
1192
+ this.options = options;
1193
+ this.sidePaneRatio = clampSidePaneRatio(options.sidePaneRatio ?? DEFAULT_BTW_SIDE_PANE_RATIO);
1194
+ this.persistedSidePaneRatio = this.sidePaneRatio;
1195
+ this.mainPane = new MainThreadPane(options.mainThread, options.mainLayout, options.theme, options.terminalRows);
1196
+ this.viewportRouter = new PaneViewportRouter(
1197
+ options.sideScrollView ?? findPrimaryScrollView(options.sideLayout),
1198
+ this.mainPane.getPrimaryScrollView(),
1199
+ options.setViewportTarget
1200
+ );
1201
+ this.viewportRouter.activate("side");
1202
+ const separator = {
1203
+ render: (width) => Array.from({ length: Math.max(1, options.terminalRows()) }, () => truncateToWidth2(this.renderDivider(), width)),
1204
+ handleMouse: (event) => this.handleDividerMouse(event),
1205
+ invalidate() {
1206
+ }
1207
+ };
1208
+ const side = options.sideLayout;
1209
+ const main = this.mainPane.getLayout();
1210
+ this.layoutRoot = options.layout === "left-pane" ? new ResponsivePaneRow(
1211
+ side,
1212
+ separator,
1213
+ main,
1214
+ 0,
1215
+ () => this.sidePaneRatio,
1216
+ (width) => this.handleViewportWidth(width)
1217
+ ) : new ResponsivePaneRow(
1218
+ main,
1219
+ separator,
1220
+ side,
1221
+ 2,
1222
+ () => this.sidePaneRatio,
1223
+ (width) => this.handleViewportWidth(width)
1224
+ );
1225
+ }
1226
+ options;
1227
+ pasteGuard = new BtwPasteGuard();
1228
+ mainPane;
1229
+ layoutRoot;
1230
+ viewportRouter;
1231
+ activePane = "side";
1232
+ sidePaneRatio;
1233
+ persistedSidePaneRatio;
1234
+ dividerDragStartRatio;
1235
+ focusGeneration = 0;
1236
+ ratioSaveGeneration = 0;
1237
+ confirmedRatioSaveGeneration = 0;
1238
+ failedRatioSaveGenerationDuringDrag;
1239
+ disposed = false;
1240
+ getFullscreenLayout() {
1241
+ return this.layoutRoot;
1242
+ }
1243
+ handleTerminalInput(data) {
1244
+ if (this.disposed) return false;
1245
+ const pasted = this.pasteGuard.consume(data);
1246
+ if (this.options.hasFocusedOverlay()) return false;
1247
+ const width = this.terminalColumns();
1248
+ if (width < MIN_BTW_SPLIT_COLUMNS && this.activePane !== "side") this.activatePane("side");
1249
+ if (pasted) {
1250
+ this.forwardPastedInput(data);
1251
+ return true;
1252
+ }
1253
+ if (width < MIN_BTW_SPLIT_COLUMNS) return false;
1254
+ const pane = paneForMouseClick(data, width, this.options.layout, this.sidePaneRatio);
1255
+ if (pane) this.queuePaneFocus(pane);
1256
+ return false;
1257
+ }
1258
+ render(width) {
1259
+ if (width <= 0) return [];
1260
+ const safeWidth = Math.max(1, width);
1261
+ this.handleViewportWidth(safeWidth);
1262
+ if (safeWidth < MIN_BTW_SPLIT_COLUMNS) {
1263
+ return this.options.sideComponent.render(safeWidth).map((line) => truncateToWidth2(line, safeWidth));
1264
+ }
1265
+ const { sideWidth, mainWidth } = paneWidths(safeWidth, this.options.layout, this.sidePaneRatio);
1266
+ const sideLines = this.options.sideComponent.render(sideWidth);
1267
+ const mainLines = this.mainPane.render(mainWidth);
1268
+ const rows = Math.max(1, this.options.terminalRows());
1269
+ const separator = this.renderDivider();
1270
+ const lines = [];
1271
+ for (let index = 0; index < rows; index += 1) {
1272
+ const sideLine = padLine(sideLines[index] ?? "", sideWidth);
1273
+ const mainLine = padLine(mainLines[index] ?? "", mainWidth);
1274
+ lines.push(
1275
+ this.options.layout === "left-pane" ? `${sideLine}${separator}${mainLine}` : `${mainLine}${separator}${sideLine}`
1276
+ );
1277
+ }
1278
+ return lines.map((line) => truncateToWidth2(line, safeWidth));
1327
1279
  }
1328
- const [first, ...rest] = answeredTurns;
1329
- messages.push(createUserMessage(buildUserPrompt(first.question, thread.conversationContext)), first.response);
1330
- for (const turn of rest) {
1331
- messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
1280
+ invalidate() {
1281
+ this.layoutRoot.invalidate();
1332
1282
  }
1333
- messages.push(createUserMessage(buildFollowUpPrompt(question)));
1334
- return messages;
1335
- }
1336
- async function completeSideThreadTurn({
1337
- thread,
1338
- model,
1339
- question,
1340
- thinkingLevel,
1341
- auth,
1342
- signal,
1343
- completeSimple,
1344
- sessionId
1345
- }) {
1346
- if (signal?.aborted) return { kind: "aborted" };
1347
- try {
1348
- const response = await completeSimple(
1349
- model,
1350
- { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
1351
- buildStreamOptions(
1352
- auth,
1353
- { thinkingLevel, signal, model, sessionId },
1354
- completeSimple.appliesRequestHeaderTransforms === true
1355
- )
1356
- );
1357
- if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
1358
- if (!isAssistantMessage(response)) {
1359
- return { kind: "error", message: "The side model returned a malformed response." };
1283
+ dispose() {
1284
+ if (this.disposed) return;
1285
+ this.disposed = true;
1286
+ this.focusGeneration += 1;
1287
+ this.dividerDragStartRatio = void 0;
1288
+ this.failedRatioSaveGenerationDuringDrag = void 0;
1289
+ this.viewportRouter.dispose();
1290
+ }
1291
+ renderDivider() {
1292
+ return this.options.theme.fg("borderMuted", "\u2502");
1293
+ }
1294
+ handleDividerMouse(event) {
1295
+ if (this.disposed) return void 0;
1296
+ if (this.options.hasFocusedOverlay()) {
1297
+ return { handled: true, render: this.cancelDividerDrag() };
1360
1298
  }
1361
- if (response.stopReason === "error") {
1362
- return {
1363
- kind: "error",
1364
- message: response.errorMessage ?? "The side model returned an error."
1299
+ if (event.type === "press" && event.button === "left") {
1300
+ if (this.terminalColumns() < MIN_BTW_SPLIT_COLUMNS) return void 0;
1301
+ this.focusGeneration += 1;
1302
+ this.dividerDragStartRatio = this.sidePaneRatio;
1303
+ return { handled: true, capture: true, render: false };
1304
+ }
1305
+ if (this.dividerDragStartRatio === void 0) return void 0;
1306
+ if (event.type === "drag") {
1307
+ const changed = this.updateSidePaneRatio(event.screenX);
1308
+ return { handled: true, render: changed };
1309
+ }
1310
+ if (event.type === "release") {
1311
+ const changed = this.updateSidePaneRatio(event.screenX);
1312
+ const startRatio = this.dividerDragStartRatio;
1313
+ const failedDuringDrag = this.failedRatioSaveGenerationDuringDrag === this.ratioSaveGeneration;
1314
+ this.dividerDragStartRatio = void 0;
1315
+ this.failedRatioSaveGenerationDuringDrag = void 0;
1316
+ if (failedDuringDrag && this.sidePaneRatio === this.persistedSidePaneRatio) {
1317
+ return { handled: true, render: changed };
1318
+ }
1319
+ if (this.sidePaneRatio !== startRatio) {
1320
+ this.persistCurrentSidePaneRatio();
1321
+ return { handled: true, render: changed };
1322
+ }
1323
+ const restored = failedDuringDrag && this.restorePersistedSidePaneRatio();
1324
+ return { handled: true, render: changed || restored };
1325
+ }
1326
+ return { handled: true, render: false };
1327
+ }
1328
+ updateSidePaneRatio(dividerColumn) {
1329
+ const width = this.terminalColumns();
1330
+ if (width < MIN_BTW_SPLIT_COLUMNS) return false;
1331
+ const contentWidth = width - PANE_DIVIDER_COLUMNS;
1332
+ const leftWidth = clamp(Math.floor(dividerColumn), 1, contentWidth - 1);
1333
+ const sideWidth = this.options.layout === "left-pane" ? leftWidth : contentWidth - leftWidth;
1334
+ const ratio = clampSidePaneRatio(roundPaneRatio(sideWidth / contentWidth));
1335
+ if (ratio === this.sidePaneRatio) return false;
1336
+ this.sidePaneRatio = ratio;
1337
+ return true;
1338
+ }
1339
+ cancelDividerDrag() {
1340
+ const startRatio = this.dividerDragStartRatio;
1341
+ if (startRatio === void 0) return false;
1342
+ const failedDuringDrag = this.failedRatioSaveGenerationDuringDrag === this.ratioSaveGeneration;
1343
+ this.dividerDragStartRatio = void 0;
1344
+ this.failedRatioSaveGenerationDuringDrag = void 0;
1345
+ return this.setSidePaneRatio(failedDuringDrag ? this.persistedSidePaneRatio : startRatio);
1346
+ }
1347
+ persistCurrentSidePaneRatio() {
1348
+ const persist = this.options.persistSidePaneRatio;
1349
+ const ratio = this.sidePaneRatio;
1350
+ if (!persist) {
1351
+ this.persistedSidePaneRatio = ratio;
1352
+ return;
1353
+ }
1354
+ this.failedRatioSaveGenerationDuringDrag = void 0;
1355
+ const saveGeneration = ++this.ratioSaveGeneration;
1356
+ void Promise.resolve().then(() => persist(ratio)).then(() => {
1357
+ if (saveGeneration <= this.confirmedRatioSaveGeneration) return;
1358
+ this.confirmedRatioSaveGeneration = saveGeneration;
1359
+ this.persistedSidePaneRatio = ratio;
1360
+ }).catch(() => {
1361
+ if (this.disposed || saveGeneration !== this.ratioSaveGeneration) return;
1362
+ const dragActive = this.dividerDragStartRatio !== void 0;
1363
+ const changed = this.restorePersistedSidePaneRatio();
1364
+ if (dragActive) this.failedRatioSaveGenerationDuringDrag = saveGeneration;
1365
+ if (changed) this.options.requestRender();
1366
+ });
1367
+ }
1368
+ restorePersistedSidePaneRatio() {
1369
+ return this.setSidePaneRatio(this.persistedSidePaneRatio);
1370
+ }
1371
+ setSidePaneRatio(ratio) {
1372
+ if (ratio === this.sidePaneRatio) return false;
1373
+ this.sidePaneRatio = ratio;
1374
+ this.layoutRoot.invalidate();
1375
+ return true;
1376
+ }
1377
+ queuePaneFocus(pane) {
1378
+ const generation = ++this.focusGeneration;
1379
+ queueMicrotask(() => {
1380
+ if (this.disposed || generation !== this.focusGeneration || this.options.hasFocusedOverlay()) return;
1381
+ this.activatePane(this.terminalColumns() < MIN_BTW_SPLIT_COLUMNS ? "side" : pane);
1382
+ });
1383
+ }
1384
+ terminalColumns() {
1385
+ return Math.max(1, Math.floor(this.options.terminalColumns()));
1386
+ }
1387
+ handleViewportWidth(width) {
1388
+ if (this.disposed || width >= MIN_BTW_SPLIT_COLUMNS || this.activePane === "side" || this.options.hasFocusedOverlay()) {
1389
+ return;
1390
+ }
1391
+ this.activatePane("side");
1392
+ }
1393
+ activatePane(pane) {
1394
+ if (this.disposed) return;
1395
+ this.activePane = pane;
1396
+ this.viewportRouter.activate(pane);
1397
+ this.options.setFocus(pane === "side" ? this.options.sideComponent : this.options.mainInput);
1398
+ this.options.requestRender();
1399
+ }
1400
+ forwardPastedInput(data) {
1401
+ const target = this.activePane === "side" ? this.options.sideComponent : this.options.mainInput;
1402
+ target.handleInput?.(data);
1403
+ this.options.requestRender();
1404
+ }
1405
+ };
1406
+ var ResponsivePaneRow = class extends HStack {
1407
+ constructor(left, separator, right, sideIndex, sidePaneRatio, onViewportWidth) {
1408
+ super([
1409
+ { component: left, basis: 1, grow: 0, shrink: 0, minSize: 1 },
1410
+ {
1411
+ component: separator,
1412
+ basis: PANE_DIVIDER_COLUMNS,
1413
+ grow: 0,
1414
+ shrink: 0,
1415
+ minSize: PANE_DIVIDER_COLUMNS
1416
+ },
1417
+ { component: right, basis: 1, grow: 0, shrink: 0, minSize: 1 }
1418
+ ]);
1419
+ this.sideIndex = sideIndex;
1420
+ this.sidePaneRatio = sidePaneRatio;
1421
+ this.onViewportWidth = onViewportWidth;
1422
+ for (const [index, entry] of this.entries.entries()) {
1423
+ entry.visible = (viewport) => {
1424
+ if (index === 0) this.resize(viewport.width);
1425
+ return index === this.sideIndex || viewport.width >= MIN_BTW_SPLIT_COLUMNS;
1365
1426
  };
1366
1427
  }
1367
- const answer = extractAssistantText(response) || "No response received.";
1368
- thread.turns.push({ kind: "answered", question, answer, response });
1369
- return { kind: "answered", response, answer };
1370
- } catch (error) {
1371
- if (signal?.aborted) return { kind: "aborted" };
1372
- return { kind: "error", message: formatError(error) };
1373
1428
  }
1429
+ sideIndex;
1430
+ sidePaneRatio;
1431
+ onViewportWidth;
1432
+ resize(width) {
1433
+ const safeWidth = Math.max(1, Math.floor(width));
1434
+ this.onViewportWidth(safeWidth);
1435
+ if (safeWidth < MIN_BTW_SPLIT_COLUMNS) {
1436
+ for (const [index, entry] of this.entries.entries()) {
1437
+ entry.basis = index === this.sideIndex ? safeWidth : 1;
1438
+ }
1439
+ return;
1440
+ }
1441
+ const layout = this.sideIndex === 0 ? "left-pane" : "right-pane";
1442
+ const { leftWidth, rightWidth } = paneWidths(safeWidth, layout, this.sidePaneRatio());
1443
+ const left = this.entries[0];
1444
+ const separator = this.entries[1];
1445
+ const right = this.entries[2];
1446
+ if (left) left.basis = leftWidth;
1447
+ if (separator) separator.basis = PANE_DIVIDER_COLUMNS;
1448
+ if (right) right.basis = rightWidth;
1449
+ }
1450
+ };
1451
+ var MainThreadPane = class {
1452
+ constructor(mainThread, mainLayout, theme, terminalRows) {
1453
+ this.terminalRows = terminalRows;
1454
+ this.body = {
1455
+ render: (width) => mainThread.render(Math.max(1, width)).map((line) => truncateToWidth2(line, Math.max(1, width))),
1456
+ invalidate() {
1457
+ }
1458
+ };
1459
+ if (mainLayout) {
1460
+ this.layout = mainLayout;
1461
+ this.scroll = findPrimaryScrollView(mainLayout);
1462
+ return;
1463
+ }
1464
+ this.scroll = new ScrollView(this.body, {
1465
+ follow: "end",
1466
+ scrollbar: "auto",
1467
+ scrollbarTrackStyle: (text) => theme.fg("borderMuted", text),
1468
+ scrollbarThumbStyle: (text) => theme.fg("muted", text)
1469
+ });
1470
+ this.layout = this.scroll;
1471
+ }
1472
+ terminalRows;
1473
+ body;
1474
+ layout;
1475
+ scroll;
1476
+ getLayout() {
1477
+ return this.layout;
1478
+ }
1479
+ getPrimaryScrollView() {
1480
+ return this.scroll;
1481
+ }
1482
+ render(width) {
1483
+ if (width <= 0) return [];
1484
+ const safeWidth = Math.max(1, width);
1485
+ const rows = Math.max(1, this.terminalRows());
1486
+ const visible = this.body.render(safeWidth).slice(-rows);
1487
+ return [...Array.from({ length: Math.max(0, rows - visible.length) }, () => ""), ...visible];
1488
+ }
1489
+ };
1490
+ var PaneViewportRouter = class {
1491
+ constructor(side, main, setViewportTarget) {
1492
+ this.side = side;
1493
+ this.main = main;
1494
+ this.setViewportTarget = setViewportTarget;
1495
+ for (const scrollView of [side, main]) {
1496
+ if (scrollView) this.originalPrimary.set(scrollView, scrollView.primary);
1497
+ }
1498
+ }
1499
+ side;
1500
+ main;
1501
+ setViewportTarget;
1502
+ originalPrimary = /* @__PURE__ */ new Map();
1503
+ disposed = false;
1504
+ activate(pane) {
1505
+ if (this.disposed) return;
1506
+ const target = pane === "side" ? this.side : this.main;
1507
+ for (const scrollView of this.originalPrimary.keys()) setScrollViewPrimary(scrollView, scrollView === target);
1508
+ this.setViewportTarget(target);
1509
+ }
1510
+ dispose() {
1511
+ if (this.disposed) return;
1512
+ this.disposed = true;
1513
+ for (const [scrollView, primary] of this.originalPrimary) setScrollViewPrimary(scrollView, primary);
1514
+ this.setViewportTarget(void 0);
1515
+ }
1516
+ };
1517
+ function setScrollViewPrimary(scrollView, primary) {
1518
+ Reflect.set(scrollView, "primary", primary);
1519
+ }
1520
+ function findPrimaryScrollView(root) {
1521
+ const visited = /* @__PURE__ */ new Set();
1522
+ let fallback;
1523
+ let primary;
1524
+ const visit = (component) => {
1525
+ if (visited.has(component)) return;
1526
+ visited.add(component);
1527
+ if (component instanceof ScrollView) {
1528
+ fallback ??= component;
1529
+ if (component.primary) primary = component;
1530
+ }
1531
+ if (!("children" in component) || !Array.isArray(component.children)) return;
1532
+ for (const child of component.children) visit(child);
1533
+ };
1534
+ visit(root);
1535
+ return primary ?? fallback;
1536
+ }
1537
+ function paneForMouseClick(data, terminalColumns, layout, sidePaneRatio) {
1538
+ const match = SGR_MOUSE_PRESS_PATTERN.exec(data);
1539
+ if (!match) return void 0;
1540
+ const button = Number.parseInt(match[1] ?? "", 10);
1541
+ if ((button & 32) !== 0 || (button & 64) !== 0 || (button & 3) !== 0) return void 0;
1542
+ const column = Number.parseInt(match[2] ?? "", 10) - 1;
1543
+ if (!Number.isFinite(column) || column < 0 || column >= terminalColumns) return void 0;
1544
+ const { leftWidth } = paneWidths(terminalColumns, layout, sidePaneRatio);
1545
+ if (column >= leftWidth && column < leftWidth + PANE_DIVIDER_COLUMNS) return void 0;
1546
+ const clickedLeft = column < leftWidth;
1547
+ if (layout === "left-pane") return clickedLeft ? "side" : "main";
1548
+ return clickedLeft ? "main" : "side";
1549
+ }
1550
+ function paneWidths(width, layout, sidePaneRatio) {
1551
+ const contentWidth = Math.max(2, width - PANE_DIVIDER_COLUMNS);
1552
+ const sideWidth = clamp(
1553
+ Math.round(contentWidth * clampSidePaneRatio(sidePaneRatio)),
1554
+ 1,
1555
+ Math.max(1, contentWidth - 1)
1556
+ );
1557
+ const mainWidth = Math.max(1, contentWidth - sideWidth);
1558
+ const leftWidth = layout === "left-pane" ? sideWidth : mainWidth;
1559
+ const rightWidth = layout === "left-pane" ? mainWidth : sideWidth;
1560
+ return { leftWidth, rightWidth, sideWidth, mainWidth };
1374
1561
  }
1375
- function extractAssistantText(response) {
1376
- return response.content.filter(
1377
- (content) => content !== null && typeof content === "object" && content.type === "text" && typeof content.text === "string"
1378
- ).map((content) => content.text).join("\n").trim();
1379
- }
1380
- function isAssistantMessage(value) {
1381
- if (value === null || typeof value !== "object") return false;
1382
- const candidate = value;
1383
- return candidate.role === "assistant" && Array.isArray(candidate.content) && typeof candidate.stopReason === "string";
1384
- }
1385
- function buildUserPrompt(question, conversationContext) {
1386
- return [
1387
- "Answer this side question without modifying the main conversation.",
1388
- "",
1389
- "<side_question>",
1390
- question,
1391
- "</side_question>",
1392
- "",
1393
- "<conversation_context>",
1394
- conversationContext || "No prior conversation context was available.",
1395
- "</conversation_context>"
1396
- ].join("\n");
1562
+ function clampSidePaneRatio(ratio) {
1563
+ return Number.isFinite(ratio) ? clamp(ratio, MIN_BTW_SIDE_PANE_RATIO, MAX_BTW_SIDE_PANE_RATIO) : DEFAULT_BTW_SIDE_PANE_RATIO;
1397
1564
  }
1398
- function buildFollowUpPrompt(question) {
1399
- return ["Continue the same side conversation.", "", "<side_question>", question, "</side_question>"].join("\n");
1565
+ function roundPaneRatio(ratio) {
1566
+ return Math.round(ratio * 1e4) / 1e4;
1400
1567
  }
1401
- function createUserMessage(text) {
1402
- return {
1403
- role: "user",
1404
- content: [{ type: "text", text }],
1405
- timestamp: Date.now()
1406
- };
1568
+ function clamp(value, minimum, maximum) {
1569
+ return Math.max(minimum, Math.min(maximum, value));
1407
1570
  }
1408
- function getOpencodeSessionHeaders(model, sessionId) {
1409
- if (!sessionId || model.provider !== "opencode" && model.provider !== "opencode-go") return void 0;
1410
- return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
1571
+ function hasInput(component) {
1572
+ return component !== null && typeof component.handleInput === "function";
1411
1573
  }
1412
- function mergeSessionHeaders(authHeaders, sessionHeaders) {
1413
- if (!sessionHeaders && !authHeaders) return void 0;
1414
- return { ...sessionHeaders, ...authHeaders };
1574
+ function padLine(line, width) {
1575
+ const truncated = truncateToWidth2(line, width);
1576
+ return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth2(truncated)))}`;
1415
1577
  }
1416
- function buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId }, applyRequestHeaderTransforms) {
1417
- const sessionHeaders = model ? getOpencodeSessionHeaders(model, sessionId) : void 0;
1418
- const options = {
1419
- apiKey: auth?.apiKey,
1420
- headers: applyRequestHeaderTransforms ? auth?.headers : mergeSessionHeaders(auth?.headers, sessionHeaders),
1421
- env: auth?.env,
1422
- signal
1423
- };
1424
- if (applyRequestHeaderTransforms && sessionHeaders) {
1425
- options.transformHeaders = (headers) => mergeSessionHeaders(headers, sessionHeaders) ?? {};
1578
+
1579
+ // src/fullscreen-ui.ts
1580
+ var FullscreenUiDisposedError = class extends Error {
1581
+ constructor() {
1582
+ super("The dedicated pi-btw UI was disposed.");
1583
+ this.name = "FullscreenUiDisposedError";
1426
1584
  }
1427
- if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
1428
- return options;
1429
- }
1430
- function formatError(error) {
1431
- return error instanceof Error ? error.message : String(error);
1585
+ };
1586
+ async function runBtwFullscreen(ctx, run, options = {}, dependencies = {}) {
1587
+ const createTui = dependencies.createTui ?? ((parent, theme, keybindings, fullscreenOptions) => createBtwFullscreenTui(
1588
+ parent,
1589
+ theme,
1590
+ keybindings,
1591
+ fullscreenOptions.copyOnSelect ?? true,
1592
+ dependencies.manualSelectionCopySupported ?? hasManualSelectionCopyApi(),
1593
+ dependencies.openUrl ?? openUrlInBrowser,
1594
+ dependencies.copyToClipboard ?? copyToHostClipboard
1595
+ ));
1596
+ let liveEditorText = ctx.ui.getEditorText();
1597
+ let restoreEditor = false;
1598
+ let host;
1599
+ const outcome = await ctx.ui.custom(
1600
+ (parent, theme, keybindings, done) => {
1601
+ host = new BtwFullscreenHost(
1602
+ parent,
1603
+ theme,
1604
+ keybindings,
1605
+ ctx,
1606
+ run,
1607
+ (value) => {
1608
+ try {
1609
+ liveEditorText = ctx.ui.getEditorText();
1610
+ restoreEditor = true;
1611
+ } catch {
1612
+ }
1613
+ done(value);
1614
+ },
1615
+ createTui,
1616
+ options
1617
+ );
1618
+ return host;
1619
+ },
1620
+ {
1621
+ overlay: true,
1622
+ onHandle: (handle) => host?.setParentOverlay(handle)
1623
+ }
1624
+ );
1625
+ if (restoreEditor) {
1626
+ try {
1627
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
1628
+ } catch {
1629
+ }
1630
+ }
1631
+ if (outcome.kind === "failed") throw outcome.error;
1632
+ return outcome.value;
1432
1633
  }
1433
- var SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
1434
-
1435
- Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
1436
-
1437
- // src/settings.ts
1438
- var BTW_SETTINGS_FILE = "pi-btw.json";
1439
- var DEFAULT_FULLSCREEN_COPY_ON_SELECT = true;
1440
- var DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
1441
- var MAX_SETTINGS_BYTES = 64 * 1024;
1442
- var mutationQueues = /* @__PURE__ */ new Map();
1443
- function btwSettingsPath() {
1444
- return join(getAgentDir(), BTW_SETTINGS_FILE);
1634
+ var btwInputListeners = /* @__PURE__ */ new WeakMap();
1635
+ function dispatchBtwInput(listeners, data) {
1636
+ let current = data;
1637
+ for (const group of [listeners.beforeAll, listeners.beforeViewport, listeners.regular]) {
1638
+ for (const listener of group) {
1639
+ const result = listener(current);
1640
+ if (result?.consume) return result;
1641
+ if (result?.data !== void 0) current = result.data;
1642
+ }
1643
+ }
1644
+ return current === data ? void 0 : { data: current };
1445
1645
  }
1446
- function normalizeBtwSettings(value) {
1447
- if (!isSettingsDocument(value)) return void 0;
1448
- const settings = {};
1449
- if (Object.hasOwn(value, "keybindings")) {
1450
- const keys = value.keybindings;
1451
- if (!isSettingsDocument(keys)) return void 0;
1452
- settings.keybindings = {};
1453
- for (const action of BTW_SHORTCUT_ACTIONS) {
1454
- if (!Object.hasOwn(keys, action)) continue;
1455
- const key = normalizeBtwKey(keys[action]);
1456
- if (!key) return void 0;
1457
- settings.keybindings[action] = key;
1646
+ var BtwTuiAltScreen = class extends TuiAltScreen {
1647
+ hasFocusedOverlay() {
1648
+ return this.isOverlayFocused();
1649
+ }
1650
+ setViewportTarget(scrollView) {
1651
+ if (!scrollView) return;
1652
+ const layout = Reflect.get(this, "currentLayout");
1653
+ if (layout) layout.primaryScrollView = scrollView;
1654
+ }
1655
+ addInputListener(listener) {
1656
+ let listeners = btwInputListeners.get(this);
1657
+ if (!listeners) {
1658
+ const registeredListeners = {
1659
+ beforeAll: /* @__PURE__ */ new Set(),
1660
+ beforeViewport: /* @__PURE__ */ new Set(),
1661
+ regular: /* @__PURE__ */ new Set()
1662
+ };
1663
+ btwInputListeners.set(this, registeredListeners);
1664
+ super.addInputListener((data) => dispatchBtwInput(registeredListeners, data));
1665
+ listeners = registeredListeners;
1458
1666
  }
1667
+ listeners.regular.add(listener);
1668
+ return () => listeners.regular.delete(listener);
1459
1669
  }
1460
- if (Object.hasOwn(value, "model")) {
1461
- const model = Reflect.get(value, "model");
1462
- if (typeof model !== "string" || !parseBtwModelReference(model)) return void 0;
1463
- settings.model = model;
1464
- }
1465
- if (Object.hasOwn(value, "thinkingLevel")) {
1466
- const thinkingLevel = Reflect.get(value, "thinkingLevel");
1467
- if (!isBtwThinkingLevel(thinkingLevel)) return void 0;
1468
- settings.thinkingLevel = thinkingLevel;
1670
+ addInputListenerBeforeAll(listener) {
1671
+ const listeners = btwInputListeners.get(this);
1672
+ if (!listeners) return super.addInputListener(listener);
1673
+ listeners.beforeAll.add(listener);
1674
+ return () => listeners.beforeAll.delete(listener);
1469
1675
  }
1470
- if (Object.hasOwn(value, "rememberThinkingLevelChanges")) {
1471
- const remember = Reflect.get(value, "rememberThinkingLevelChanges");
1472
- if (typeof remember !== "boolean") return void 0;
1473
- settings.rememberThinkingLevelChanges = remember;
1676
+ addInputListenerBeforeViewport(listener) {
1677
+ const listeners = btwInputListeners.get(this);
1678
+ if (!listeners) return super.addInputListener(listener);
1679
+ listeners.beforeViewport.add(listener);
1680
+ return () => listeners.beforeViewport.delete(listener);
1474
1681
  }
1475
- if (Object.hasOwn(value, "fullscreenCopyOnSelect")) {
1476
- const copyOnSelect = Reflect.get(value, "fullscreenCopyOnSelect");
1477
- if (typeof copyOnSelect !== "boolean") return void 0;
1478
- settings.fullscreenCopyOnSelect = copyOnSelect;
1682
+ removeInputListener(listener) {
1683
+ const listeners = btwInputListeners.get(this);
1684
+ if (!listeners) {
1685
+ super.removeInputListener(listener);
1686
+ return;
1687
+ }
1688
+ listeners.beforeAll.delete(listener);
1689
+ listeners.beforeViewport.delete(listener);
1690
+ listeners.regular.delete(listener);
1479
1691
  }
1480
- return settings;
1692
+ };
1693
+ var BRACKETED_PASTE_START = "\x1B[200~";
1694
+ var BRACKETED_PASTE_END = "\x1B[201~";
1695
+ var ALT_SCREEN_ACTIONS_BEFORE_BOTTOM = [
1696
+ "tui.altScreen.search",
1697
+ "tui.altScreen.searchNext",
1698
+ "tui.altScreen.searchPrevious",
1699
+ "tui.altScreen.searchClose",
1700
+ "tui.altScreen.pageUp",
1701
+ "tui.altScreen.pageDown",
1702
+ "tui.altScreen.halfPageUp",
1703
+ "tui.altScreen.halfPageDown",
1704
+ "tui.altScreen.lineUp",
1705
+ "tui.altScreen.lineDown",
1706
+ "tui.altScreen.previousPrompt",
1707
+ "tui.altScreen.nextPrompt",
1708
+ "tui.altScreen.top"
1709
+ ];
1710
+ var KEY_MODIFIER_ORDER = ["shift", "ctrl", "alt", "super"];
1711
+ var MATCHABLE_SPECIAL_KEYS = /* @__PURE__ */ new Set([
1712
+ "space",
1713
+ "tab",
1714
+ "enter",
1715
+ "backspace",
1716
+ "delete",
1717
+ "insert",
1718
+ "home",
1719
+ "end",
1720
+ "pageup",
1721
+ "pagedown",
1722
+ "up",
1723
+ "down",
1724
+ "left",
1725
+ "right"
1726
+ ]);
1727
+ var MATCHABLE_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?");
1728
+ function normalizedKeyId(key) {
1729
+ const parts = key.toLowerCase().split("+");
1730
+ const base = parts.at(-1);
1731
+ if (!base) return "";
1732
+ const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
1733
+ const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
1734
+ return [...modifiers, normalizedBase].join("+");
1481
1735
  }
1482
- function parseBtwModelReference(reference) {
1483
- if (/[\s\p{Cc}]/u.test(reference)) return void 0;
1484
- const separator = reference.indexOf("/");
1485
- if (separator <= 0 || separator === reference.length - 1) return void 0;
1486
- return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
1736
+ function formatEffectiveKeyLabel(key) {
1737
+ const parts = key.split("+");
1738
+ const base = parts.at(-1);
1739
+ if (base === "pageup") parts[parts.length - 1] = "pageUp";
1740
+ if (base === "pagedown") parts[parts.length - 1] = "pageDown";
1741
+ return formatKeyLabel2(parts.join("+"));
1487
1742
  }
1488
- function effectiveFullscreenCopyOnSelect(settings) {
1489
- return settings.fullscreenCopyOnSelect ?? DEFAULT_FULLSCREEN_COPY_ON_SELECT;
1743
+ function canMatchKeyInput(key) {
1744
+ const parts = key.split("+");
1745
+ const base = parts.at(-1) ?? "";
1746
+ const modifiers = parts.slice(0, -1);
1747
+ if (base === "escape") return modifiers.length === 0;
1748
+ if (base === "clear") {
1749
+ return modifiers.length === 0 || modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl");
1750
+ }
1751
+ if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
1752
+ return MATCHABLE_SPECIAL_KEYS.has(base) || base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base));
1490
1753
  }
1491
- function effectiveRememberThinkingLevelChanges(settings) {
1492
- return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
1754
+ function rawCtrlInput(base) {
1755
+ if (base.length !== 1) return void 0;
1756
+ const rawBase = base === "-" ? "_" : base;
1757
+ if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return void 0;
1758
+ return String.fromCharCode(rawBase.charCodeAt(0) & 31);
1493
1759
  }
1494
- async function readBtwSettings(settingsPath = btwSettingsPath()) {
1495
- await awaitBtwSettingsWrites(settingsPath);
1496
- return readBtwSettingsUncoordinated(settingsPath);
1760
+ function legacyRawInput(key) {
1761
+ const parts = key.split("+");
1762
+ const base = parts.at(-1) ?? "";
1763
+ if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
1764
+ if (isKittyProtocolActive2()) return void 0;
1765
+ if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\x1B${base}`;
1766
+ if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
1767
+ const input = rawCtrlInput(base);
1768
+ return input ? `\x1B${input}` : void 0;
1769
+ }
1770
+ return void 0;
1497
1771
  }
1498
- function updateBtwSettings(patch, options = {}) {
1499
- const settingsPath = options.settingsPath ?? btwSettingsPath();
1500
- return enqueueMutation(settingsPath, async () => {
1501
- options.signal?.throwIfAborted();
1502
- const current = await readSettingsDocumentForUpdate(settingsPath);
1503
- options.signal?.throwIfAborted();
1504
- options.validateCurrent?.(normalizeBtwSettings(current) ?? {});
1505
- const updated = applyBtwSettingsPatch(current, patch);
1506
- const settings = normalizeBtwSettings(updated);
1507
- if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
1508
- await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
1509
- return settings;
1510
- });
1772
+ function keyInputIdentity(key) {
1773
+ const identity = normalizedKeyId(key);
1774
+ const input = legacyRawInput(identity);
1775
+ return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
1511
1776
  }
1512
- async function awaitBtwSettingsWrites(settingsPath = btwSettingsPath()) {
1513
- await mutationQueues.get(settingsPath);
1777
+ function hasManualSelectionCopyApi() {
1778
+ return typeof TuiAltScreen.prototype.hasActiveSelection === "function" && typeof TuiAltScreen.prototype.copyActiveSelectionToClipboard === "function";
1514
1779
  }
1515
- function enqueueMutation(settingsPath, mutation) {
1516
- const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
1517
- const result = previous.then(mutation, mutation);
1518
- const settled = result.then(
1519
- () => void 0,
1520
- () => void 0
1521
- );
1522
- mutationQueues.set(settingsPath, settled);
1523
- void settled.finally(() => {
1524
- if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
1780
+ function createBtwFullscreenTui(parent, theme, keybindings, copyOnSelect, manualSelectionCopySupported, openUrl, copyToClipboard2) {
1781
+ if (!copyOnSelect && !manualSelectionCopySupported) {
1782
+ throw new Error(
1783
+ "Manual fullscreen selection copying is unavailable in this Pi version; update Pi or enable automatic selection copying."
1784
+ );
1785
+ }
1786
+ const styleSearchMatch = (text) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
1787
+ const fullscreen = new BtwTuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), void 0, {
1788
+ mouse: true,
1789
+ copyOnSelect,
1790
+ searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
1791
+ scrollToEndIndicator: () => {
1792
+ const unavailableKeyIdentities = /* @__PURE__ */ new Set([keyInputIdentity(Key2.ctrl("c"))]);
1793
+ for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
1794
+ for (const actionKey of keybindings.getKeys(action)) {
1795
+ unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
1796
+ }
1797
+ }
1798
+ if (!copyOnSelect) {
1799
+ for (const copyKey of keybindings.getKeys("app.message.copy")) {
1800
+ unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
1801
+ }
1802
+ }
1803
+ const key = keybindings.getKeys("tui.altScreen.bottom").map((candidate) => keyInputIdentity(String(candidate))).find(
1804
+ (identity) => identity && canMatchKeyInput(identity) && !unavailableKeyIdentities.has(identity) && formatEffectiveKeyLabel(identity)
1805
+ );
1806
+ const label = theme.fg("text", " \u2193 Jump to latest message");
1807
+ const shortcut = key ? theme.fg("muted", ` \xB7 ${formatEffectiveKeyLabel(key)}`) : "";
1808
+ return theme.bg("selectedBg", `${label}${shortcut} `);
1809
+ },
1810
+ searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
1811
+ openUrl,
1812
+ copySelection: async (text) => {
1813
+ try {
1814
+ await copyToClipboard2(text);
1815
+ return true;
1816
+ } catch {
1817
+ return false;
1818
+ }
1819
+ }
1525
1820
  });
1526
- return result;
1821
+ if (!copyOnSelect) {
1822
+ let isInBracketedPaste = false;
1823
+ fullscreen.addInputListenerBeforeViewport((data) => {
1824
+ const wasInBracketedPaste = isInBracketedPaste;
1825
+ const startsBracketedPaste = data.includes(BRACKETED_PASTE_START);
1826
+ if (startsBracketedPaste) isInBracketedPaste = true;
1827
+ if (isInBracketedPaste && data.includes(BRACKETED_PASTE_END)) {
1828
+ isInBracketedPaste = false;
1829
+ }
1830
+ if (wasInBracketedPaste || startsBracketedPaste || fullscreen.hasFocusedOverlay() || isKeyRelease2(data) || !keybindings.matches(data, "app.message.copy")) {
1831
+ return void 0;
1832
+ }
1833
+ if (!fullscreen.hasActiveSelection()) {
1834
+ fullscreen.flash("No selection to copy");
1835
+ return { consume: true };
1836
+ }
1837
+ void fullscreen.copyActiveSelectionToClipboard().catch(() => fullscreen.flash("Copy failed"));
1838
+ return { consume: true };
1839
+ });
1840
+ }
1841
+ return fullscreen;
1527
1842
  }
1528
- async function readBtwSettingsUncoordinated(settingsPath) {
1529
- let contents;
1530
- try {
1531
- contents = await readSettingsContents(settingsPath);
1532
- } catch (error) {
1533
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
1534
- return { kind: "invalid", reason: `${settingsPath}: ${formatError2(error)}` };
1843
+ function openUrlInBrowser(target) {
1844
+ const [command, args] = process.platform === "darwin" ? ["open", [target]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", target]] : ["xdg-open", [target]];
1845
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => {
1846
+ }).unref();
1847
+ }
1848
+ var BtwFullscreenHost = class {
1849
+ constructor(parent, theme, keybindings, ctx, run, done, createTui, options) {
1850
+ this.parent = parent;
1851
+ this.theme = theme;
1852
+ this.keybindings = keybindings;
1853
+ this.ctx = ctx;
1854
+ this.run = run;
1855
+ this.done = done;
1856
+ this.createTui = createTui;
1857
+ this.options = options;
1858
+ const initialMainInput = getFocusedComponent(parent);
1859
+ this.mainThreadInput = new BtwMainThreadInput(
1860
+ initialMainInput,
1861
+ () => {
1862
+ const target = getFocusedComponent(parent);
1863
+ return target === this ? initialMainInput : target;
1864
+ },
1865
+ () => this.fullscreen?.requestRender()
1866
+ );
1867
+ this.sidePaneRatio = options.sidePaneRatio;
1868
+ queueMicrotask(() => void this.start());
1869
+ }
1870
+ parent;
1871
+ theme;
1872
+ keybindings;
1873
+ ctx;
1874
+ run;
1875
+ done;
1876
+ createTui;
1877
+ options;
1878
+ fullscreen;
1879
+ parentOverlay;
1880
+ cancelActiveCustom;
1881
+ hardCancelActiveCustom;
1882
+ removeHardCancelListener;
1883
+ removeUpstreamAbortListener;
1884
+ started = false;
1885
+ disposed = false;
1886
+ finished = false;
1887
+ parentStopped = false;
1888
+ parentRestoreAttempted = false;
1889
+ fullscreenCreated = false;
1890
+ fullscreenStopped = false;
1891
+ parentRestoreQueued = false;
1892
+ parentRestorePromise;
1893
+ cleanupError;
1894
+ removeMainThreadUpdateListener;
1895
+ mainThreadRefreshTimer;
1896
+ mainThreadInput;
1897
+ lifetimeController = new AbortController();
1898
+ pendingSidePaneWrites = /* @__PURE__ */ new Set();
1899
+ sidePaneRatio;
1900
+ sidePaneWriteGeneration = 0;
1901
+ confirmedSidePaneWriteGeneration = 0;
1902
+ setParentOverlay(overlay) {
1903
+ this.parentOverlay = overlay;
1535
1904
  }
1536
- try {
1537
- const settings = normalizeBtwSettings(JSON.parse(contents));
1538
- return settings ? { kind: "loaded", settings } : { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
1539
- } catch {
1540
- return { kind: "invalid", reason: `${settingsPath}: invalid JSON` };
1905
+ render(width) {
1906
+ return [truncateToWidth3(this.theme.fg("muted", "Opening btw side thread\u2026"), width)];
1541
1907
  }
1542
- }
1543
- async function readSettingsDocumentForUpdate(settingsPath) {
1544
- let contents;
1545
- try {
1546
- contents = await readSettingsContents(settingsPath);
1547
- } catch (error) {
1548
- if (isNodeError(error) && error.code === "ENOENT") return {};
1549
- throw invalidSettingsError(settingsPath, formatError2(error));
1908
+ invalidate() {
1550
1909
  }
1551
- let parsed;
1552
- try {
1553
- parsed = JSON.parse(contents);
1554
- } catch {
1555
- throw invalidSettingsError(settingsPath, "invalid JSON");
1910
+ dispose() {
1911
+ if (this.disposed || this.finished) return;
1912
+ this.disposed = true;
1913
+ this.lifetimeController.abort();
1914
+ this.cancelActiveCustom?.();
1556
1915
  }
1557
- if (!isSettingsDocument(parsed) || !normalizeBtwSettings(parsed)) {
1558
- throw invalidSettingsError(settingsPath, "invalid settings shape");
1916
+ async start() {
1917
+ if (this.started || this.finished) return;
1918
+ this.started = true;
1919
+ this.watchUpstreamCancellation();
1920
+ let outcome;
1921
+ try {
1922
+ if (this.disposed) throw new FullscreenUiDisposedError();
1923
+ this.parent.stop({ preserveScreen: true });
1924
+ this.parentStopped = true;
1925
+ if (this.disposed) throw new FullscreenUiDisposedError();
1926
+ this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
1927
+ this.fullscreenCreated = true;
1928
+ this.fullscreen.start();
1929
+ this.watchMainThreadUpdates();
1930
+ const shortcuts = resolveBtwShortcuts(
1931
+ this.options.keybindings,
1932
+ this.keybindings,
1933
+ this.options.copyOnSelect ?? true
1934
+ );
1935
+ setBtwShortcuts(this.fullscreen, shortcuts);
1936
+ let previousWarnings = [];
1937
+ const reportWarnings = () => {
1938
+ const warnings = shortcuts.warnings;
1939
+ for (const warning of warnings) {
1940
+ if (previousWarnings.includes(warning)) continue;
1941
+ try {
1942
+ this.ctx.ui.notify(`Pi BTW: ${warning}`, "warning");
1943
+ } catch {
1944
+ }
1945
+ }
1946
+ previousWarnings = warnings;
1947
+ };
1948
+ const pasteGuard = new BtwPasteGuard();
1949
+ const addHardCancelListener = this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ?? this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ?? this.fullscreen.addInputListener.bind(this.fullscreen);
1950
+ this.removeHardCancelListener = addHardCancelListener((data) => {
1951
+ reportWarnings();
1952
+ if (pasteGuard.consume(data) || !shortcuts.matches(data, "exit")) return void 0;
1953
+ this.disposed = true;
1954
+ this.lifetimeController.abort();
1955
+ try {
1956
+ this.hardCancelActiveCustom?.();
1957
+ } finally {
1958
+ this.queueParentRestore();
1959
+ }
1960
+ return { consume: true };
1961
+ });
1962
+ const value = await this.run(this.createContext());
1963
+ await this.waitForSidePaneWrites();
1964
+ outcome = { kind: "completed", value };
1965
+ } catch (error) {
1966
+ await this.waitForSidePaneWrites();
1967
+ outcome = { kind: "failed", error };
1968
+ }
1969
+ try {
1970
+ this.cancelActiveCustom?.();
1971
+ } catch (error) {
1972
+ this.cleanupError ??= error;
1973
+ }
1974
+ if (this.parentRestorePromise) await this.parentRestorePromise;
1975
+ else this.restoreParent();
1976
+ if (this.cleanupError !== void 0) outcome = { kind: "failed", error: this.cleanupError };
1977
+ this.finished = true;
1978
+ this.done(outcome);
1559
1979
  }
1560
- return parsed;
1561
- }
1562
- async function readSettingsContents(settingsPath) {
1563
- const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
1564
- const handle = await open(settingsPath, flags);
1565
- try {
1566
- const descriptorStats = await handle.stat();
1567
- if (!descriptorStats.isFile()) throw new Error("settings path is not a regular file");
1568
- if (descriptorStats.size > MAX_SETTINGS_BYTES) {
1569
- throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
1980
+ watchUpstreamCancellation() {
1981
+ const signal = this.ctx.signal;
1982
+ if (!signal) return;
1983
+ const onAbort = () => this.dispose();
1984
+ signal.addEventListener("abort", onAbort, { once: true });
1985
+ this.removeUpstreamAbortListener = () => signal.removeEventListener("abort", onAbort);
1986
+ if (signal.aborted) onAbort();
1987
+ }
1988
+ queueParentRestore() {
1989
+ if (this.parentRestoreQueued || this.parentRestoreAttempted) return;
1990
+ this.parentRestoreQueued = true;
1991
+ this.parentRestorePromise = Promise.resolve().then(async () => {
1992
+ try {
1993
+ await this.fullscreen?.terminal.drainInput?.();
1994
+ } catch (error) {
1995
+ this.cleanupError ??= error;
1996
+ }
1997
+ this.parentRestoreQueued = false;
1998
+ this.restoreParent();
1999
+ });
2000
+ }
2001
+ watchMainThreadUpdates() {
2002
+ if ((this.options.layout ?? "fullscreen") === "fullscreen") return;
2003
+ this.removeMainThreadUpdateListener = this.options.subscribeMainThreadUpdates?.(() => {
2004
+ this.mainThreadInput.refreshTarget();
2005
+ if (this.mainThreadRefreshTimer || this.disposed || this.finished) return;
2006
+ this.mainThreadRefreshTimer = setTimeout(() => {
2007
+ this.mainThreadRefreshTimer = void 0;
2008
+ if (!this.disposed && !this.finished) this.fullscreen?.requestRender();
2009
+ }, 0);
2010
+ this.mainThreadRefreshTimer.unref();
2011
+ });
2012
+ }
2013
+ restoreParent() {
2014
+ if (this.mainThreadRefreshTimer) {
2015
+ clearTimeout(this.mainThreadRefreshTimer);
2016
+ this.mainThreadRefreshTimer = void 0;
1570
2017
  }
1571
- const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
1572
- let offset = 0;
1573
- while (offset < buffer.byteLength) {
1574
- const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
1575
- if (bytesRead === 0) break;
1576
- offset += bytesRead;
2018
+ const removeMainThreadUpdateListener = this.removeMainThreadUpdateListener;
2019
+ this.removeMainThreadUpdateListener = void 0;
2020
+ try {
2021
+ removeMainThreadUpdateListener?.();
2022
+ } catch (error) {
2023
+ this.cleanupError ??= error;
1577
2024
  }
1578
- if (offset > MAX_SETTINGS_BYTES) {
1579
- throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
2025
+ this.mainThreadInput.dispose();
2026
+ const removeUpstreamAbortListener = this.removeUpstreamAbortListener;
2027
+ this.removeUpstreamAbortListener = void 0;
2028
+ try {
2029
+ removeUpstreamAbortListener?.();
2030
+ } catch (error) {
2031
+ this.cleanupError ??= error;
1580
2032
  }
2033
+ const removeHardCancelListener = this.removeHardCancelListener;
2034
+ this.removeHardCancelListener = void 0;
1581
2035
  try {
1582
- return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer.subarray(0, offset));
1583
- } catch {
1584
- throw new Error("settings file is not valid UTF-8");
2036
+ removeHardCancelListener?.();
2037
+ } catch (error) {
2038
+ this.cleanupError ??= error;
2039
+ }
2040
+ if (this.fullscreenCreated && !this.fullscreenStopped) {
2041
+ this.fullscreenStopped = true;
2042
+ try {
2043
+ this.fullscreen?.stop({ preserveScreen: true });
2044
+ } catch (error) {
2045
+ this.cleanupError ??= error;
2046
+ }
2047
+ }
2048
+ if (!this.parentStopped || this.parentRestoreAttempted) return;
2049
+ const parentOverlay = this.parentOverlay;
2050
+ this.parentOverlay = void 0;
2051
+ try {
2052
+ parentOverlay?.setHidden(true);
2053
+ } catch (error) {
2054
+ this.cleanupError ??= error;
2055
+ }
2056
+ try {
2057
+ this.parentRestoreAttempted = true;
2058
+ this.parent.start();
2059
+ this.parent.renderNow(false);
2060
+ } catch (error) {
2061
+ this.cleanupError ??= error;
1585
2062
  }
1586
- } finally {
1587
- await handle.close();
1588
2063
  }
1589
- }
1590
- async function publishSettings(settingsPath, document, signal, beforeRename) {
1591
- signal?.throwIfAborted();
1592
- const contents = `${JSON.stringify(document, null, 2)}
1593
- `;
1594
- if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
1595
- throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
2064
+ createContext() {
2065
+ const ui = new Proxy(this.ctx.ui, {
2066
+ get: (target, property) => {
2067
+ if (property === "custom") {
2068
+ return (factory, options) => this.showCustom(factory, options);
2069
+ }
2070
+ if (property === "notify") {
2071
+ return (message, level) => {
2072
+ target.notify(message, level);
2073
+ const display = sanitizeSingleLine(message);
2074
+ if (display) this.fullscreen?.flash?.(display);
2075
+ };
2076
+ }
2077
+ const value = Reflect.get(target, property, target);
2078
+ return typeof value === "function" ? value.bind(target) : value;
2079
+ }
2080
+ });
2081
+ const signal = this.ctx.signal ? AbortSignal.any([this.ctx.signal, this.lifetimeController.signal]) : this.lifetimeController.signal;
2082
+ return new Proxy(this.ctx, {
2083
+ get: (target, property) => {
2084
+ if (property === "ui") return ui;
2085
+ if (property === "signal") return signal;
2086
+ return Reflect.get(target, property, target);
2087
+ }
2088
+ });
1596
2089
  }
1597
- const directory = dirname(settingsPath);
1598
- await mkdir(directory, { recursive: true });
1599
- signal?.throwIfAborted();
1600
- const temporaryPath = join(directory, `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`);
1601
- try {
1602
- await writeFile(temporaryPath, contents, {
1603
- encoding: "utf8",
1604
- flag: "wx",
1605
- mode: 384,
1606
- signal
2090
+ showCustom(factory, options) {
2091
+ const fullscreen = this.fullscreen;
2092
+ if (!fullscreen || this.disposed || this.finished) {
2093
+ return Promise.reject(new FullscreenUiDisposedError());
2094
+ }
2095
+ if (this.cancelActiveCustom) {
2096
+ return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
2097
+ }
2098
+ return new Promise((resolve, reject) => {
2099
+ let component;
2100
+ let overlay;
2101
+ let splitPane;
2102
+ let removePaneFocusListener;
2103
+ let mounted = false;
2104
+ let layoutMounted = false;
2105
+ let factorySettled = false;
2106
+ let closed = false;
2107
+ let promiseSettled = false;
2108
+ let componentDisposed = false;
2109
+ let pendingValue;
2110
+ let hasPendingValue = false;
2111
+ const disposeComponent = () => {
2112
+ if (!component || componentDisposed) return;
2113
+ componentDisposed = true;
2114
+ try {
2115
+ component.dispose?.();
2116
+ } catch {
2117
+ }
2118
+ };
2119
+ const unmount = () => {
2120
+ let cleanupError;
2121
+ const removeFocusListener = removePaneFocusListener;
2122
+ removePaneFocusListener = void 0;
2123
+ try {
2124
+ removeFocusListener?.();
2125
+ } catch (error) {
2126
+ cleanupError = error;
2127
+ }
2128
+ splitPane?.dispose();
2129
+ splitPane = void 0;
2130
+ try {
2131
+ if (overlay) overlay.hide();
2132
+ else if (mounted && layoutMounted) fullscreen.setLayoutRoot(void 0);
2133
+ else if (mounted && component) fullscreen.removeChild(component);
2134
+ } catch (error) {
2135
+ cleanupError ??= error;
2136
+ }
2137
+ if (overlay || mounted) {
2138
+ try {
2139
+ fullscreen.setFocus(null);
2140
+ fullscreen.requestRender();
2141
+ } catch (error) {
2142
+ cleanupError ??= error;
2143
+ }
2144
+ }
2145
+ disposeComponent();
2146
+ if (cleanupError !== void 0) throw cleanupError;
2147
+ };
2148
+ const complete = () => {
2149
+ if (promiseSettled || !hasPendingValue) return;
2150
+ promiseSettled = true;
2151
+ this.cancelActiveCustom = void 0;
2152
+ this.hardCancelActiveCustom = void 0;
2153
+ if (!factorySettled) {
2154
+ resolve(pendingValue);
2155
+ return;
2156
+ }
2157
+ try {
2158
+ unmount();
2159
+ resolve(pendingValue);
2160
+ } catch (error) {
2161
+ reject(error);
2162
+ }
2163
+ };
2164
+ const close = (value) => {
2165
+ if (closed || promiseSettled) return;
2166
+ closed = true;
2167
+ pendingValue = value;
2168
+ hasPendingValue = true;
2169
+ complete();
2170
+ };
2171
+ const fail = (error) => {
2172
+ if (promiseSettled) return;
2173
+ closed = true;
2174
+ promiseSettled = true;
2175
+ this.cancelActiveCustom = void 0;
2176
+ this.hardCancelActiveCustom = void 0;
2177
+ try {
2178
+ unmount();
2179
+ reject(error);
2180
+ } catch (cleanupError) {
2181
+ reject(cleanupError);
2182
+ }
2183
+ };
2184
+ this.cancelActiveCustom = () => {
2185
+ if (promiseSettled) return;
2186
+ disposeComponent();
2187
+ if (!promiseSettled) fail(new FullscreenUiDisposedError());
2188
+ };
2189
+ this.hardCancelActiveCustom = () => {
2190
+ if (promiseSettled) return;
2191
+ try {
2192
+ component?.handleInput?.("");
2193
+ } catch (error) {
2194
+ fail(error);
2195
+ return;
2196
+ }
2197
+ this.cancelActiveCustom?.();
2198
+ };
2199
+ let created;
2200
+ try {
2201
+ created = factory(fullscreen, this.theme, this.keybindings, close);
2202
+ } catch (error) {
2203
+ factorySettled = true;
2204
+ fail(error);
2205
+ return;
2206
+ }
2207
+ Promise.resolve(created).then(async (value) => {
2208
+ component = value;
2209
+ factorySettled = true;
2210
+ if (promiseSettled) {
2211
+ disposeComponent();
2212
+ return;
2213
+ }
2214
+ if (closed) {
2215
+ complete();
2216
+ return;
2217
+ }
2218
+ const workspaceLayout = this.options.layout ?? "fullscreen";
2219
+ if (!options?.overlay && workspaceLayout !== "fullscreen") {
2220
+ await this.waitForSidePaneWrites();
2221
+ if (promiseSettled) {
2222
+ disposeComponent();
2223
+ return;
2224
+ }
2225
+ if (closed) {
2226
+ complete();
2227
+ return;
2228
+ }
2229
+ if (this.disposed || this.finished || this.fullscreen !== fullscreen) {
2230
+ fail(new FullscreenUiDisposedError());
2231
+ return;
2232
+ }
2233
+ }
2234
+ if (options?.overlay) {
2235
+ const overlayOptions = typeof options.overlayOptions === "function" ? options.overlayOptions() : options.overlayOptions;
2236
+ overlay = fullscreen.showOverlay(component, overlayOptions);
2237
+ options.onHandle?.(overlay);
2238
+ } else {
2239
+ fullscreen.clear();
2240
+ mounted = true;
2241
+ if (workspaceLayout !== "fullscreen") {
2242
+ layoutMounted = true;
2243
+ const sideLayout = isFullscreenLayoutComponent(component) ? component.getFullscreenLayout() : component;
2244
+ splitPane = new BtwSplitPane({
2245
+ sideComponent: component,
2246
+ sideLayout,
2247
+ mainThread: this.parent,
2248
+ mainLayout: getParentFullscreenLayout(this.parent),
2249
+ mainInput: this.mainThreadInput,
2250
+ sideScrollView: isFullscreenLayoutComponent(component) ? component.getPrimaryScrollView?.() : void 0,
2251
+ layout: workspaceLayout,
2252
+ theme: this.theme,
2253
+ terminalColumns: () => fullscreen.terminal.columns,
2254
+ terminalRows: () => fullscreen.terminal.rows,
2255
+ hasFocusedOverlay: () => fullscreen.hasFocusedOverlay?.() ?? false,
2256
+ setFocus: (target) => fullscreen.setFocus(target),
2257
+ setViewportTarget: (target) => fullscreen.setViewportTarget?.(target),
2258
+ requestRender: () => fullscreen.requestRender(),
2259
+ sidePaneRatio: this.sidePaneRatio,
2260
+ ...this.options.persistSidePaneRatio ? { persistSidePaneRatio: (ratio) => this.persistSidePaneRatio(ratio) } : {}
2261
+ });
2262
+ const addPaneFocusListener = fullscreen.addInputListenerBeforeViewport?.bind(fullscreen) ?? fullscreen.addInputListener.bind(fullscreen);
2263
+ removePaneFocusListener = addPaneFocusListener((data) => {
2264
+ const consumed = splitPane?.handleTerminalInput(data);
2265
+ return consumed ? { consume: true } : void 0;
2266
+ });
2267
+ fullscreen.setLayoutRoot(splitPane.getFullscreenLayout());
2268
+ } else if (isFullscreenLayoutComponent(component)) {
2269
+ layoutMounted = true;
2270
+ fullscreen.setLayoutRoot(component.getFullscreenLayout());
2271
+ } else {
2272
+ fullscreen.addChild(component);
2273
+ }
2274
+ fullscreen.setFocus(component);
2275
+ fullscreen.requestRender();
2276
+ }
2277
+ }).catch(fail);
1607
2278
  });
1608
- await beforeRename?.(temporaryPath, settingsPath);
1609
- signal?.throwIfAborted();
1610
- await rename(temporaryPath, settingsPath);
1611
- } catch (error) {
1612
- await rm(temporaryPath, { force: true }).catch(() => void 0);
1613
- throw error;
1614
2279
  }
1615
- }
1616
- function applyBtwSettingsPatch(current, patch) {
1617
- const updated = { ...current };
1618
- if (patch.keybindings) {
1619
- const keys = isSettingsDocument(current.keybindings) ? { ...current.keybindings } : {};
1620
- for (const action of BTW_SHORTCUT_ACTIONS) {
1621
- if (!Object.hasOwn(patch.keybindings, action)) continue;
1622
- if (patch.keybindings[action] === void 0) delete keys[action];
1623
- else keys[action] = patch.keybindings[action];
2280
+ persistSidePaneRatio(ratio) {
2281
+ const persist = this.options.persistSidePaneRatio;
2282
+ if (!persist) {
2283
+ this.sidePaneRatio = ratio;
2284
+ return Promise.resolve();
2285
+ }
2286
+ const writeGeneration = ++this.sidePaneWriteGeneration;
2287
+ let task;
2288
+ task = Promise.resolve().then(() => persist(ratio, this.lifetimeController.signal)).then(() => {
2289
+ if (this.lifetimeController.signal.aborted || writeGeneration <= this.confirmedSidePaneWriteGeneration) {
2290
+ return;
2291
+ }
2292
+ this.confirmedSidePaneWriteGeneration = writeGeneration;
2293
+ this.sidePaneRatio = ratio;
2294
+ }).catch((error) => {
2295
+ if (!this.lifetimeController.signal.aborted) {
2296
+ const message = sanitizeSingleLine(
2297
+ `Pi BTW pane width was not saved; the previous value remains active: ${formatError3(error)}`
2298
+ );
2299
+ try {
2300
+ this.ctx.ui.notify(message, "error");
2301
+ } catch {
2302
+ }
2303
+ this.fullscreen?.flash?.(message);
2304
+ }
2305
+ throw error;
2306
+ }).finally(() => this.pendingSidePaneWrites.delete(task));
2307
+ this.pendingSidePaneWrites.add(task);
2308
+ return task;
2309
+ }
2310
+ async waitForSidePaneWrites() {
2311
+ while (this.pendingSidePaneWrites.size > 0) {
2312
+ await Promise.allSettled([...this.pendingSidePaneWrites]);
1624
2313
  }
1625
- if (Object.keys(keys).length) updated.keybindings = keys;
1626
- else delete updated.keybindings;
1627
- }
1628
- if (Object.hasOwn(patch, "model")) {
1629
- if (patch.model === void 0) delete updated.model;
1630
- else updated.model = patch.model;
1631
- }
1632
- if (Object.hasOwn(patch, "thinkingLevel")) {
1633
- if (patch.thinkingLevel === void 0) delete updated.thinkingLevel;
1634
- else updated.thinkingLevel = patch.thinkingLevel;
1635
- }
1636
- if (Object.hasOwn(patch, "rememberThinkingLevelChanges")) {
1637
- updated.rememberThinkingLevelChanges = patch.rememberThinkingLevelChanges;
1638
- }
1639
- if (Object.hasOwn(patch, "fullscreenCopyOnSelect")) {
1640
- if (patch.fullscreenCopyOnSelect === void 0) delete updated.fullscreenCopyOnSelect;
1641
- else updated.fullscreenCopyOnSelect = patch.fullscreenCopyOnSelect;
1642
2314
  }
1643
- return updated;
2315
+ };
2316
+ function formatError3(error) {
2317
+ return error instanceof Error ? error.message : String(error);
1644
2318
  }
1645
- function isSettingsDocument(value) {
1646
- return typeof value === "object" && value !== null && !Array.isArray(value);
2319
+ function getFocusedComponent(tui) {
2320
+ return tui.getFocusedComponent?.() ?? null;
1647
2321
  }
1648
- function isBtwThinkingLevel(value) {
1649
- return BTW_THINKING_LEVELS.includes(value);
2322
+ function getParentFullscreenLayout(tui) {
2323
+ if (tui.mode !== "fullscreen") return void 0;
2324
+ const layoutRoot = Reflect.get(tui, "layoutRoot");
2325
+ return isComponent(layoutRoot) ? layoutRoot : void 0;
1650
2326
  }
1651
- function invalidSettingsError(settingsPath, reason) {
1652
- return new Error(`pi-btw settings at ${settingsPath} are invalid: ${reason}`);
2327
+ function isComponent(value) {
2328
+ return typeof value === "object" && value !== null && "render" in value && typeof value.render === "function" && "invalidate" in value && typeof value.invalidate === "function";
1653
2329
  }
1654
- function isNodeError(error) {
1655
- return error instanceof Error && "code" in error;
2330
+ function isFullscreenLayoutComponent(component) {
2331
+ return "getFullscreenLayout" in component && typeof component.getFullscreenLayout === "function";
1656
2332
  }
1657
- function formatError2(error) {
1658
- return error instanceof Error ? error.message : String(error);
2333
+
2334
+ // src/main-thread-updates.ts
2335
+ function registerBtwMainThreadUpdates(pi) {
2336
+ const listeners = /* @__PURE__ */ new WeakMap();
2337
+ const notify = (ctx) => {
2338
+ for (const listener of listeners.get(ctx.sessionManager) ?? []) listener();
2339
+ };
2340
+ pi.on("session_info_changed", (_event, ctx) => notify(ctx));
2341
+ pi.on("session_compact", (_event, ctx) => notify(ctx));
2342
+ pi.on("session_tree", (_event, ctx) => notify(ctx));
2343
+ pi.on("agent_start", (_event, ctx) => notify(ctx));
2344
+ pi.on("agent_end", (_event, ctx) => notify(ctx));
2345
+ pi.on("agent_settled", (_event, ctx) => notify(ctx));
2346
+ pi.on("turn_start", (_event, ctx) => notify(ctx));
2347
+ pi.on("turn_end", (_event, ctx) => notify(ctx));
2348
+ pi.on("message_start", (_event, ctx) => notify(ctx));
2349
+ pi.on("message_update", (_event, ctx) => notify(ctx));
2350
+ pi.on("message_end", (_event, ctx) => notify(ctx));
2351
+ pi.on("tool_execution_start", (_event, ctx) => notify(ctx));
2352
+ pi.on("tool_execution_update", (_event, ctx) => notify(ctx));
2353
+ pi.on("tool_execution_end", (_event, ctx) => notify(ctx));
2354
+ pi.on("model_select", (_event, ctx) => notify(ctx));
2355
+ pi.on("thinking_level_select", (_event, ctx) => notify(ctx));
2356
+ return (sessionManager, listener) => {
2357
+ const active = listeners.get(sessionManager) ?? /* @__PURE__ */ new Set();
2358
+ active.add(listener);
2359
+ listeners.set(sessionManager, active);
2360
+ return () => {
2361
+ active.delete(listener);
2362
+ if (active.size === 0) listeners.delete(sessionManager);
2363
+ };
2364
+ };
1659
2365
  }
1660
2366
 
2367
+ // src/main-tree-picker.ts
2368
+ import {
2369
+ copyToClipboard,
2370
+ TreeSelectorComponent
2371
+ } from "@earendil-works/pi-coding-agent";
2372
+ import { Key as Key3, matchesKey as matchesKey3 } from "@earendil-works/pi-tui";
2373
+
1661
2374
  // src/menu.ts
2375
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2376
+ import {
2377
+ BorderedLoader
2378
+ } from "@earendil-works/pi-coding-agent";
1662
2379
  var SAME_AS_MAIN_THREAD = "Same as main thread";
2380
+ var BTW_LAYOUT_LABELS = {
2381
+ fullscreen: "Fullscreen",
2382
+ "left-pane": "Side thread left",
2383
+ "right-pane": "Side thread right"
2384
+ };
2385
+ var BTW_LAYOUT_VALUES = Object.values(BTW_LAYOUT_LABELS);
1663
2386
  async function showBtwCommandMenu(ctx, options) {
1664
2387
  if (ctx.mode !== "tui") return "closed";
1665
2388
  const { defineMenu, runMenu, sanitizeTerminalText } = await import("@narumitw/pi-tui-kit");
@@ -1875,7 +2598,7 @@ async function showBtwCommandMenu(ctx, options) {
1875
2598
  lines: [
1876
2599
  `Model: ${displayModelValue(state.settings)}`,
1877
2600
  `Thinking: ${displayThinkingSummary(state.settings)} \xB7 Remember changes: ${displayRememberSummary(state.settings)}`,
1878
- `Copy on select: ${effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off"}`
2601
+ `Layout: ${BTW_LAYOUT_LABELS[effectiveBtwLayout(state.settings)]} \xB7 Copy on select: ${effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off"}`
1879
2602
  ],
1880
2603
  items: [
1881
2604
  {
@@ -1901,7 +2624,7 @@ async function showBtwCommandMenu(ctx, options) {
1901
2624
  {
1902
2625
  id: "settings",
1903
2626
  label: "Settings",
1904
- description: "Choose model, thinking, keybindings, and selection copying",
2627
+ description: "Choose model, thinking, layout, keybindings, and selection copying",
1905
2628
  to: state.kind === "invalid" ? "invalid" : "settings"
1906
2629
  }
1907
2630
  ],
@@ -1956,6 +2679,14 @@ async function showBtwCommandMenu(ctx, options) {
1956
2679
  values: ["On", "Off"],
1957
2680
  action: "set-fullscreen-copy"
1958
2681
  },
2682
+ {
2683
+ id: "layout",
2684
+ label: "Side-thread layout",
2685
+ description: "Use the full workspace or place BTW beside the live, click-to-focus main thread.",
2686
+ currentValue: BTW_LAYOUT_LABELS[effectiveBtwLayout(state.settings)],
2687
+ values: BTW_LAYOUT_VALUES,
2688
+ action: "set-layout"
2689
+ },
1959
2690
  ...BTW_SHORTCUT_ACTIONS.map((action) => ({
1960
2691
  id: action,
1961
2692
  label: shortcutLabels[action],
@@ -2089,6 +2820,19 @@ async function showBtwCommandMenu(ctx, options) {
2089
2820
  if (!signal.aborted) notifySaveFailure(ctx, error);
2090
2821
  return { kind: "rejected" };
2091
2822
  }
2823
+ },
2824
+ "set-layout": async ({ value, signal }) => {
2825
+ const layout = Object.entries(BTW_LAYOUT_LABELS).find(([, label]) => label === value)?.[0];
2826
+ if (!layout) return { kind: "rejected" };
2827
+ try {
2828
+ await updateSettings({ layout }, { settingsPath, signal });
2829
+ if (signal.aborted) return { kind: "rejected" };
2830
+ notifySafely(ctx, `Pi BTW layout: ${BTW_LAYOUT_LABELS[layout]}. Applies when BTW next opens.`, "info");
2831
+ return { kind: "stay" };
2832
+ } catch (error) {
2833
+ if (!signal.aborted) notifySaveFailure(ctx, error);
2834
+ return { kind: "rejected" };
2835
+ }
2092
2836
  }
2093
2837
  }
2094
2838
  });
@@ -2187,7 +2931,7 @@ function clampToAvailableThinkingLevel(requested, available) {
2187
2931
  function notifySaveFailure(ctx, error) {
2188
2932
  notifySafely(
2189
2933
  ctx,
2190
- `Pi BTW settings were not saved; the previous value remains active: ${formatError3(error)}`,
2934
+ `Pi BTW settings were not saved; the previous value remains active: ${formatError4(error)}`,
2191
2935
  "error"
2192
2936
  );
2193
2937
  }
@@ -2197,7 +2941,7 @@ function notifySafely(ctx, message, level) {
2197
2941
  } catch {
2198
2942
  }
2199
2943
  }
2200
- function formatError3(error) {
2944
+ function formatError4(error) {
2201
2945
  return error instanceof Error ? error.message : String(error);
2202
2946
  }
2203
2947
 
@@ -2290,7 +3034,7 @@ async function pickMainEntry(pi, ctx, dependencies = {}) {
2290
3034
  if (!settled) notifySafely2(ctx, "Copied selected message", "info");
2291
3035
  }).catch((error) => {
2292
3036
  if (!settled && !controller.signal.aborted) {
2293
- notifySafely2(ctx, `Could not copy selected message: ${formatError4(error)}`, "error");
3037
+ notifySafely2(ctx, `Could not copy selected message: ${formatError5(error)}`, "error");
2294
3038
  }
2295
3039
  }).finally(() => {
2296
3040
  copyControllers.delete(controller);
@@ -2318,7 +3062,7 @@ async function pickMainEntry(pi, ctx, dependencies = {}) {
2318
3062
  tui.requestRender();
2319
3063
  } catch (error) {
2320
3064
  restoreLabel(entryId);
2321
- notifySafely2(ctx, `Could not update tree label: ${formatError4(error)}`, "error");
3065
+ notifySafely2(ctx, `Could not update tree label: ${formatError5(error)}`, "error");
2322
3066
  }
2323
3067
  };
2324
3068
  selector = createSelector({
@@ -2505,7 +3249,7 @@ function notifySafely2(ctx, message, level) {
2505
3249
  } catch {
2506
3250
  }
2507
3251
  }
2508
- function formatError4(error) {
3252
+ function formatError5(error) {
2509
3253
  return error instanceof Error ? error.message : String(error);
2510
3254
  }
2511
3255
 
@@ -2565,16 +3309,16 @@ import {
2565
3309
  Loader,
2566
3310
  Markdown,
2567
3311
  matchesKey as matchesKey4,
2568
- ScrollView,
2569
- truncateToWidth as truncateToWidth3,
3312
+ ScrollView as ScrollView2,
3313
+ truncateToWidth as truncateToWidth4,
2570
3314
  VStack,
2571
- visibleWidth as visibleWidth2
3315
+ visibleWidth as visibleWidth3
2572
3316
  } from "@earendil-works/pi-tui";
2573
3317
  var TRANSCRIPT_CHROME_LINES = 2;
2574
3318
  var MAX_STEERING_DISPLAY_LINES = 3;
2575
3319
  var OSC133_MARKERS = ["\x1B]133;A\x07", "\x1B]133;B\x07", "\x1B]133;C\x07"];
2576
3320
  var RESERVED_APP_LINES = 3;
2577
- var PreservingScrollView = class extends ScrollView {
3321
+ var PreservingScrollView = class extends ScrollView2 {
2578
3322
  updateLayout(contentHeight, viewportHeight, requestRender) {
2579
3323
  const preserveManualPosition = !this.isFollowingEnd;
2580
3324
  super.updateLayout(contentHeight, viewportHeight, requestRender);
@@ -2655,6 +3399,9 @@ var BtwTranscriptPager = class {
2655
3399
  getFullscreenLayout() {
2656
3400
  return this.layoutRoot;
2657
3401
  }
3402
+ getPrimaryScrollView() {
3403
+ return this.scrollView;
3404
+ }
2658
3405
  render(width) {
2659
3406
  if (width <= 0) return [];
2660
3407
  const safeWidth = Math.max(1, width);
@@ -2670,7 +3417,7 @@ var BtwTranscriptPager = class {
2670
3417
  this.renderFooter(safeWidth),
2671
3418
  editorLines,
2672
3419
  availableRows
2673
- ).map((line) => truncateToWidth3(line, safeWidth));
3420
+ ).map((line) => truncateToWidth4(line, safeWidth));
2674
3421
  }
2675
3422
  handleInput(data) {
2676
3423
  if (this.finished) return;
@@ -2728,7 +3475,7 @@ var BtwTranscriptPager = class {
2728
3475
  const bringKey = this.shortcuts.label("bringToMain");
2729
3476
  if (this.warning) {
2730
3477
  const warning = width < 32 ? `Empty \u2022 ${exit}` : `${this.warning} \u2022 ${exit} exit`;
2731
- return truncateToWidth3(this.theme.fg("warning", warning), width);
3478
+ return truncateToWidth4(this.theme.fg("warning", warning), width);
2732
3479
  }
2733
3480
  const scrollable = this.getMaxScrollOffset() > 0;
2734
3481
  const thinking = this.options.thinking;
@@ -2738,24 +3485,24 @@ var BtwTranscriptPager = class {
2738
3485
  const fallbackBase = `btw \u2022 Enter \u2022 ${exit}`;
2739
3486
  const compactBase = bring ? `btw \u2022 Enter \u2022 ${bringKey} \u2022 ${exit}` : fallbackBase;
2740
3487
  const compactWithThinking = `${compactBase}${cycleHint}`;
2741
- let hints = visibleWidth2(fullBase) <= width ? fullBase : visibleWidth2(compactWithThinking) <= width ? compactWithThinking : visibleWidth2(compactBase) <= width ? compactBase : fallbackBase;
3488
+ let hints = visibleWidth3(fullBase) <= width ? fullBase : visibleWidth3(compactWithThinking) <= width ? compactWithThinking : visibleWidth3(compactBase) <= width ? compactBase : fallbackBase;
2742
3489
  if (scrollable) {
2743
3490
  const history = ` \u2022 ${this.scrollView.scrollTop > 0 ? "\u2191 older" : "\u2193 newer"} \u2022 PgUp/PgDn history`;
2744
3491
  const compactHistory = " \u2022 PgUp/PgDn";
2745
3492
  const compactScrollable = bring ? `Enter \u2022 ${bringKey} \u2022 ${exit} \u2022 PgUp/PgDn` : `${fallbackBase}${compactHistory}`;
2746
- if (visibleWidth2(`${hints}${history}`) <= width) {
3493
+ if (visibleWidth3(`${hints}${history}`) <= width) {
2747
3494
  hints += history;
2748
- } else if (visibleWidth2(`${compactBase}${history}`) <= width) {
3495
+ } else if (visibleWidth3(`${compactBase}${history}`) <= width) {
2749
3496
  hints = `${compactBase}${history}`;
2750
- } else if (visibleWidth2(`${hints}${compactHistory}`) <= width) {
3497
+ } else if (visibleWidth3(`${hints}${compactHistory}`) <= width) {
2751
3498
  hints += compactHistory;
2752
- } else if (visibleWidth2(`${compactBase}${compactHistory}`) <= width) {
3499
+ } else if (visibleWidth3(`${compactBase}${compactHistory}`) <= width) {
2753
3500
  hints = `${compactBase}${compactHistory}`;
2754
- } else if (visibleWidth2(compactScrollable) <= width) {
3501
+ } else if (visibleWidth3(compactScrollable) <= width) {
2755
3502
  hints = compactScrollable;
2756
3503
  }
2757
3504
  }
2758
- return truncateToWidth3(this.theme.fg("muted", hints), width);
3505
+ return truncateToWidth4(this.theme.fg("muted", hints), width);
2759
3506
  }
2760
3507
  createHeaderComponent() {
2761
3508
  return {
@@ -2878,6 +3625,9 @@ var BtwAnsweringView = class {
2878
3625
  getFullscreenLayout() {
2879
3626
  return this.layoutRoot;
2880
3627
  }
3628
+ getPrimaryScrollView() {
3629
+ return this.scrollView;
3630
+ }
2881
3631
  render(width) {
2882
3632
  if (width <= 0) return [];
2883
3633
  const safeWidth = Math.max(1, width);
@@ -2904,7 +3654,7 @@ var BtwAnsweringView = class {
2904
3654
  editorLines,
2905
3655
  availableRows,
2906
3656
  steeringLines
2907
- ).map((line) => truncateToWidth3(line, safeWidth));
3657
+ ).map((line) => truncateToWidth4(line, safeWidth));
2908
3658
  }
2909
3659
  handleInput(data) {
2910
3660
  if (this.finished) return;
@@ -2967,7 +3717,7 @@ var BtwAnsweringView = class {
2967
3717
  const exit = this.shortcuts.label("exit");
2968
3718
  if (this.warning) {
2969
3719
  const warning = width < 32 ? `Empty \u2022 ${exit}` : `${this.warning} \u2022 ${exit} cancel`;
2970
- return truncateToWidth3(this.theme.fg("warning", warning), width);
3720
+ return truncateToWidth4(this.theme.fg("warning", warning), width);
2971
3721
  }
2972
3722
  const baseHint = this.editor ? `Enter steer \u2022 ${exit} cancel` : `${exit} cancel`;
2973
3723
  const thinking = this.options.steering?.thinking;
@@ -2975,10 +3725,10 @@ var BtwAnsweringView = class {
2975
3725
  const scrollHint = this.getMaxScrollOffset() > 0 ? " \u2022 PgUp/PgDn history" : "";
2976
3726
  const hints = `${baseHint}${cycleHint}${scrollHint}`;
2977
3727
  const compactHints = this.editor ? `Enter \u2022 ${exit}` : exit;
2978
- const selectedHints = visibleWidth2(hints) <= width ? hints : compactHints;
2979
- const loaderWidth = Math.max(1, width - visibleWidth2(selectedHints) - 3);
3728
+ const selectedHints = visibleWidth3(hints) <= width ? hints : compactHints;
3729
+ const loaderWidth = Math.max(1, width - visibleWidth3(selectedHints) - 3);
2980
3730
  const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering\u2026";
2981
- return truncateToWidth3(`${loaderLine} \u2022 ${this.theme.fg("muted", selectedHints)}`, width);
3731
+ return truncateToWidth4(`${loaderLine} \u2022 ${this.theme.fg("muted", selectedHints)}`, width);
2982
3732
  }
2983
3733
  createHeaderComponent() {
2984
3734
  return {
@@ -3050,8 +3800,8 @@ function renderTranscriptLines(components, width) {
3050
3800
  }
3051
3801
  function renderSideThreadHeader(width, theme, thinkingLevel) {
3052
3802
  const thinking = thinkingLevel ? ` \xB7 thinking ${thinkingLevel}` : "";
3053
- const title = truncateToWidth3(`\u2500 btw \xB7 side thread${thinking} `, width);
3054
- const ruleWidth = Math.max(0, width - visibleWidth2(title));
3803
+ const title = truncateToWidth4(`\u2500 btw \xB7 side thread${thinking} `, width);
3804
+ const ruleWidth = Math.max(0, width - visibleWidth3(title));
3055
3805
  return theme.fg("muted", `${title}${"\u2500".repeat(ruleWidth)}`);
3056
3806
  }
3057
3807
  function fitComposerLayout(header, contentLines, footer, editorLines, availableRows, statusLines = []) {
@@ -3074,7 +3824,7 @@ function renderSteeringLines(questions, width, theme, maxLines) {
3074
3824
  const formatQuestion = (question) => sanitizeSingleLine(question) || "(non-printing message)";
3075
3825
  if (maxLines === 1 && questions.length > 1) {
3076
3826
  return [
3077
- truncateToWidth3(
3827
+ truncateToWidth4(
3078
3828
  theme.fg("dim", `Steering (+${questions.length - 1} more): ${formatQuestion(questions[0] ?? "")}`),
3079
3829
  width
3080
3830
  )
@@ -3082,9 +3832,9 @@ function renderSteeringLines(questions, width, theme, maxLines) {
3082
3832
  }
3083
3833
  const hasOverflow = questions.length > maxLines;
3084
3834
  const questionLimit = hasOverflow ? Math.max(1, maxLines - 1) : maxLines;
3085
- const lines = questions.slice(0, questionLimit).map((question) => truncateToWidth3(theme.fg("dim", `Steering: ${formatQuestion(question)}`), width));
3835
+ const lines = questions.slice(0, questionLimit).map((question) => truncateToWidth4(theme.fg("dim", `Steering: ${formatQuestion(question)}`), width));
3086
3836
  if (hasOverflow) {
3087
- lines.push(truncateToWidth3(theme.fg("dim", `Steering: \u2026 +${questions.length - questionLimit} more`), width));
3837
+ lines.push(truncateToWidth4(theme.fg("dim", `Steering: \u2026 +${questions.length - questionLimit} more`), width));
3088
3838
  }
3089
3839
  return lines;
3090
3840
  }
@@ -3104,7 +3854,6 @@ function escapeTerminalControls2(text) {
3104
3854
  }
3105
3855
 
3106
3856
  // src/btw.ts
3107
- var MAX_CONTEXT_CHARS = 4e4;
3108
3857
  function createModelRegistryCompleteSimple(modelRegistry) {
3109
3858
  const completeSimple = async (model, context, options) => modelRegistry.streamSimple(model, context, options).result();
3110
3859
  completeSimple.appliesRequestHeaderTransforms = true;
@@ -3140,7 +3889,7 @@ async function resolveBtwModel({
3140
3889
  }
3141
3890
  return currentModel && isAvailable(currentModel) ? { model: currentModel } : void 0;
3142
3891
  }
3143
- function formatError5(error) {
3892
+ function formatError6(error) {
3144
3893
  return error instanceof Error ? error.message : String(error);
3145
3894
  }
3146
3895
  function readBtwSessionId(ctx) {
@@ -3162,6 +3911,7 @@ function btw(pi, dependencies = {}) {
3162
3911
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelForCommand;
3163
3912
  const runThread = dependencies.runThread ?? runBtwThread;
3164
3913
  const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
3914
+ const subscribeMainThreadUpdates = registerBtwMainThreadUpdates(pi);
3165
3915
  const resumableThreads = /* @__PURE__ */ new Map();
3166
3916
  let nextThreadNumber = 1;
3167
3917
  const listResumeThreads = () => [...resumableThreads.values()].reverse().filter((state) => state.thread.turns.length > 0 && state.title).sort((first, second) => second.updatedAt - first.updatedAt || second.createdAt - first.createdAt).map((state) => ({
@@ -3250,6 +4000,10 @@ function btw(pi, dependencies = {}) {
3250
4000
  },
3251
4001
  {
3252
4002
  copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
4003
+ layout: effectiveBtwLayout(settings),
4004
+ sidePaneRatio: effectiveBtwSidePaneRatio(settings),
4005
+ persistSidePaneRatio: (ratio, signal) => updateBtwSettings({ sidePaneRatio: ratio }, { signal }).then(() => void 0),
4006
+ subscribeMainThreadUpdates: (listener) => subscribeMainThreadUpdates(ctx.sessionManager, listener),
3253
4007
  ...settings.keybindings ? { keybindings: settings.keybindings } : {}
3254
4008
  }
3255
4009
  );
@@ -3323,7 +4077,7 @@ async function runBtwThread({
3323
4077
  write = Promise.resolve().then(() => persistThinkingLevel(level)).then(() => void 0).catch((error) => {
3324
4078
  notifySafely3(
3325
4079
  ctx,
3326
- `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError5(error)}`,
4080
+ `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError6(error)}`,
3327
4081
  "warning"
3328
4082
  );
3329
4083
  }).finally(() => pendingWrites.delete(write));
@@ -3659,50 +4413,6 @@ async function showThreadComposer(thread, startAtBottom, ctx, initialQuestion, t
3659
4413
  })
3660
4414
  );
3661
4415
  }
3662
- function buildConversationContext(entries) {
3663
- const sections = [];
3664
- for (const entry of entries) {
3665
- if (entry.type !== "message" || !entry.message?.role) continue;
3666
- const role = entry.message.role;
3667
- if (role !== "user" && role !== "assistant") continue;
3668
- const contentLines = extractContentLines(entry.message.content);
3669
- if (contentLines.length === 0) continue;
3670
- const label = role === "user" ? "User" : "Assistant";
3671
- const status = entry.message.stopReason && entry.message.stopReason !== "stop" ? ` (${entry.message.stopReason})` : "";
3672
- sections.push(`${label}${status}: ${contentLines.join("\n")}`);
3673
- }
3674
- return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
3675
- }
3676
- function extractContentLines(content) {
3677
- if (typeof content === "string") return [content.trim()].filter(Boolean);
3678
- if (!Array.isArray(content)) return [];
3679
- const lines = [];
3680
- for (const part of content) {
3681
- if (!part || typeof part !== "object") continue;
3682
- const block = part;
3683
- if (block.type === "text" && typeof block.text === "string") {
3684
- lines.push(block.text.trim());
3685
- } else if (block.type === "toolCall" && typeof block.name === "string") {
3686
- lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
3687
- } else if (block.type === "toolResult" && typeof block.name === "string") {
3688
- lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
3689
- }
3690
- }
3691
- return lines.filter(Boolean);
3692
- }
3693
- function formatJson(value) {
3694
- if (value === void 0) return "";
3695
- try {
3696
- return JSON.stringify(value);
3697
- } catch {
3698
- return String(value);
3699
- }
3700
- }
3701
- function truncateFromStart(text, maxChars) {
3702
- if (text.length <= maxChars) return text;
3703
- return `[Earlier context omitted; showing the last ${maxChars} characters.]
3704
- ${text.slice(-maxChars)}`;
3705
- }
3706
4416
  export {
3707
4417
  btw as default
3708
4418
  };