@selesai/code 0.8.1 → 0.8.3

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 (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -2
  3. package/dist/cli/args.d.ts +2 -0
  4. package/dist/cli/args.js +18 -0
  5. package/dist/cli/config-selector.js +2 -2
  6. package/dist/cli/startup-ui.d.ts +1 -1
  7. package/dist/cli/startup-ui.js +2 -2
  8. package/dist/core/keybindings.d.ts +44 -4
  9. package/dist/core/llama/provider.js +22 -7
  10. package/dist/core/model-resolver.js +2 -0
  11. package/dist/core/model-runtime.d.ts +4 -2
  12. package/dist/core/model-runtime.js +23 -2
  13. package/dist/core/remote-catalog-provider.js +72 -67
  14. package/dist/core/settings-manager.d.ts +8 -0
  15. package/dist/core/settings-manager.js +18 -0
  16. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +3 -1
  17. package/dist/extensions/pi-powerline-footer/guide.ts +11 -3
  18. package/dist/extensions/pi-powerline-footer/index.ts +3 -272
  19. package/dist/extensions/pi-powerline-footer/package.json +4 -5
  20. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -6
  21. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +2 -2
  22. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +24 -1
  23. package/dist/extensions/pi-powerline-footer/tsconfig.json +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/main.js +1 -0
  29. package/dist/modes/interactive/components/settings-selector.d.ts +6 -2
  30. package/dist/modes/interactive/components/settings-selector.js +20 -0
  31. package/dist/modes/interactive/interactive-mode.d.ts +28 -0
  32. package/dist/modes/interactive/interactive-mode.js +214 -28
  33. package/dist/modes/interactive/theme/dark.json +1 -0
  34. package/dist/modes/interactive/theme/light.json +1 -0
  35. package/dist/modes/interactive/theme/theme-controller.d.ts +3 -0
  36. package/dist/modes/interactive/theme/theme-controller.js +10 -1
  37. package/dist/modes/interactive/theme/theme.d.ts +2 -2
  38. package/dist/modes/interactive/theme/theme.js +24 -6
  39. package/docs/keybindings.md +35 -7
  40. package/docs/settings.md +16 -14
  41. package/docs/usage.md +5 -0
  42. package/package.json +5 -5
  43. package/dist/extensions/pi-powerline-footer/fixed-editor/cluster.ts +0 -113
  44. package/dist/extensions/pi-powerline-footer/fixed-editor/terminal-split.ts +0 -1080
  45. package/dist/extensions/pi-powerline-footer/tests/fixed-editor.test.ts +0 -1452
  46. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -49
@@ -3,8 +3,11 @@
3
3
  * Handles TUI rendering and user interaction, delegating business logic to AgentSession.
4
4
  */
5
5
  import type { ImageContent } from "@earendil-works/pi-ai/compat";
6
+ import type { Terminal } from "@earendil-works/pi-tui";
7
+ import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
6
8
  import { type AgentSessionRuntime } from "../../core/agent-session-runtime.ts";
7
9
  import { SessionManager } from "../../core/session-manager.ts";
10
+ import type { TuiMode } from "../../core/settings-manager.ts";
8
11
  import { type LatestPiRelease } from "../../utils/version-check.ts";
9
12
  type PendingUserInput = {
10
13
  text: string;
@@ -29,14 +32,32 @@ export interface InteractiveModeOptions {
29
32
  initialMessages?: string[];
30
33
  /** Force verbose startup (overrides quietStartup setting) */
31
34
  verbose?: boolean;
35
+ /** TUI layout mode. */
36
+ tuiMode?: TuiMode;
32
37
  }
38
+ interface InteractiveTuiOptions {
39
+ tuiMode: TuiMode;
40
+ showHardwareCursor: boolean;
41
+ logDirectory: string;
42
+ terminal?: Terminal;
43
+ onRightClickPaste?: () => void;
44
+ }
45
+ /** Composition root for selecting the interactive terminal renderer. */
46
+ export declare function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen;
47
+ /** Stable reference for components while InteractiveMode replaces the active renderer. */
48
+ export declare function createInteractiveTuiReference(getTui: () => TUI): TUI;
33
49
  export declare class InteractiveMode {
34
50
  private runtimeHost;
51
+ private renderer;
35
52
  private ui;
53
+ private mainScreenRenderState;
54
+ private documentContainer;
36
55
  private loadedResourcesContainer;
37
56
  private chatContainer;
38
57
  private pendingMessagesContainer;
39
58
  private statusContainer;
59
+ private transcriptScrollView;
60
+ private fullscreenLayoutRoot;
40
61
  private defaultEditor;
41
62
  private editor;
42
63
  private editorComponentFactory;
@@ -45,6 +66,7 @@ export declare class InteractiveMode {
45
66
  private fdPath;
46
67
  private editorContainer;
47
68
  private footer;
69
+ private footerContainer;
48
70
  private footerDataProvider;
49
71
  private keybindings;
50
72
  private version;
@@ -95,6 +117,7 @@ export declare class InteractiveMode {
95
117
  private builtInHeader;
96
118
  private customHeader;
97
119
  private options;
120
+ private readonly onRightClickPaste;
98
121
  private autoTrustOnReloadCwd;
99
122
  private themeController;
100
123
  private get session();
@@ -108,6 +131,9 @@ export declare class InteractiveMode {
108
131
  private createBaseAutocompleteProvider;
109
132
  private setupAutocompleteProvider;
110
133
  private showStartupNoticesIfNeeded;
134
+ private mountInteractiveTui;
135
+ private stopInteractiveTui;
136
+ private switchTuiMode;
111
137
  init(): Promise<void>;
112
138
  /**
113
139
  * Update terminal title with session name and cwd.
@@ -193,6 +219,7 @@ export declare class InteractiveMode {
193
219
  */
194
220
  private setExtensionHeader;
195
221
  private addExtensionTerminalInputListener;
222
+ private rebindExtensionTerminalInputListeners;
196
223
  private clearExtensionTerminalInputListeners;
197
224
  /**
198
225
  * Create the ExtensionUIContext for extensions.
@@ -240,6 +267,7 @@ export declare class InteractiveMode {
240
267
  */
241
268
  private showExtensionError;
242
269
  private setupKeyHandlers;
270
+ private handleRightClickPaste;
243
271
  private handleClipboardPaste;
244
272
  private consumePendingInputImages;
245
273
  private handleGitCommand;
@@ -5,7 +5,8 @@
5
5
  import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
- import { CombinedAutocompleteProvider, Container, fuzzyFilter, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TUI, visibleWidth, } from "@earendil-works/pi-tui";
8
+ import * as TuiLayouts from "@earendil-works/pi-tui";
9
+ import { CombinedAutocompleteProvider, Container, fuzzyFilter, Markdown, matchesKey, ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, TuiAltScreen, TuiMainScreen, visibleWidth, } from "@earendil-works/pi-tui";
9
10
  import chalk from "chalk";
10
11
  import { spawn, spawnSync } from "child_process";
11
12
  import { APP_NAME, APP_TITLE, CONFIG_DIR_NAME, getAgentDir, getAuthPath, getDebugLogPath, getDocsPath, getShareViewerUrl, getUpdateInstruction, PACKAGE_NAME, VERSION, } from "../../config.js";
@@ -31,6 +32,7 @@ import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipb
31
32
  import { parseGitUrl } from "../../utils/git.js";
32
33
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
33
34
  import { getCwdRelativePath } from "../../utils/paths.js";
35
+ import { openBrowser } from "../../utils/open-browser.js";
34
36
  import { getPiUserAgent } from "../../utils/pi-user-agent.js";
35
37
  import { killTrackedDetachedChildren } from "../../utils/shell.js";
36
38
  import { ensureTool } from "../../utils/tools-manager.js";
@@ -162,13 +164,60 @@ function formatLoginProviderCompletionDescription(provider) {
162
164
  const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/");
163
165
  return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`;
164
166
  }
167
+ /** Composition root for selecting the interactive terminal renderer. */
168
+ export function createInteractiveTui(options) {
169
+ const terminal = options.terminal ?? new ProcessTerminal();
170
+ if (options.tuiMode === "fullscreen") {
171
+ return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {
172
+ openUrl: openBrowser,
173
+ onRightClickPaste: options.onRightClickPaste,
174
+ });
175
+ }
176
+ return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
177
+ }
178
+ /** Stable reference for components while InteractiveMode replaces the active renderer. */
179
+ export function createInteractiveTuiReference(getTui) {
180
+ return new Proxy({}, {
181
+ get: (_target, property) => {
182
+ const tui = getTui();
183
+ const value = Reflect.get(tui, property, tui);
184
+ if (typeof value !== "function")
185
+ return value;
186
+ let methodTui = tui;
187
+ let method = value;
188
+ return (...args) => {
189
+ const currentTui = getTui();
190
+ if (currentTui !== methodTui) {
191
+ const currentMethod = Reflect.get(currentTui, property, currentTui);
192
+ if (typeof currentMethod !== "function") {
193
+ throw new TypeError(`TUI property ${String(property)} is not callable`);
194
+ }
195
+ methodTui = currentTui;
196
+ method = currentMethod;
197
+ }
198
+ return Reflect.apply(method, methodTui, args);
199
+ };
200
+ },
201
+ set: (_target, property, value) => {
202
+ const tui = getTui();
203
+ return Reflect.set(tui, property, value, tui);
204
+ },
205
+ has: (_target, property) => Reflect.has(getTui(), property),
206
+ getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
207
+ });
208
+ }
165
209
  export class InteractiveMode {
166
210
  runtimeHost;
211
+ renderer;
167
212
  ui;
213
+ mainScreenRenderState;
214
+ documentContainer;
168
215
  loadedResourcesContainer;
169
216
  chatContainer;
170
217
  pendingMessagesContainer;
171
218
  statusContainer;
219
+ transcriptScrollView;
220
+ fullscreenLayoutRoot;
172
221
  defaultEditor;
173
222
  editor;
174
223
  editorComponentFactory;
@@ -177,6 +226,7 @@ export class InteractiveMode {
177
226
  fdPath;
178
227
  editorContainer;
179
228
  footer;
229
+ footerContainer;
180
230
  footerDataProvider;
181
231
  // Stored so the same manager can be injected into custom editors, selectors, and extension UI.
182
232
  keybindings;
@@ -232,7 +282,9 @@ export class InteractiveMode {
232
282
  extensionSelector = undefined;
233
283
  extensionInput = undefined;
234
284
  extensionEditor = undefined;
235
- extensionTerminalInputUnsubscribers = new Set();
285
+ // Extension terminal input listeners, keyed by handler so they can be
286
+ // re-registered on the active renderer after a TUI mode switch.
287
+ extensionTerminalInputUnsubscribers = new Map();
236
288
  // Extension widgets (components rendered above/below the editor)
237
289
  extensionWidgetsAbove = new Map();
238
290
  extensionWidgetsBelow = new Map();
@@ -247,6 +299,9 @@ export class InteractiveMode {
247
299
  // Custom header from extension (undefined = use built-in header)
248
300
  customHeader = undefined;
249
301
  options;
302
+ onRightClickPaste = () => {
303
+ void this.handleRightClickPaste();
304
+ };
250
305
  autoTrustOnReloadCwd;
251
306
  themeController;
252
307
  // Convenience accessors
@@ -264,7 +319,8 @@ export class InteractiveMode {
264
319
  }
265
320
  constructor(runtimeHost, options = {}) {
266
321
  this.runtimeHost = runtimeHost;
267
- this.options = options;
322
+ const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode();
323
+ this.options = { ...options, tuiMode };
268
324
  this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd;
269
325
  this.runtimeHost.setBeforeSessionInvalidate(() => {
270
326
  this.resetExtensionUI();
@@ -273,11 +329,21 @@ export class InteractiveMode {
273
329
  await this.rebindCurrentSession({ renderBeforeBind: true });
274
330
  });
275
331
  this.version = VERSION;
276
- this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor(), getAgentDir());
332
+ this.renderer = createInteractiveTui({
333
+ tuiMode,
334
+ showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
335
+ logDirectory: getAgentDir(),
336
+ onRightClickPaste: this.onRightClickPaste,
337
+ });
338
+ this.ui = createInteractiveTuiReference(() => this.renderer);
277
339
  this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
340
+ this.documentContainer = new Container();
278
341
  this.headerContainer = new Container();
279
342
  this.loadedResourcesContainer = new Container();
280
343
  this.chatContainer = new Container();
344
+ this.documentContainer.addChild(this.headerContainer);
345
+ this.documentContainer.addChild(this.loadedResourcesContainer);
346
+ this.documentContainer.addChild(this.chatContainer);
281
347
  this.pendingMessagesContainer = new Container();
282
348
  this.statusContainer = new Container();
283
349
  this.widgetContainerAbove = new Container();
@@ -296,6 +362,8 @@ export class InteractiveMode {
296
362
  this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
297
363
  this.footer = new FooterComponent(this.session, this.footerDataProvider);
298
364
  this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
365
+ this.footerContainer = new Container();
366
+ this.footerContainer.addChild(this.footer);
299
367
  // Load hide thinking block setting
300
368
  this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
301
369
  this.outputPad = this.settingsManager.getOutputPad();
@@ -453,6 +521,73 @@ export class InteractiveMode {
453
521
  }
454
522
  this.chatContainer.addChild(new DynamicBorder());
455
523
  }
524
+ mountInteractiveTui(tui, components) {
525
+ for (const component of components)
526
+ tui.addChild(component);
527
+ if (TuiLayouts.isViewportTUI(tui)) {
528
+ if (!this.fullscreenLayoutRoot)
529
+ throw new Error("Fullscreen layout is not initialized");
530
+ tui.setLayoutRoot(this.fullscreenLayoutRoot);
531
+ }
532
+ }
533
+ stopInteractiveTui() {
534
+ if (this.renderer.mode === "fullscreen") {
535
+ while (this.renderer.hasOverlayEntries)
536
+ this.renderer.hideOverlay();
537
+ this.switchTuiMode("regular", false, false);
538
+ this.renderer.renderNow();
539
+ }
540
+ this.ui.stop();
541
+ }
542
+ switchTuiMode(mode, restoreProgress = true, startRenderer = true) {
543
+ const previousUi = this.renderer;
544
+ if (mode === previousUi.mode)
545
+ return true;
546
+ if (previousUi.hasOverlayEntries)
547
+ return false;
548
+ const components = [...previousUi.children];
549
+ const focus = previousUi.getFocusedComponent();
550
+ const terminal = previousUi.terminal;
551
+ const showHardwareCursor = previousUi.getShowHardwareCursor();
552
+ const clearOnShrink = previousUi.getClearOnShrink();
553
+ const onDebug = previousUi.onDebug;
554
+ if (previousUi instanceof TuiMainScreen) {
555
+ this.mainScreenRenderState = previousUi.captureRenderState();
556
+ }
557
+ previousUi.stop({ preserveScreen: true });
558
+ previousUi.setFocus(null);
559
+ previousUi.clear();
560
+ if (TuiLayouts.isViewportTUI(previousUi))
561
+ previousUi.setLayoutRoot(undefined);
562
+ const nextUi = createInteractiveTui({
563
+ tuiMode: mode,
564
+ showHardwareCursor,
565
+ logDirectory: getAgentDir(),
566
+ terminal,
567
+ onRightClickPaste: this.onRightClickPaste,
568
+ });
569
+ nextUi.setClearOnShrink(clearOnShrink);
570
+ nextUi.onDebug = onDebug;
571
+ if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) {
572
+ nextUi.restoreRenderState(this.mainScreenRenderState);
573
+ }
574
+ this.renderer = nextUi;
575
+ this.options.tuiMode = mode;
576
+ this.mountInteractiveTui(nextUi, components);
577
+ nextUi.invalidate();
578
+ nextUi.setFocus(focus);
579
+ if (!startRenderer)
580
+ return true;
581
+ nextUi.start();
582
+ this.themeController.rebindTui();
583
+ this.rebindExtensionTerminalInputListeners();
584
+ if (restoreProgress &&
585
+ this.settingsManager.getShowTerminalProgress() &&
586
+ (this.session.isStreaming || this.session.isCompacting)) {
587
+ terminal.setProgress(true);
588
+ }
589
+ return true;
590
+ }
456
591
  async init() {
457
592
  if (this.isInitialized)
458
593
  return;
@@ -476,18 +611,36 @@ export class InteractiveMode {
476
611
  : "";
477
612
  console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
478
613
  }
479
- // Add header container as first child. Populate it after applying theme settings.
480
- // Keep loaded resources before chat so restored session messages never precede them.
481
- this.ui.addChild(this.headerContainer);
482
- this.ui.addChild(this.loadedResourcesContainer);
483
- this.ui.addChild(this.chatContainer);
484
- this.ui.addChild(this.pendingMessagesContainer);
485
- this.ui.addChild(this.statusContainer);
614
+ // Keep one component tree and remount it when changing renderers.
486
615
  this.renderWidgets(); // Initialize with default spacer
487
- this.ui.addChild(this.widgetContainerAbove);
488
- this.ui.addChild(this.editorContainer);
489
- this.ui.addChild(this.widgetContainerBelow);
490
- this.ui.addChild(this.footer);
616
+ this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, {
617
+ follow: "end",
618
+ primary: true,
619
+ overscroll: "chain",
620
+ scrollbar: this.settingsManager.getFullscreenScrollbar(),
621
+ scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
622
+ });
623
+ const dock = new TuiLayouts.VStack([
624
+ { component: this.pendingMessagesContainer, shrink: 1, minSize: 0 },
625
+ { component: this.statusContainer, shrink: 1, minSize: 0 },
626
+ { component: this.widgetContainerAbove, shrink: 1, minSize: 0 },
627
+ { component: this.editorContainer, shrink: 1, minSize: 3 },
628
+ { component: this.widgetContainerBelow, shrink: 1, minSize: 0 },
629
+ { component: this.footerContainer, shrink: 1, minSize: 1 },
630
+ ]);
631
+ this.fullscreenLayoutRoot = new TuiLayouts.VStack([
632
+ { component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
633
+ { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
634
+ ]);
635
+ this.mountInteractiveTui(this.renderer, [
636
+ this.documentContainer,
637
+ this.pendingMessagesContainer,
638
+ this.statusContainer,
639
+ this.widgetContainerAbove,
640
+ this.editorContainer,
641
+ this.widgetContainerBelow,
642
+ this.footerContainer,
643
+ ]);
491
644
  this.ui.setFocus(this.editor);
492
645
  this.setupKeyHandlers();
493
646
  this.setupEditorSubmitHandler();
@@ -1577,22 +1730,16 @@ export class InteractiveMode {
1577
1730
  if (this.customFooter?.dispose) {
1578
1731
  this.customFooter.dispose();
1579
1732
  }
1580
- // Remove current footer from UI
1581
- if (this.customFooter) {
1582
- this.ui.removeChild(this.customFooter);
1583
- }
1584
- else {
1585
- this.ui.removeChild(this.footer);
1586
- }
1733
+ this.footerContainer.clear();
1587
1734
  if (factory) {
1588
1735
  // Create and add custom footer, passing the data provider
1589
1736
  this.customFooter = factory(this.ui, theme, this.footerDataProvider);
1590
- this.ui.addChild(this.customFooter);
1737
+ this.footerContainer.addChild(this.customFooter);
1591
1738
  }
1592
1739
  else {
1593
1740
  // Restore built-in footer
1594
1741
  this.customFooter = undefined;
1595
- this.ui.addChild(this.footer);
1742
+ this.footerContainer.addChild(this.footer);
1596
1743
  }
1597
1744
  this.ui.requestRender();
1598
1745
  }
@@ -1639,14 +1786,19 @@ export class InteractiveMode {
1639
1786
  }
1640
1787
  addExtensionTerminalInputListener(handler) {
1641
1788
  const unsubscribe = this.ui.addInputListener(handler);
1642
- this.extensionTerminalInputUnsubscribers.add(unsubscribe);
1789
+ this.extensionTerminalInputUnsubscribers.set(handler, unsubscribe);
1643
1790
  return () => {
1644
1791
  unsubscribe();
1645
- this.extensionTerminalInputUnsubscribers.delete(unsubscribe);
1792
+ this.extensionTerminalInputUnsubscribers.delete(handler);
1646
1793
  };
1647
1794
  }
1795
+ rebindExtensionTerminalInputListeners() {
1796
+ for (const handler of this.extensionTerminalInputUnsubscribers.keys()) {
1797
+ this.extensionTerminalInputUnsubscribers.set(handler, this.ui.addInputListener(handler));
1798
+ }
1799
+ }
1648
1800
  clearExtensionTerminalInputListeners() {
1649
- for (const unsubscribe of this.extensionTerminalInputUnsubscribers) {
1801
+ for (const unsubscribe of this.extensionTerminalInputUnsubscribers.values()) {
1650
1802
  unsubscribe();
1651
1803
  }
1652
1804
  this.extensionTerminalInputUnsubscribers.clear();
@@ -2074,6 +2226,22 @@ export class InteractiveMode {
2074
2226
  void this.handleClipboardPaste();
2075
2227
  };
2076
2228
  }
2229
+ async handleRightClickPaste() {
2230
+ const target = this.renderer.getFocusedComponent();
2231
+ const handleInput = target?.handleInput;
2232
+ if (!target || !handleInput)
2233
+ return;
2234
+ try {
2235
+ const text = await readClipboardText();
2236
+ if (!text || this.renderer.getFocusedComponent() !== target)
2237
+ return;
2238
+ handleInput.call(target, `\x1b[200~${text}\x1b[201~`);
2239
+ this.ui.requestRender();
2240
+ }
2241
+ catch {
2242
+ // Silently ignore clipboard errors (may not have permission, etc.)
2243
+ }
2244
+ }
2077
2245
  async handleClipboardPaste() {
2078
2246
  try {
2079
2247
  const image = await readClipboardImage();
@@ -3470,6 +3638,8 @@ export class InteractiveMode {
3470
3638
  quietStartup: this.settingsManager.getQuietStartup(),
3471
3639
  clearOnShrink: this.settingsManager.getClearOnShrink(),
3472
3640
  showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
3641
+ tuiMode: this.settingsManager.getTuiMode(),
3642
+ fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
3473
3643
  warnings: this.settingsManager.getWarnings(),
3474
3644
  }, {
3475
3645
  onAutoCompactChange: (enabled) => {
@@ -3609,6 +3779,22 @@ export class InteractiveMode {
3609
3779
  onShowTerminalProgressChange: (enabled) => {
3610
3780
  this.settingsManager.setShowTerminalProgress(enabled);
3611
3781
  },
3782
+ onTuiModeChange: (mode) => {
3783
+ if (!this.switchTuiMode(mode)) {
3784
+ this.settingsManager.setTuiMode(this.ui.mode);
3785
+ this.showStatus("Close active overlays before changing TUI mode");
3786
+ return;
3787
+ }
3788
+ this.settingsManager.setTuiMode(mode);
3789
+ if (!this.activeStatusIndicator)
3790
+ this.statusContainer.clear();
3791
+ this.showStatus(`TUI mode: ${mode}`);
3792
+ },
3793
+ onFullscreenScrollbarChange: (mode) => {
3794
+ this.settingsManager.setFullscreenScrollbar(mode);
3795
+ this.transcriptScrollView?.setScrollbar(mode);
3796
+ this.ui.requestRender();
3797
+ },
3612
3798
  onWarningsChange: (warnings) => {
3613
3799
  this.settingsManager.setWarnings(warnings);
3614
3800
  },
@@ -5106,7 +5292,7 @@ export class InteractiveMode {
5106
5292
  this.unsubscribe();
5107
5293
  }
5108
5294
  if (this.isInitialized) {
5109
- this.ui.stop();
5295
+ this.stopInteractiveTui();
5110
5296
  this.isInitialized = false;
5111
5297
  }
5112
5298
  this.unregisterSignalHandlers();
@@ -33,6 +33,7 @@
33
33
  "thinkingText": "gray",
34
34
 
35
35
  "selectedBg": "selectedBg",
36
+ "scrollbarThumb": "selectedBg",
36
37
  "userMessageBg": "userMsgBg",
37
38
  "userMessageText": "text",
38
39
  "customMessageBg": "customMsgBg",
@@ -32,6 +32,7 @@
32
32
  "thinkingText": "mediumGray",
33
33
 
34
34
  "selectedBg": "selectedBg",
35
+ "scrollbarThumb": "selectedBg",
35
36
  "userMessageBg": "userMsgBg",
36
37
  "userMessageText": "text",
37
38
  "customMessageBg": "customMsgBg",
@@ -13,7 +13,9 @@ export declare class InteractiveThemeController {
13
13
  private terminalTheme;
14
14
  private activeThemeName;
15
15
  private autoSyncEnabled;
16
+ private terminalColorSchemeUnsubscribe;
16
17
  constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void);
18
+ rebindTui(): void;
17
19
  applyFromSettings(): Promise<void>;
18
20
  setThemeName(themeName: string, showError?: boolean): ThemeResult;
19
21
  setThemeInstance(themeInstance: Theme): ThemeResult;
@@ -23,6 +25,7 @@ export declare class InteractiveThemeController {
23
25
  private applyThemeName;
24
26
  private notifyChanged;
25
27
  private setAutoSync;
28
+ private bindTerminalColorSchemeListener;
26
29
  private applyTerminalTheme;
27
30
  }
28
31
  export {};
@@ -7,6 +7,7 @@ export class InteractiveThemeController {
7
7
  terminalTheme = detectTerminalBackgroundFromEnv().theme;
8
8
  activeThemeName;
9
9
  autoSyncEnabled = false;
10
+ terminalColorSchemeUnsubscribe;
10
11
  constructor(ui, settingsManager, showError, onChanged) {
11
12
  this.ui = ui;
12
13
  this.settingsManager = settingsManager;
@@ -14,7 +15,12 @@ export class InteractiveThemeController {
14
15
  this.onChanged = onChanged;
15
16
  this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
16
17
  initTheme(this.activeThemeName, true);
17
- this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
18
+ this.bindTerminalColorSchemeListener();
19
+ }
20
+ rebindTui() {
21
+ this.terminalColorSchemeUnsubscribe?.();
22
+ this.bindTerminalColorSchemeListener();
23
+ this.ui.setTerminalColorSchemeNotifications(this.autoSyncEnabled);
18
24
  }
19
25
  async applyFromSettings() {
20
26
  const themeSetting = this.settingsManager.getThemeSetting();
@@ -84,6 +90,9 @@ export class InteractiveThemeController {
84
90
  this.autoSyncEnabled = enabled;
85
91
  this.ui.setTerminalColorSchemeNotifications(enabled);
86
92
  }
93
+ bindTerminalColorSchemeListener() {
94
+ this.terminalColorSchemeUnsubscribe = this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
95
+ }
87
96
  applyTerminalTheme(terminalTheme) {
88
97
  if (!this.autoSyncEnabled)
89
98
  return;
@@ -2,7 +2,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import { type EditorTheme, type MarkdownTheme, type RgbColor, type SelectListTheme, type SettingsListTheme } from "@earendil-works/pi-tui";
3
3
  import type { SourceInfo } from "../../../core/source-info.ts";
4
4
  export type ThemeColor = "accent" | "border" | "borderAccent" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text" | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" | "toolTitle" | "toolOutput" | "mdHeading" | "mdLink" | "mdLinkUrl" | "mdCode" | "mdCodeBlock" | "mdCodeBlockBorder" | "mdQuote" | "mdQuoteBorder" | "mdHr" | "mdListBullet" | "toolDiffAdded" | "toolDiffRemoved" | "toolDiffContext" | "syntaxComment" | "syntaxKeyword" | "syntaxFunction" | "syntaxVariable" | "syntaxString" | "syntaxNumber" | "syntaxType" | "syntaxOperator" | "syntaxPunctuation" | "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax" | "bashMode";
5
- export type ThemeBg = "selectedBg" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
5
+ export type ThemeBg = "selectedBg" | "scrollbarThumb" | "userMessageBg" | "customMessageBg" | "toolPendingBg" | "toolSuccessBg" | "toolErrorBg";
6
6
  type ColorMode = "truecolor" | "256color";
7
7
  export declare class Theme {
8
8
  readonly name?: string;
@@ -11,7 +11,7 @@ export declare class Theme {
11
11
  private fgColors;
12
12
  private bgColors;
13
13
  private mode;
14
- constructor(fgColors: Record<ThemeColor, string | number>, bgColors: Record<ThemeBg, string | number>, mode: ColorMode, options?: {
14
+ constructor(fgColors: Record<ThemeColor, string | number>, bgColors: Record<Exclude<ThemeBg, "scrollbarThumb">, string | number> & Partial<Record<"scrollbarThumb", string | number>>, mode: ColorMode, options?: {
15
15
  name?: string;
16
16
  sourcePath?: string;
17
17
  sourceInfo?: SourceInfo;
@@ -31,8 +31,9 @@ const ThemeJsonSchema = Type.Object({
31
31
  dim: ColorValueSchema,
32
32
  text: ColorValueSchema,
33
33
  thinkingText: ColorValueSchema,
34
- // Backgrounds & Content Text (11 colors)
34
+ // Backgrounds & Content Text (11 required, 1 optional)
35
35
  selectedBg: ColorValueSchema,
36
+ scrollbarThumb: Type.Optional(ColorValueSchema),
36
37
  userMessageBg: ColorValueSchema,
37
38
  userMessageText: ColorValueSchema,
38
39
  customMessageBg: ColorValueSchema,
@@ -224,7 +225,11 @@ function resolveThemeColors(colors, vars = {}) {
224
225
  return resolved;
225
226
  }
226
227
  function withThemeColorFallbacks(colors) {
227
- return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh };
228
+ return {
229
+ ...colors,
230
+ thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh,
231
+ scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg,
232
+ };
228
233
  }
229
234
  // ============================================================================
230
235
  // Theme Class
@@ -247,7 +252,11 @@ export class Theme {
247
252
  this.fgColors.set(key, fgAnsi(value, mode));
248
253
  }
249
254
  this.bgColors = new Map();
250
- for (const [key, value] of Object.entries(bgColors)) {
255
+ const backgrounds = {
256
+ ...bgColors,
257
+ scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg,
258
+ };
259
+ for (const [key, value] of Object.entries(backgrounds)) {
251
260
  this.bgColors.set(key, bgAnsi(value, mode));
252
261
  }
253
262
  }
@@ -463,6 +472,7 @@ function createTheme(themeJson, mode, sourcePath) {
463
472
  const bgColors = {};
464
473
  const bgColorKeys = new Set([
465
474
  "selectedBg",
475
+ "scrollbarThumb",
466
476
  "userMessageBg",
467
477
  "customMessageBg",
468
478
  "toolPendingBg",
@@ -588,15 +598,23 @@ export async function detectTerminalBackgroundTheme({ ui, timeoutMs, env, }) {
588
598
  return detectTerminalBackgroundFromEnv({ env });
589
599
  }
590
600
  export async function detectTerminalThemeForAuto({ ui, timeoutMs, env, }) {
601
+ let colorSchemePromise;
602
+ try {
603
+ colorSchemePromise = ui.queryTerminalColorScheme?.({ timeoutMs });
604
+ }
605
+ catch {
606
+ // Fall back to OSC 11 / COLORFGBG detection when starting the color-scheme query fails.
607
+ }
608
+ const backgroundThemePromise = detectTerminalBackgroundTheme({ ui, timeoutMs, env });
591
609
  try {
592
- const colorScheme = await ui.queryTerminalColorScheme?.({ timeoutMs });
610
+ const colorScheme = await colorSchemePromise;
593
611
  if (colorScheme)
594
612
  return colorScheme;
595
613
  }
596
614
  catch {
597
- // Fall back to OSC 11 / COLORFGBG detection when color-scheme DSR is unsupported.
615
+ // Fall back to the concurrently queried OSC 11 / COLORFGBG detection.
598
616
  }
599
- return (await detectTerminalBackgroundTheme({ ui, timeoutMs, env })).theme;
617
+ return (await backgroundThemePromise).theme;
600
618
  }
601
619
  export function getDefaultTheme() {
602
620
  return detectTerminalBackgroundFromEnv().theme;