@selesai/code 0.8.7 → 0.8.9

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 (52) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/dist/cli/args.d.ts +1 -0
  3. package/dist/cli/args.js +11 -0
  4. package/dist/cli/args.test.d.ts +1 -0
  5. package/dist/cli/args.test.js +24 -0
  6. package/dist/core/agent-session.d.ts +4 -1
  7. package/dist/core/agent-session.js +5 -6
  8. package/dist/core/experimental.d.ts +4 -0
  9. package/dist/core/experimental.js +4 -0
  10. package/dist/core/extensions/types.d.ts +4 -0
  11. package/dist/core/keybindings.d.ts +24 -0
  12. package/dist/core/model-config.d.ts +5 -0
  13. package/dist/core/model-config.js +1 -0
  14. package/dist/core/project-trust.js +2 -2
  15. package/dist/core/sdk.d.ts +5 -3
  16. package/dist/core/sdk.js +2 -1
  17. package/dist/core/session-manager.js +2 -2
  18. package/dist/core/settings-manager.d.ts +6 -0
  19. package/dist/core/settings-manager.js +12 -0
  20. package/dist/core/system-prompt.js +1 -1
  21. package/dist/core/tools/bash.js +2 -0
  22. package/dist/core/tools/edit.js +2 -0
  23. package/dist/core/tools/find.js +1 -1
  24. package/dist/core/tools/grep.js +1 -1
  25. package/dist/core/tools/read.js +2 -0
  26. package/dist/core/tools/write.js +2 -0
  27. package/dist/defaults/models.json +21 -0
  28. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +0 -18
  29. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +26 -0
  30. package/dist/extensions/tokenin-onboarding.ts +1 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/main.js +4 -0
  33. package/dist/modes/interactive/components/model-selector.js +2 -1
  34. package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
  35. package/dist/modes/interactive/components/settings-selector.js +10 -0
  36. package/dist/modes/interactive/components/tool-execution.js +10 -1
  37. package/dist/modes/interactive/interactive-mode.d.ts +8 -2
  38. package/dist/modes/interactive/interactive-mode.js +81 -25
  39. package/dist/modes/interactive/model-catalog-refresh.d.ts +6 -0
  40. package/dist/modes/interactive/model-catalog-refresh.js +33 -0
  41. package/dist/modes/interactive/theme/dark.json +2 -0
  42. package/dist/modes/interactive/theme/light.json +2 -0
  43. package/dist/modes/interactive/theme/theme-controller.d.ts +10 -2
  44. package/dist/modes/interactive/theme/theme-controller.js +25 -11
  45. package/dist/modes/interactive/theme/theme-schema.json +9 -1
  46. package/dist/modes/interactive/theme/theme.d.ts +5 -3
  47. package/dist/modes/interactive/theme/theme.js +12 -2
  48. package/dist/utils/abort.d.ts +4 -0
  49. package/dist/utils/abort.js +47 -0
  50. package/dist/utils/tools-manager.d.ts +5 -1
  51. package/dist/utils/tools-manager.js +12 -21
  52. package/package.json +6 -6
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./co
18
18
  export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.ts";
19
19
  export { AgentSessionRuntime, type AgentSessionRuntimeDiagnostic, type AgentSessionServices, type CreateAgentSessionFromServicesOptions, type CreateAgentSessionOptions, type CreateAgentSessionResult, type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeResult, type CreateAgentSessionServicesOptions, createAgentSession, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWriteTool, type PromptTemplate, } from "./core/sdk.ts";
20
20
  export { type BranchSummaryEntry, buildContextEntries, buildSessionContext, type CompactionEntry, CURRENT_SESSION_VERSION, type CustomEntry, type CustomMessageEntry, type FileEntry, getLatestCompactionEntry, type ModelChangeEntry, migrateSessionEntries, type NewSessionOptions, parseSessionEntries, type SessionContext, type SessionEntry, type SessionEntryBase, type SessionHeader, type SessionInfo, type SessionInfoEntry, SessionManager, type SessionMessageEntry, type SessionTreeNode, sessionEntryToContextMessages, type ThinkingLevelChangeEntry, } from "./core/session-manager.ts";
21
- export { type AutoHandoffSettings, type CompactionSettings, type DefaultProjectTrust, type ImageSettings, type PackageSource, type RetrySettings, SettingsManager, type SettingsManagerCreateOptions, type TuiMode, } from "./core/settings-manager.ts";
21
+ export { type AutoHandoffSettings, type CompactionSettings, type DefaultProjectTrust, type FullscreenExitOutput, type ImageSettings, type PackageSource, type RetrySettings, SettingsManager, type SettingsManagerCreateOptions, type TuiMode, } from "./core/settings-manager.ts";
22
22
  export { formatSkillsForPrompt, type LoadSkillsFromDirOptions, type LoadSkillsResult, loadSkills, loadSkillsFromDir, type Skill, type SkillFrontmatter, } from "./core/skills.ts";
23
23
  export { createSyntheticSourceInfo } from "./core/source-info.ts";
24
24
  export { type EditDiffResult, generateDiffString, generateUnifiedPatch } from "./core/tools/edit-diff.ts";
package/dist/main.js CHANGED
@@ -491,6 +491,9 @@ export async function main(args, options) {
491
491
  await showFirstTimeSetup(startupSettingsManager);
492
492
  time("firstTimeSetup");
493
493
  }
494
+ if (appMode === "interactive" && parsed.useTheme !== undefined) {
495
+ startupSettingsManager.applyOverrides({ theme: parsed.useTheme });
496
+ }
494
497
  // Decide the final runtime cwd before creating cwd-bound runtime services.
495
498
  // --session and --resume may select a session from another project, so project-local
496
499
  // settings, resources, provider registrations, and models must be resolved only after
@@ -723,6 +726,7 @@ export async function main(args, options) {
723
726
  initialMessages: parsed.messages,
724
727
  verbose: parsed.verbose,
725
728
  tuiMode: parsed.tuiMode,
729
+ initialThemeSetting: parsed.useTheme,
726
730
  });
727
731
  if (startupBenchmark) {
728
732
  await interactiveMode.init();
@@ -1,6 +1,7 @@
1
1
  import { modelsAreEqual } from "@earendil-works/pi-ai";
2
2
  import { Container, fuzzyFilter, getKeybindings, Input, Spacer, Text, } from "@earendil-works/pi-tui";
3
3
  import { getModelSelectorSearchText } from "../model-search.js";
4
+ import { refreshModelCatalogs } from "../model-catalog-refresh.js";
4
5
  import { theme } from "../theme/theme.js";
5
6
  import { DynamicBorder } from "./dynamic-border.js";
6
7
  import { keyHint } from "./keybinding-hints.js";
@@ -123,7 +124,7 @@ export class ModelSelectorComponent extends Container {
123
124
  this.refreshAbortController.abort();
124
125
  }, timeoutMs);
125
126
  try {
126
- const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal });
127
+ const result = await refreshModelCatalogs(this.modelRuntime, this.refreshAbortController.signal);
127
128
  if (this.closed)
128
129
  return;
129
130
  this.refreshStatusMessage = "";
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Transport } from "@earendil-works/pi-ai";
3
3
  import { Container, type ScrollViewScrollbar, SettingsList } from "@earendil-works/pi-tui";
4
- import type { DefaultProjectTrust, TuiMode, WarningSettings } from "../../../core/settings-manager.ts";
4
+ import type { DefaultProjectTrust, FullscreenExitOutput, TuiMode, WarningSettings } from "../../../core/settings-manager.ts";
5
5
  import { type TerminalTheme } from "../theme/theme.ts";
6
6
  export interface SettingsConfig {
7
7
  autoCompact: boolean;
@@ -35,6 +35,7 @@ export interface SettingsConfig {
35
35
  clearOnShrink: boolean;
36
36
  showTerminalProgress: boolean;
37
37
  tuiMode: TuiMode;
38
+ fullscreenExitOutput: FullscreenExitOutput;
38
39
  fullscreenScrollbar: ScrollViewScrollbar;
39
40
  warnings: WarningSettings;
40
41
  }
@@ -68,6 +69,7 @@ export interface SettingsCallbacks {
68
69
  onClearOnShrinkChange: (enabled: boolean) => void;
69
70
  onShowTerminalProgressChange: (enabled: boolean) => void;
70
71
  onTuiModeChange: (mode: TuiMode) => void;
72
+ onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void;
71
73
  onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void;
72
74
  onWarningsChange: (warnings: WarningSettings) => void;
73
75
  onCancel: () => void;
@@ -409,6 +409,13 @@ export class SettingsSelectorComponent extends Container {
409
409
  currentValue: config.tuiMode,
410
410
  values: ["fullscreen", "regular"],
411
411
  },
412
+ {
413
+ id: "fullscreen-exit-output",
414
+ label: "Fullscreen exit output",
415
+ description: "Print the transcript or only a session resume hint when exiting fullscreen mode",
416
+ currentValue: config.fullscreenExitOutput,
417
+ values: ["transcript", "resume-hint"],
418
+ },
412
419
  {
413
420
  id: "fullscreen-scrollbar",
414
421
  label: "Fullscreen scrollbar",
@@ -603,6 +610,9 @@ export class SettingsSelectorComponent extends Container {
603
610
  case "tui-mode":
604
611
  callbacks.onTuiModeChange(newValue);
605
612
  break;
613
+ case "fullscreen-exit-output":
614
+ callbacks.onFullscreenExitOutputChange(newValue);
615
+ break;
606
616
  case "fullscreen-scrollbar":
607
617
  callbacks.onFullscreenScrollbarChange(newValue);
608
618
  break;
@@ -2,7 +2,9 @@ import { Box, Container, getCapabilities, Image, Spacer, Text } from "@earendil-
2
2
  import { createAllToolDefinitions } from "../../../core/tools/index.js";
3
3
  import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/render-utils.js";
4
4
  import { convertToPng } from "../../../utils/image-convert.js";
5
+ import { keyHint } from "./keybinding-hints.js";
5
6
  import { theme } from "../theme/theme.js";
7
+ const FALLBACK_PREVIEW_LINES = 10;
6
8
  export class ToolExecutionComponent extends Container {
7
9
  contentBox;
8
10
  contentText;
@@ -111,7 +113,14 @@ export class ToolExecutionComponent extends Container {
111
113
  if (!output) {
112
114
  return undefined;
113
115
  }
114
- return new Text(theme.fg("toolOutput", output), 0, 0);
116
+ const lines = output.split("\n");
117
+ const displayLines = this.expanded ? lines : lines.slice(0, FALLBACK_PREVIEW_LINES);
118
+ const remaining = lines.length - displayLines.length;
119
+ let text = displayLines.map((line) => theme.fg("toolOutput", line)).join("\n");
120
+ if (remaining > 0) {
121
+ text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`;
122
+ }
123
+ return new Text(text, 0, 0);
115
124
  }
116
125
  updateArgs(args) {
117
126
  this.args = args;
@@ -7,7 +7,7 @@ import type { Terminal } from "@earendil-works/pi-tui";
7
7
  import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
8
8
  import { type AgentSessionRuntime } from "../../core/agent-session-runtime.ts";
9
9
  import { SessionManager } from "../../core/session-manager.ts";
10
- import type { TuiMode } from "../../core/settings-manager.ts";
10
+ import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts";
11
11
  import { type LatestPiRelease } from "../../utils/version-check.ts";
12
12
  type PendingUserInput = {
13
13
  text: string;
@@ -34,6 +34,8 @@ export interface InteractiveModeOptions {
34
34
  verbose?: boolean;
35
35
  /** TUI layout mode. */
36
36
  tuiMode?: TuiMode;
37
+ /** Initial interactive theme setting for this invocation. */
38
+ initialThemeSetting?: string;
37
39
  }
38
40
  interface InteractiveTuiOptions {
39
41
  tuiMode: TuiMode;
@@ -93,6 +95,7 @@ export declare class InteractiveMode {
93
95
  private streamingMessage;
94
96
  private pendingTools;
95
97
  private toolOutputExpanded;
98
+ private managedToolStatusStarted;
96
99
  private hideThinkingBlock;
97
100
  private outputPad;
98
101
  private unsubscribe?;
@@ -271,11 +274,14 @@ export declare class InteractiveMode {
271
274
  private handleClipboardPaste;
272
275
  private consumePendingInputImages;
273
276
  private handleGitCommand;
277
+ private handleStartupSubmit;
274
278
  private setupEditorSubmitHandler;
275
279
  private subscribeToAgent;
276
280
  private handleEvent;
277
281
  /** Extract text content from a user message */
278
282
  private getUserMessageText;
283
+ /** Show a managed-tool status update in the chat. */
284
+ private showManagedToolStatus;
279
285
  /**
280
286
  * Show a status message in the chat.
281
287
  *
@@ -425,6 +431,6 @@ export declare class InteractiveMode {
425
431
  private checkDaxnutsEasterEgg;
426
432
  private handleBashCommand;
427
433
  private handleCompactCommand;
428
- stop(): void;
434
+ stop(fullscreenExitOutput?: FullscreenExitOutput): void;
429
435
  }
430
436
  export {};
@@ -69,6 +69,7 @@ import { UserMessageComponent } from "./components/user-message.js";
69
69
  import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
70
70
  import { editInExternalEditor } from "./external-editor.js";
71
71
  import { getModelSearchText } from "./model-search.js";
72
+ import { refreshModelCatalogs } from "./model-catalog-refresh.js";
72
73
  import { getAvailableThemes, getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getThemeByName, onThemeChange, setRegisteredThemes, stopThemeWatcher, Theme, theme, } from "./theme/theme.js";
73
74
  import { InteractiveThemeController } from "./theme/theme-controller.js";
74
75
  function isExpandable(obj) {
@@ -168,9 +169,21 @@ function formatLoginProviderCompletionDescription(provider) {
168
169
  export function createInteractiveTui(options) {
169
170
  const terminal = options.terminal ?? new ProcessTerminal();
170
171
  if (options.tuiMode === "fullscreen") {
172
+ const styleSearchMatch = (text) => theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
171
173
  return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {
174
+ searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
175
+ searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
172
176
  openUrl: openBrowser,
173
177
  onRightClickPaste: options.onRightClickPaste,
178
+ copySelection: async (text) => {
179
+ try {
180
+ await copyToClipboard(text);
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ },
174
187
  });
175
188
  }
176
189
  return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
@@ -258,6 +271,7 @@ export class InteractiveMode {
258
271
  pendingTools = new Map();
259
272
  // Tool output expansion state
260
273
  toolOutputExpanded = false;
274
+ managedToolStatusStarted = false;
261
275
  // Thinking block visibility state
262
276
  hideThinkingBlock = false;
263
277
  outputPad = 1;
@@ -369,7 +383,12 @@ export class InteractiveMode {
369
383
  this.outputPad = this.settingsManager.getOutputPad();
370
384
  // Register themes from resource loader and initialize
371
385
  setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
372
- this.themeController = new InteractiveThemeController(this.ui, this.settingsManager, (message) => this.showError(message), () => this.updateEditorBorderColor());
386
+ this.themeController = new InteractiveThemeController(this.ui, {
387
+ getSettingsManager: () => this.settingsManager,
388
+ showError: (message) => this.showError(message),
389
+ onChanged: () => this.updateEditorBorderColor(),
390
+ initialThemeSetting: options.initialThemeSetting,
391
+ });
373
392
  }
374
393
  getAutocompleteSourceTag(sourceInfo) {
375
394
  if (!sourceInfo) {
@@ -530,14 +549,14 @@ export class InteractiveMode {
530
549
  tui.setLayoutRoot(this.fullscreenLayoutRoot);
531
550
  }
532
551
  }
533
- stopInteractiveTui() {
534
- if (this.renderer.mode === "fullscreen") {
552
+ stopInteractiveTui(fullscreenExitOutput) {
553
+ if (this.renderer.mode === "fullscreen" && fullscreenExitOutput === "transcript") {
535
554
  while (this.renderer.hasOverlayEntries)
536
555
  this.renderer.hideOverlay();
537
556
  this.switchTuiMode("regular", false, false);
538
557
  this.renderer.renderNow();
539
558
  }
540
- this.ui.stop();
559
+ this.ui.stop({ preserveScreen: this.renderer.mode === "fullscreen" });
541
560
  }
542
561
  switchTuiMode(mode, restoreProgress = true, startRenderer = true) {
543
562
  const previousUi = this.renderer;
@@ -594,10 +613,6 @@ export class InteractiveMode {
594
613
  this.registerSignalHandlers();
595
614
  // Load changelog (only show new entries, skip for resumed sessions)
596
615
  this.changelogMarkdown = this.getChangelogForDisplay();
597
- // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
598
- // Both are needed: fd for autocomplete, rg for grep tool and bash commands
599
- const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
600
- this.fdPath = fdPath;
601
616
  if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) {
602
617
  const modelList = this.session.scopedModels
603
618
  .map((sm) => {
@@ -641,9 +656,11 @@ export class InteractiveMode {
641
656
  this.widgetContainerBelow,
642
657
  this.footerContainer,
643
658
  ]);
659
+ // Accept text while startup completes, but only enable interrupt, exit, and submission feedback.
660
+ this.defaultEditor.onAction("app.clear", () => this.handleCtrlC());
661
+ this.defaultEditor.onCtrlD = () => this.handleCtrlD();
662
+ this.defaultEditor.onSubmit = (text) => this.handleStartupSubmit(text);
644
663
  this.ui.setFocus(this.editor);
645
- this.setupKeyHandlers();
646
- this.setupEditorSubmitHandler();
647
664
  // Start the UI before initializing extensions so session_start handlers can use interactive dialogs
648
665
  this.ui.start();
649
666
  this.isInitialized = true;
@@ -695,6 +712,18 @@ export class InteractiveMode {
695
712
  this.headerContainer.addChild(this.builtInHeader);
696
713
  }
697
714
  this.ui.requestRender();
715
+ // Ensure fd and rg are available after mounting the TUI (downloads if missing, adds to PATH via getBinDir)
716
+ // so slow downloads do not make startup appear frozen.
717
+ // Both are needed: fd for autocomplete, rg for grep tool and bash commands.
718
+ const [fdPath] = await Promise.all([
719
+ ensureTool("fd", (status) => this.showManagedToolStatus(status)),
720
+ ensureTool("rg", (status) => this.showManagedToolStatus(status)),
721
+ ]);
722
+ this.fdPath = fdPath;
723
+ // Enable the remaining input handlers only after managed-tool setup completes.
724
+ this.setupKeyHandlers();
725
+ this.setupEditorSubmitHandler();
726
+ this.ui.requestRender();
698
727
  // Initialize extensions first so resources are shown before messages
699
728
  await this.rebindCurrentSession();
700
729
  // Render initial messages AFTER showing loaded resources
@@ -732,10 +761,12 @@ export class InteractiveMode {
732
761
  async run() {
733
762
  await this.init();
734
763
  if (!process.env.PI_OFFLINE) {
735
- void this.session.modelRuntime
736
- .refresh()
764
+ const controller = new AbortController();
765
+ const timeout = setTimeout(() => controller.abort(), 15_000);
766
+ void refreshModelCatalogs(this.session.modelRuntime, controller.signal)
737
767
  .then(() => this.updateAvailableProviderCount())
738
- .catch(() => { });
768
+ .catch(() => { })
769
+ .finally(() => clearTimeout(timeout));
739
770
  }
740
771
  // Start version check asynchronously
741
772
  checkForNewPiVersion(this.version, PACKAGE_NAME).then((newRelease) => {
@@ -1484,7 +1515,7 @@ export class InteractiveMode {
1484
1515
  const message = error instanceof Error ? error.message : String(error);
1485
1516
  this.showError(`${prefix}: ${message}`);
1486
1517
  stopThemeWatcher();
1487
- this.stop();
1518
+ this.stop("transcript");
1488
1519
  process.exit(1);
1489
1520
  }
1490
1521
  renderCurrentSessionState() {
@@ -2307,6 +2338,10 @@ export class InteractiveMode {
2307
2338
  }
2308
2339
  await this.session.prompt(prompt, { images });
2309
2340
  }
2341
+ handleStartupSubmit(text) {
2342
+ this.editor.setText(text);
2343
+ this.showStatus("Startup is still in progress");
2344
+ }
2310
2345
  setupEditorSubmitHandler() {
2311
2346
  this.defaultEditor.onSubmit = async (text) => {
2312
2347
  text = text.trim();
@@ -2794,6 +2829,19 @@ export class InteractiveMode {
2794
2829
  : message.content.filter((c) => c.type === "text");
2795
2830
  return textBlocks.map((c) => c.text).join("");
2796
2831
  }
2832
+ /** Show a managed-tool status update in the chat. */
2833
+ showManagedToolStatus(status) {
2834
+ if (!this.managedToolStatusStarted) {
2835
+ this.chatContainer.addChild(new Spacer(1));
2836
+ this.managedToolStatusStarted = true;
2837
+ }
2838
+ const message = status.type === "warning" ? `Warning: ${status.message}` : status.message;
2839
+ const color = status.type === "warning" ? "warning" : "dim";
2840
+ this.chatContainer.addChild(new Text(theme.fg(color, message), 1, 0));
2841
+ this.lastStatusSpacer = undefined;
2842
+ this.lastStatusText = undefined;
2843
+ this.ui.requestRender();
2844
+ }
2797
2845
  /**
2798
2846
  * Show a status message in the chat.
2799
2847
  *
@@ -3165,7 +3213,7 @@ export class InteractiveMode {
3165
3213
  this.ui.stop();
3166
3214
  }
3167
3215
  catch { }
3168
- console.error("pi exiting due to uncaughtException:");
3216
+ console.error(`${APP_NAME} exiting due to uncaughtException:`);
3169
3217
  console.error(error);
3170
3218
  process.exit(1);
3171
3219
  }
@@ -3626,7 +3674,7 @@ export class InteractiveMode {
3626
3674
  httpIdleTimeoutMs: this.settingsManager.getHttpIdleTimeoutMs(),
3627
3675
  thinkingLevel: this.session.thinkingLevel,
3628
3676
  availableThinkingLevels: this.session.getAvailableThinkingLevels(),
3629
- currentTheme: this.settingsManager.getThemeSetting() || "dark",
3677
+ currentTheme: this.themeController.getThemeSelection() || "dark",
3630
3678
  terminalTheme: this.themeController.getTerminalTheme(),
3631
3679
  availableThemes: getAvailableThemes(),
3632
3680
  hideThinkingBlock: this.hideThinkingBlock,
@@ -3644,6 +3692,7 @@ export class InteractiveMode {
3644
3692
  clearOnShrink: this.settingsManager.getClearOnShrink(),
3645
3693
  showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
3646
3694
  tuiMode: this.settingsManager.getTuiMode(),
3695
+ fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(),
3647
3696
  fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
3648
3697
  warnings: this.settingsManager.getWarnings(),
3649
3698
  }, {
@@ -3701,7 +3750,7 @@ export class InteractiveMode {
3701
3750
  },
3702
3751
  onThemeChange: (themeSetting) => {
3703
3752
  this.settingsManager.setTheme(themeSetting);
3704
- void this.themeController.applyFromSettings();
3753
+ void this.themeController.setThemeSetting(themeSetting);
3705
3754
  },
3706
3755
  onThemePreview: (themeName) => this.themeController.preview(themeName),
3707
3756
  onHideThinkingBlockChange: (hidden) => {
@@ -3795,6 +3844,9 @@ export class InteractiveMode {
3795
3844
  this.statusContainer.clear();
3796
3845
  this.showStatus(`TUI mode: ${mode}`);
3797
3846
  },
3847
+ onFullscreenExitOutputChange: (output) => {
3848
+ this.settingsManager.setFullscreenExitOutput(output);
3849
+ },
3798
3850
  onFullscreenScrollbarChange: (mode) => {
3799
3851
  this.settingsManager.setFullscreenScrollbar(mode);
3800
3852
  this.transcriptScrollView?.setScrollbar(mode);
@@ -3842,7 +3894,8 @@ export class InteractiveMode {
3842
3894
  return this.session.scopedModels.map((scoped) => scoped.model);
3843
3895
  }
3844
3896
  try {
3845
- await this.session.modelRuntime.refresh();
3897
+ const controller = new AbortController();
3898
+ await refreshModelCatalogs(this.session.modelRuntime, controller.signal);
3846
3899
  return [...(await this.session.modelRuntime.getAvailable())];
3847
3900
  }
3848
3901
  catch {
@@ -3919,7 +3972,7 @@ export class InteractiveMode {
3919
3972
  onSelect: (selection) => {
3920
3973
  trustStore.setMany(selection.updates);
3921
3974
  done();
3922
- this.showStatus(`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`);
3975
+ this.showStatus(`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart ${APP_NAME} for this to take effect.`);
3923
3976
  },
3924
3977
  onCancel: () => {
3925
3978
  done();
@@ -3954,7 +4007,8 @@ export class InteractiveMode {
3954
4007
  }
3955
4008
  async showModelsSelector() {
3956
4009
  // Get all available models
3957
- await this.session.modelRuntime.refresh();
4010
+ const controller = new AbortController();
4011
+ await refreshModelCatalogs(this.session.modelRuntime, controller.signal);
3958
4012
  const allModels = [...(await this.session.modelRuntime.getAvailable())];
3959
4013
  const allModelIds = new Set(allModels.map((model) => `${model.provider}/${model.id}`));
3960
4014
  const configuredPatterns = this.settingsManager.getEnabledModels();
@@ -4507,7 +4561,7 @@ export class InteractiveMode {
4507
4561
  this.ui.requestRender();
4508
4562
  };
4509
4563
  const dialog = new LoginDialogComponent(this.ui, providerOption.id, () => restoreEditor(), providerOption.name, `${providerOption.name} setup`);
4510
- dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside pi.`, [], true);
4564
+ dialog.showInfo(`${providerOption.method?.name ?? "Authentication"} is configured outside ${APP_NAME}.`, [], true);
4511
4565
  this.editorContainer.clear();
4512
4566
  this.editorContainer.addChild(dialog);
4513
4567
  this.ui.setFocus(dialog);
@@ -4794,7 +4848,9 @@ export class InteractiveMode {
4794
4848
  this.showStatus(`Session exported to: ${filePath}`);
4795
4849
  }
4796
4850
  else {
4797
- const filePath = await this.session.exportToHtml(outputPath);
4851
+ const filePath = await this.session.exportToHtml(outputPath, {
4852
+ themeName: theme.name,
4853
+ });
4798
4854
  this.showStatus(`Session exported to: ${filePath}`);
4799
4855
  }
4800
4856
  }
@@ -4885,7 +4941,7 @@ export class InteractiveMode {
4885
4941
  // Export to a temp file
4886
4942
  const tmpFile = path.join(os.tmpdir(), "session.html");
4887
4943
  try {
4888
- await this.session.exportToHtml(tmpFile);
4944
+ await this.session.exportToHtml(tmpFile, { themeName: theme.name });
4889
4945
  }
4890
4946
  catch (error) {
4891
4947
  this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -5326,7 +5382,7 @@ export class InteractiveMode {
5326
5382
  // Ignore, will be emitted as an event
5327
5383
  }
5328
5384
  }
5329
- stop() {
5385
+ stop(fullscreenExitOutput = this.settingsManager.getFullscreenExitOutput()) {
5330
5386
  if (this.settingsManager.getShowTerminalProgress()) {
5331
5387
  this.ui.terminal.setProgress(false);
5332
5388
  }
@@ -5339,7 +5395,7 @@ export class InteractiveMode {
5339
5395
  this.unsubscribe();
5340
5396
  }
5341
5397
  if (this.isInitialized) {
5342
- this.stopInteractiveTui();
5398
+ this.stopInteractiveTui(fullscreenExitOutput);
5343
5399
  this.isInitialized = false;
5344
5400
  }
5345
5401
  this.unregisterSignalHandlers();
@@ -0,0 +1,6 @@
1
+ import type { ModelsRefreshResult } from "@earendil-works/pi-ai";
2
+ import type { ModelRuntime } from "../../core/model-runtime.ts";
3
+ type ModelCatalogRuntime = Pick<ModelRuntime, "refresh">;
4
+ /** Share concurrent interactive all-catalog refreshes while keeping each caller's cancellation independent. */
5
+ export declare function refreshModelCatalogs(modelRuntime: ModelCatalogRuntime, signal: AbortSignal): Promise<ModelsRefreshResult>;
6
+ export {};
@@ -0,0 +1,33 @@
1
+ import { raceWithAbortSignal } from "../../utils/abort.js";
2
+ class ModelCatalogRefreshCoordinator {
3
+ activeByRuntime = new WeakMap();
4
+ refresh(modelRuntime, signal) {
5
+ signal.throwIfAborted();
6
+ let active = this.activeByRuntime.get(modelRuntime);
7
+ if (!active) {
8
+ const controller = new AbortController();
9
+ let created;
10
+ const operation = modelRuntime.refresh({ signal: controller.signal });
11
+ const promise = raceWithAbortSignal(operation, controller.signal).finally(() => {
12
+ if (this.activeByRuntime.get(modelRuntime) === created) {
13
+ this.activeByRuntime.delete(modelRuntime);
14
+ }
15
+ });
16
+ created = { controller, promise, waiters: 0 };
17
+ active = created;
18
+ this.activeByRuntime.set(modelRuntime, active);
19
+ }
20
+ active.waiters++;
21
+ return raceWithAbortSignal(active.promise, signal).finally(() => {
22
+ active.waiters--;
23
+ if (active.waiters === 0 && this.activeByRuntime.get(modelRuntime) === active) {
24
+ active.controller.abort();
25
+ }
26
+ });
27
+ }
28
+ }
29
+ const modelCatalogRefreshCoordinator = new ModelCatalogRefreshCoordinator();
30
+ /** Share concurrent interactive all-catalog refreshes while keeping each caller's cancellation independent. */
31
+ export function refreshModelCatalogs(modelRuntime, signal) {
32
+ return modelCatalogRefreshCoordinator.refresh(modelRuntime, signal);
33
+ }
@@ -34,6 +34,8 @@
34
34
 
35
35
  "selectedBg": "selectedBg",
36
36
  "scrollbarThumb": "selectedBg",
37
+ "searchMatchBg": "selectedBg",
38
+ "searchMatchText": "text",
37
39
  "userMessageBg": "userMsgBg",
38
40
  "userMessageText": "text",
39
41
  "customMessageBg": "customMsgBg",
@@ -33,6 +33,8 @@
33
33
 
34
34
  "selectedBg": "selectedBg",
35
35
  "scrollbarThumb": "selectedBg",
36
+ "searchMatchBg": "selectedBg",
37
+ "searchMatchText": "text",
36
38
  "userMessageBg": "userMsgBg",
37
39
  "userMessageText": "text",
38
40
  "customMessageBg": "customMsgBg",
@@ -7,17 +7,25 @@ type ThemeResult = {
7
7
  };
8
8
  export declare class InteractiveThemeController {
9
9
  private readonly ui;
10
- private readonly settingsManager;
10
+ private readonly getSettingsManager;
11
11
  private readonly showError;
12
12
  private readonly onChanged;
13
+ private currentThemeSetting;
13
14
  private terminalTheme;
14
15
  private activeThemeName;
15
16
  private autoSyncEnabled;
16
17
  private terminalColorSchemeUnsubscribe;
17
- constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void);
18
+ constructor(ui: TUI, options: {
19
+ getSettingsManager: () => SettingsManager;
20
+ showError: (message: string) => void;
21
+ onChanged: () => void;
22
+ initialThemeSetting?: string;
23
+ });
18
24
  rebindTui(): void;
19
25
  applyFromSettings(): Promise<void>;
26
+ getThemeSelection(): string | undefined;
20
27
  setThemeName(themeName: string, showError?: boolean): ThemeResult;
28
+ setThemeSetting(themeSetting: string): Promise<void>;
21
29
  setThemeInstance(themeInstance: Theme): ThemeResult;
22
30
  preview(themeSettingOrName: string): void;
23
31
  disableAutoSync(): void;
@@ -1,19 +1,21 @@
1
1
  import { detectTerminalBackgroundFromEnv, detectTerminalBackgroundTheme, detectTerminalThemeForAuto, initTheme, parseAutoThemeSetting, resolveThemeSetting, setTheme, setThemeInstance, } from "./theme.js";
2
2
  export class InteractiveThemeController {
3
3
  ui;
4
- settingsManager;
4
+ getSettingsManager;
5
5
  showError;
6
6
  onChanged;
7
+ currentThemeSetting;
7
8
  terminalTheme = detectTerminalBackgroundFromEnv().theme;
8
9
  activeThemeName;
9
10
  autoSyncEnabled = false;
10
11
  terminalColorSchemeUnsubscribe;
11
- constructor(ui, settingsManager, showError, onChanged) {
12
+ constructor(ui, options) {
12
13
  this.ui = ui;
13
- this.settingsManager = settingsManager;
14
- this.showError = showError;
15
- this.onChanged = onChanged;
16
- this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
14
+ this.getSettingsManager = options.getSettingsManager;
15
+ this.showError = options.showError;
16
+ this.onChanged = options.onChanged;
17
+ this.currentThemeSetting = options.initialThemeSetting;
18
+ this.activeThemeName = resolveThemeSetting(this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting(), this.terminalTheme);
17
19
  initTheme(this.activeThemeName, true);
18
20
  this.bindTerminalColorSchemeListener();
19
21
  }
@@ -23,7 +25,8 @@ export class InteractiveThemeController {
23
25
  this.ui.setTerminalColorSchemeNotifications(this.autoSyncEnabled);
24
26
  }
25
27
  async applyFromSettings() {
26
- const themeSetting = this.settingsManager.getThemeSetting();
28
+ const settingsManager = this.getSettingsManager();
29
+ const themeSetting = this.currentThemeSetting ?? settingsManager.getThemeSetting();
27
30
  const autoTheme = parseAutoThemeSetting(themeSetting);
28
31
  if (autoTheme) {
29
32
  this.terminalTheme = await detectTerminalThemeForAuto({ ui: this.ui, timeoutMs: 100 });
@@ -41,13 +44,24 @@ export class InteractiveThemeController {
41
44
  if (!this.applyThemeName(detection.theme).success)
42
45
  return;
43
46
  if (detection.confidence === "high") {
44
- this.settingsManager.setTheme(detection.theme);
45
- await this.settingsManager.flush();
47
+ settingsManager.setTheme(detection.theme);
48
+ await settingsManager.flush();
46
49
  }
47
50
  }
51
+ getThemeSelection() {
52
+ return this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting() ?? this.activeThemeName;
53
+ }
48
54
  setThemeName(themeName, showError = false) {
49
55
  this.setAutoSync(false);
50
- return this.applyThemeName(themeName, showError);
56
+ const result = this.applyThemeName(themeName, showError);
57
+ if (result.success) {
58
+ this.currentThemeSetting = themeName;
59
+ }
60
+ return result;
61
+ }
62
+ async setThemeSetting(themeSetting) {
63
+ this.currentThemeSetting = themeSetting;
64
+ await this.applyFromSettings();
51
65
  }
52
66
  setThemeInstance(themeInstance) {
53
67
  this.setAutoSync(false);
@@ -97,7 +111,7 @@ export class InteractiveThemeController {
97
111
  if (!this.autoSyncEnabled)
98
112
  return;
99
113
  this.terminalTheme = terminalTheme;
100
- const autoTheme = parseAutoThemeSetting(this.settingsManager.getThemeSetting());
114
+ const autoTheme = parseAutoThemeSetting(this.currentThemeSetting ?? this.getSettingsManager().getThemeSetting());
101
115
  if (!autoTheme) {
102
116
  this.setAutoSync(false);
103
117
  return;
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "colors": {
36
36
  "type": "object",
37
- "description": "Theme color definitions (thinkingMax is optional and falls back to thinkingXhigh)",
37
+ "description": "Theme color definitions (thinkingMax and scrollbarThumb are optional and use compatible fallbacks)",
38
38
  "required": [
39
39
  "accent",
40
40
  "border",
@@ -137,6 +137,14 @@
137
137
  "$ref": "#/$defs/colorValue",
138
138
  "description": "Selected item background"
139
139
  },
140
+ "searchMatchBg": {
141
+ "$ref": "#/$defs/colorValue",
142
+ "description": "Transcript search match background and current-match text (falls back to selectedBg when omitted)"
143
+ },
144
+ "searchMatchText": {
145
+ "$ref": "#/$defs/colorValue",
146
+ "description": "Transcript search match text and current-match background (falls back to text when omitted)"
147
+ },
140
148
  "userMessageBg": {
141
149
  "$ref": "#/$defs/colorValue",
142
150
  "description": "User message background"