@oh-my-pi/pi-coding-agent 15.11.6 → 15.11.8

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 (102) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/cli.js +431 -381
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/cli/bench-cli.d.ts +78 -0
  5. package/dist/types/collab/crypto.d.ts +12 -0
  6. package/dist/types/collab/guest.d.ts +21 -0
  7. package/dist/types/collab/host.d.ts +13 -0
  8. package/dist/types/collab/protocol.d.ts +100 -0
  9. package/dist/types/collab/relay-client.d.ts +22 -0
  10. package/dist/types/commands/bench.d.ts +29 -0
  11. package/dist/types/commands/join.d.ts +12 -0
  12. package/dist/types/config/model-resolver.d.ts +3 -2
  13. package/dist/types/config/settings-schema.d.ts +93 -1
  14. package/dist/types/edit/renderer.d.ts +1 -0
  15. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  16. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/oauth-selector.d.ts +10 -1
  20. package/dist/types/modes/components/segment-track.d.ts +11 -6
  21. package/dist/types/modes/components/settings-selector.d.ts +8 -1
  22. package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
  23. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  24. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  25. package/dist/types/modes/components/tool-execution.d.ts +13 -9
  26. package/dist/types/modes/interactive-mode.d.ts +7 -0
  27. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
  28. package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
  29. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
  30. package/dist/types/modes/types.d.ts +8 -0
  31. package/dist/types/session/agent-session.d.ts +11 -0
  32. package/dist/types/session/session-manager.d.ts +21 -0
  33. package/dist/types/session/snapcompact-inline.d.ts +8 -3
  34. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  35. package/dist/types/tools/bash.d.ts +2 -0
  36. package/dist/types/tools/eval-render.d.ts +1 -0
  37. package/dist/types/tools/renderers.d.ts +13 -0
  38. package/dist/types/tools/ssh.d.ts +1 -0
  39. package/package.json +14 -12
  40. package/scripts/bench-guard.ts +71 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli/bench-cli.ts +437 -0
  43. package/src/cli-commands.ts +2 -0
  44. package/src/collab/crypto.ts +57 -0
  45. package/src/collab/guest.ts +421 -0
  46. package/src/collab/host.ts +494 -0
  47. package/src/collab/protocol.ts +191 -0
  48. package/src/collab/relay-client.ts +216 -0
  49. package/src/commands/bench.ts +42 -0
  50. package/src/commands/join.ts +39 -0
  51. package/src/config/model-registry.ts +74 -19
  52. package/src/config/model-resolver.ts +36 -5
  53. package/src/config/settings-schema.ts +119 -1
  54. package/src/edit/renderer.ts +5 -0
  55. package/src/extensibility/slash-commands.ts +1 -97
  56. package/src/hindsight/client.ts +26 -1
  57. package/src/hindsight/state.ts +6 -2
  58. package/src/internal-urls/docs-index.generated.ts +4 -3
  59. package/src/main.ts +11 -2
  60. package/src/mcp/transports/stdio.ts +81 -7
  61. package/src/modes/components/agent-hub.ts +119 -22
  62. package/src/modes/components/assistant-message.ts +126 -6
  63. package/src/modes/components/collab-prompt-message.ts +30 -0
  64. package/src/modes/components/hook-selector.ts +4 -5
  65. package/src/modes/components/oauth-selector.ts +67 -7
  66. package/src/modes/components/segment-track.ts +44 -7
  67. package/src/modes/components/settings-selector.ts +27 -0
  68. package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
  69. package/src/modes/components/snapcompact-shape-preview.ts +192 -0
  70. package/src/modes/components/status-line/component.ts +21 -1
  71. package/src/modes/components/status-line/presets.ts +1 -1
  72. package/src/modes/components/status-line/segments.ts +13 -0
  73. package/src/modes/components/status-line/types.ts +10 -0
  74. package/src/modes/components/tips.txt +2 -1
  75. package/src/modes/components/tool-execution.ts +18 -10
  76. package/src/modes/controllers/input-controller.ts +80 -12
  77. package/src/modes/controllers/selector-controller.ts +6 -2
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +36 -4
  80. package/src/modes/setup-wizard/index.ts +1 -0
  81. package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
  82. package/src/modes/setup-wizard/scenes/providers.ts +36 -2
  83. package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
  84. package/src/modes/setup-wizard/scenes/theme.ts +28 -1
  85. package/src/modes/setup-wizard/scenes/types.ts +10 -1
  86. package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
  87. package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
  88. package/src/modes/types.ts +8 -0
  89. package/src/modes/utils/context-usage.ts +1 -1
  90. package/src/modes/utils/ui-helpers.ts +7 -0
  91. package/src/prompts/bench.md +7 -0
  92. package/src/sdk.ts +240 -36
  93. package/src/session/agent-session.ts +22 -0
  94. package/src/session/session-manager.ts +44 -0
  95. package/src/session/snapcompact-inline.ts +20 -22
  96. package/src/slash-commands/builtin-registry.ts +210 -0
  97. package/src/tools/bash.ts +3 -0
  98. package/src/tools/eval-render.ts +4 -0
  99. package/src/tools/read.ts +38 -5
  100. package/src/tools/renderers.ts +13 -0
  101. package/src/tools/ssh.ts +3 -0
  102. package/src/tools/write.ts +13 -42
@@ -1,9 +1,9 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
3
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
- import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
5
- import { getRoleInfo } from "../../config/model-roles";
4
+ import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
6
5
  import { isSettingsInitialized, settings } from "../../config/settings";
6
+ import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
7
  import { renderSegmentTrack } from "../../modes/components/segment-track";
8
8
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
9
9
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
@@ -17,6 +17,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
17
17
  import { isLowSignalTitleInput } from "../../tiny/text";
18
18
  import { tinyTitleClient } from "../../tiny/title-client";
19
19
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
20
+ import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
20
21
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
21
22
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
22
23
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
@@ -124,6 +125,16 @@ export class InputController {
124
125
  if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
125
126
  return;
126
127
  }
128
+ if (this.ctx.collabGuest) {
129
+ // Guest Esc: ask the host to interrupt its agent; the local replica
130
+ // session is never streaming, so the native abort path below would
131
+ // no-op.
132
+ if (this.ctx.collabGuest.state?.isStreaming || this.ctx.loadingAnimation) {
133
+ this.ctx.notifyInterrupting();
134
+ this.ctx.collabGuest.sendAbort();
135
+ }
136
+ return;
137
+ }
127
138
  if (this.ctx.loadingAnimation) {
128
139
  if (this.ctx.cancelPendingSubmission()) {
129
140
  return;
@@ -391,6 +402,32 @@ export class InputController {
391
402
  text = slashResult;
392
403
  }
393
404
 
405
+ // Collab guest: prompts execute on the host; local slash/skill/bash/
406
+ // python execution is host-only (builtins are gated inside
407
+ // executeBuiltinSlashCommand, which already consumed allowed ones).
408
+ if (this.ctx.collabGuest) {
409
+ if (text.startsWith("/")) {
410
+ this.ctx.showStatus(`${text.split(/\s+/, 1)[0]} is host-only during a collab session`);
411
+ this.ctx.editor.setText("");
412
+ return;
413
+ }
414
+ if (text.startsWith("!") || text.startsWith("$")) {
415
+ this.ctx.showStatus("Local execution is host-only during a collab session");
416
+ this.ctx.editor.setText("");
417
+ return;
418
+ }
419
+ this.ctx.editor.addToHistory(text);
420
+ this.ctx.editor.setText("");
421
+ this.ctx.editor.imageLinks = undefined;
422
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
423
+ this.ctx.pendingImages = [];
424
+ this.ctx.pendingImageLinks = [];
425
+ // No local render: the prompt comes back from the host as a
426
+ // collab-prompt event/entry and renders with the author badge.
427
+ this.ctx.collabGuest.sendPrompt(text, images);
428
+ return;
429
+ }
430
+
394
431
  // Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
395
432
  // free-text Enter semantics applied a few lines below at the streaming
396
433
  // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
@@ -873,11 +910,41 @@ export class InputController {
873
910
  `Unsupported pasted image format: ${image.mimeType}`,
874
911
  );
875
912
  } catch (error) {
913
+ if (error instanceof ImageInputTooLargeError) {
914
+ this.ctx.editor.pasteText(path);
915
+ this.ctx.ui.requestRender();
916
+ this.ctx.showStatus(error.message);
917
+ return;
918
+ }
919
+ if (isEnoent(error)) {
920
+ // #2375: the bracketed paste forwarded by a local terminal carries a
921
+ // path on the *local* filesystem. When omp itself runs over SSH, that
922
+ // path is unreachable here; pasting it as text would look like the
923
+ // image was attached when in fact nothing was sent. Refuse the silent
924
+ // degrade and tell the user how to send the bytes for real. The
925
+ // pasted path is untrusted terminal input — strip control/ANSI/
926
+ // newlines, collapse home to `~`, and bound the displayed length
927
+ // before splicing it into the status string.
928
+ const displayPath = truncateToWidth(
929
+ shortenPath(
930
+ sanitizeText(path)
931
+ .replace(/[\r\n\t]+/g, " ")
932
+ .trim(),
933
+ ),
934
+ TRUNCATE_LENGTHS.CONTENT,
935
+ );
936
+ const env = process.env;
937
+ const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
938
+ this.ctx.showStatus(
939
+ overSsh
940
+ ? `Image not found at ${displayPath}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.`
941
+ : `Image not found at ${displayPath}`,
942
+ );
943
+ return;
944
+ }
876
945
  this.ctx.editor.pasteText(path);
877
946
  this.ctx.ui.requestRender();
878
- this.ctx.showStatus(
879
- error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
880
- );
947
+ this.ctx.showStatus("Failed to read pasted image path");
881
948
  }
882
949
  }
883
950
 
@@ -1006,7 +1073,7 @@ export class InputController {
1006
1073
  // the cycle status is just a status-line-style chip track (active role
1007
1074
  // filled), matching the plan-approval model slider.
1008
1075
  const track = renderSegmentTrack(
1009
- cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
1076
+ cycleOrder.map(role => ({ label: role })),
1010
1077
  cycleOrder.indexOf(result.role),
1011
1078
  );
1012
1079
  this.ctx.showStatus(track, { dim: false });
@@ -1039,18 +1106,19 @@ export class InputController {
1039
1106
 
1040
1107
  toggleThinkingBlockVisibility(): void {
1041
1108
  this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
1042
- settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
1109
+ this.ctx.settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
1043
1110
  this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
1044
1111
 
1045
- // Rebuild chat from session messages
1046
- this.ctx.chatContainer.clear();
1047
- this.ctx.rebuildChatFromMessages();
1112
+ for (const child of this.ctx.chatContainer.children) {
1113
+ if (child instanceof AssistantMessageComponent) {
1114
+ child.setHideThinkingBlock(this.ctx.hideThinkingBlock);
1115
+ child.invalidate();
1116
+ }
1117
+ }
1048
1118
 
1049
- // If streaming, re-add the streaming component with updated visibility and re-render
1050
1119
  if (this.ctx.streamingComponent && this.ctx.streamingMessage) {
1051
1120
  this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock);
1052
1121
  this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
1053
- this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
1054
1122
  }
1055
1123
 
1056
1124
  this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);
@@ -114,6 +114,9 @@ export class SelectorController {
114
114
  thinkingLevel: this.ctx.session.thinkingLevel,
115
115
  availableThemes,
116
116
  cwd: getProjectDir(),
117
+ model: this.ctx.session.model,
118
+ imageBudget: this.ctx.ui.imageBudget,
119
+ requestRender: () => this.ctx.ui.requestRender(),
117
120
  },
118
121
  {
119
122
  onChange: (id, value) => this.handleSettingChange(id, value),
@@ -313,10 +316,9 @@ export class SelectorController {
313
316
  for (const child of this.ctx.chatContainer.children) {
314
317
  if (child instanceof AssistantMessageComponent) {
315
318
  child.setHideThinkingBlock(value as boolean);
319
+ child.invalidate();
316
320
  }
317
321
  }
318
- this.ctx.chatContainer.clear();
319
- this.ctx.rebuildChatFromMessages();
320
322
  break;
321
323
  case "theme": {
322
324
  setTheme(value as string, true).then(result => {
@@ -1186,6 +1188,8 @@ export class SelectorController {
1186
1188
  hubKeys,
1187
1189
  onDone: done,
1188
1190
  requestRender: () => this.ctx.ui.requestRender(),
1191
+ registry: this.ctx.collabGuest?.agentRegistry,
1192
+ remote: this.ctx.collabGuest?.hubRemote,
1189
1193
  });
1190
1194
 
1191
1195
  overlayHandle = this.ctx.ui.showOverlay(hub, {
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { getSegmenter } from "@oh-my-pi/pi-tui";
3
+ import { LRUCache } from "lru-cache/raw";
3
4
  import type { AssistantMessageComponent } from "../components/assistant-message";
4
5
 
5
6
  export const STREAMING_REVEAL_FRAME_MS = 1000 / 30;
@@ -15,11 +16,17 @@ type StreamingRevealControllerOptions = {
15
16
  requestRender(): void;
16
17
  };
17
18
 
19
+ const graphemeCountCache = new LRUCache<string, number>({ max: 128 });
20
+
18
21
  function countGraphemes(text: string): number {
22
+ if (text.length === 0) return 0;
23
+ const cached = graphemeCountCache.get(text);
24
+ if (cached !== undefined) return cached;
19
25
  let count = 0;
20
26
  for (const _segment of getSegmenter().segment(text)) {
21
27
  count += 1;
22
28
  }
29
+ graphemeCountCache.set(text, count);
23
30
  return count;
24
31
  }
25
32
 
@@ -49,8 +49,9 @@ import {
49
49
  } from "@oh-my-pi/pi-utils";
50
50
  import chalk from "chalk";
51
51
  import { reset as resetCapabilities } from "../capability";
52
+ import type { CollabGuestLink } from "../collab/guest";
53
+ import type { CollabHost } from "../collab/host";
52
54
  import { KeybindingsManager } from "../config/keybindings";
53
- import { MODEL_ROLES, type ModelRole } from "../config/model-roles";
54
55
  import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
55
56
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
56
57
  import type {
@@ -62,7 +63,7 @@ import type {
62
63
  ExtensionWidgetOptions,
63
64
  } from "../extensibility/extensions";
64
65
  import type { CompactOptions } from "../extensibility/extensions/types";
65
- import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
66
+ import { loadSlashCommands } from "../extensibility/slash-commands";
66
67
  import type { Goal, GoalModeState } from "../goals/state";
67
68
  import { resolveLocalUrlToPath } from "../internal-urls";
68
69
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
@@ -82,7 +83,7 @@ import { HistoryStorage } from "../session/history-storage";
82
83
  import type { SessionContext, SessionManager } from "../session/session-manager";
83
84
  import { getRecentSessions } from "../session/session-manager";
84
85
  import type { ShakeMode } from "../session/shake-types";
85
- import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES } from "../slash-commands/builtin-registry";
86
+ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, BUILTIN_SLASH_COMMANDS } from "../slash-commands/builtin-registry";
86
87
  import { formatDuration } from "../slash-commands/helpers/format";
87
88
  import { STTController, type SttState } from "../stt";
88
89
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
@@ -397,6 +398,8 @@ export class InteractiveMode implements InteractiveModeContext {
397
398
  fileSlashCommands: Set<string> = new Set();
398
399
  skillCommands: Map<string, string> = new Map();
399
400
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
401
+ collabHost?: CollabHost;
402
+ collabGuest?: CollabGuestLink;
400
403
 
401
404
  #pendingSlashCommands: SlashCommand[] = [];
402
405
  #cleanupUnsubscribe?: () => void;
@@ -423,6 +426,12 @@ export class InteractiveMode implements InteractiveModeContext {
423
426
  readonly #commandController: CommandController;
424
427
  readonly #todoCommandController: TodoCommandController;
425
428
  readonly #eventController: EventController;
429
+ get eventController(): EventController {
430
+ return this.#eventController;
431
+ }
432
+ get eventBus(): EventBus | undefined {
433
+ return this.#eventBus;
434
+ }
426
435
  readonly #extensionUiController: ExtensionUiController;
427
436
  readonly #inputController: InputController;
428
437
  readonly #selectorController: SelectorController;
@@ -1162,6 +1171,30 @@ export class InteractiveMode implements InteractiveModeContext {
1162
1171
  // of restarting the visible conversation (the LLM context still resets).
1163
1172
  const context = this.session.buildTranscriptSessionContext();
1164
1173
  this.renderSessionContext(context);
1174
+ // During the pre-streaming window — after `startPendingSubmission` has
1175
+ // optimistically rendered the user's message but before the user
1176
+ // `message_start` event lands it in `session` entries — any rebuild
1177
+ // (e.g. Ctrl+T toggleThinkingBlockVisibility, theme selector) would
1178
+ // otherwise erase the user's just-submitted message until the first
1179
+ // assistant token arrived (#2372). Once `message_start` fires the
1180
+ // signature is cleared by `EventController`, so this replay is a no-op
1181
+ // post-streaming and cannot duplicate.
1182
+ this.#replayOptimisticUserMessage();
1183
+ }
1184
+
1185
+ #replayOptimisticUserMessage(): void {
1186
+ if (!this.optimisticUserMessageSignature) return;
1187
+ const submission = this.#pendingSubmittedInput;
1188
+ if (!submission || submission.cancelled || submission.customType) return;
1189
+ this.addMessageToChat(
1190
+ {
1191
+ role: "user",
1192
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
1193
+ attribution: "user",
1194
+ timestamp: Date.now(),
1195
+ },
1196
+ { imageLinks: submission.imageLinks },
1197
+ );
1165
1198
  }
1166
1199
 
1167
1200
  #formatTodoLine(todo: TodoItem, prefix: string, matched: boolean): string {
@@ -2467,7 +2500,6 @@ export class InteractiveMode implements InteractiveModeContext {
2467
2500
  index: startTierIndex,
2468
2501
  segments: cycle.models.map(entry => ({
2469
2502
  label: entry.role,
2470
- color: MODEL_ROLES[entry.role as ModelRole]?.color,
2471
2503
  detail: entry.model.name || entry.model.id,
2472
2504
  })),
2473
2505
  onChange: index => {
@@ -82,6 +82,7 @@ export async function runSetupWizard(
82
82
  maxHeight: "100%",
83
83
  anchor: "top-left",
84
84
  margin: 0,
85
+ fullscreen: true,
85
86
  });
86
87
  try {
87
88
  await component.run();
@@ -1,4 +1,4 @@
1
- import { type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
1
+ import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
2
2
  import { getSelectListTheme, type SymbolPreset, setSymbolPreset, theme } from "../../theme/theme";
3
3
  import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
4
4
 
@@ -29,6 +29,8 @@ class GlyphSceneController implements SetupSceneController {
29
29
  #selectList: SelectList;
30
30
  #previewRequest = 0;
31
31
  #committing = false;
32
+ /** Render line where the select list begins. */
33
+ #listRowStart = 0;
32
34
 
33
35
  constructor(private readonly host: SetupSceneHost) {
34
36
  this.#selectList = new SelectList(GLYPH_ITEMS, GLYPH_ITEMS.length, getSelectListTheme());
@@ -60,12 +62,28 @@ class GlyphSceneController implements SetupSceneController {
60
62
  this.#selectList.handleInput(data);
61
63
  }
62
64
 
65
+ /** Wheel moves the highlight (live preview); hover lights the row under the pointer; click confirms it. */
66
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
67
+ if (this.#committing) return;
68
+ if (event.wheel !== null) {
69
+ this.#selectList.handleWheel(event.wheel);
70
+ return;
71
+ }
72
+ const index = this.#selectList.hitTest(line - this.#listRowStart);
73
+ if (event.motion) {
74
+ this.#selectList.setHoverIndex(index ?? null);
75
+ return;
76
+ }
77
+ if (event.leftClick && index !== undefined) {
78
+ this.#selectList.clickItem(index);
79
+ }
80
+ }
81
+
63
82
  render(width: number): readonly string[] {
64
- return [
65
- theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."),
66
- "",
67
- ...this.#selectList.render(width),
68
- ];
83
+ const lines = [theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."), ""];
84
+ this.#listRowStart = lines.length;
85
+ lines.push(...this.#selectList.render(width));
86
+ return lines;
69
87
  }
70
88
 
71
89
  async #commit(preset: SymbolPreset): Promise<void> {
@@ -1,4 +1,4 @@
1
- import { TabBar } from "@oh-my-pi/pi-tui";
1
+ import { type SgrMouseEvent, TabBar } from "@oh-my-pi/pi-tui";
2
2
  import { getTabBarTheme } from "../../shared";
3
3
  import { SignInTab } from "./sign-in";
4
4
  import type { SetupScene, SetupSceneController, SetupSceneHost, SetupTab } from "./types";
@@ -16,6 +16,8 @@ class ProvidersSceneController implements SetupSceneController {
16
16
 
17
17
  #tabs: SetupTab[];
18
18
  #tabBar: TabBar;
19
+ /** Lines the tab bar occupied in the last render (body starts one blank line below). */
20
+ #tabRowCount = 1;
19
21
 
20
22
  constructor(host: SetupSceneHost) {
21
23
  this.#tabs = [new SignInTab(host), new WebSearchTab(host)];
@@ -52,8 +54,40 @@ class ProvidersSceneController implements SetupSceneController {
52
54
  tab.handleInput(data);
53
55
  }
54
56
 
57
+ /**
58
+ * Hit-test mouse reports against the last render: rows inside the tab bar
59
+ * hover/switch tabs (suppressed while the active panel is modal, matching
60
+ * keyboard tab cycling); everything else forwards to the active panel at
61
+ * panel-local coordinates. Wheel always goes to the panel so scrolling
62
+ * works regardless of pointer position.
63
+ */
64
+ routeMouse(event: SgrMouseEvent, line: number, col: number): void {
65
+ const tab = this.#activeTab();
66
+ if (event.wheel === null && line >= 0 && line < this.#tabRowCount) {
67
+ if (tab.modal) return;
68
+ const hit = this.#tabBar.tabAt(line, col);
69
+ if (event.motion) {
70
+ this.#tabBar.setHoverTab(hit && !hit.muted ? hit.id : null);
71
+ } else if (event.leftClick && hit) {
72
+ this.#tabBar.selectTab(hit.id);
73
+ }
74
+ return;
75
+ }
76
+ if (event.motion) this.#tabBar.setHoverTab(null);
77
+ const bodyLine = line - this.#tabRowCount - 1;
78
+ if (tab.routeMouse) {
79
+ tab.routeMouse(event, bodyLine, col);
80
+ return;
81
+ }
82
+ if (event.wheel !== null && !tab.modal) {
83
+ tab.handleInput(event.wheel === -1 ? "\x1b[A" : "\x1b[B");
84
+ }
85
+ }
86
+
55
87
  render(width: number): readonly string[] {
56
- return [...this.#tabBar.render(width), "", ...this.#activeTab().render(width)];
88
+ const tabLines = this.#tabBar.render(width);
89
+ this.#tabRowCount = tabLines.length;
90
+ return [...tabLines, "", ...this.#activeTab().render(width)];
57
91
  }
58
92
 
59
93
  dispose(): void {
@@ -1,7 +1,7 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
2
  import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
3
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
4
- import { Input, matchesKey, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
4
+ import { Input, matchesKey, type SgrMouseEvent, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
5
5
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
6
6
  import { OAuthSelectorComponent } from "../../components/oauth-selector";
7
7
  import { theme } from "../../theme/theme";
@@ -35,6 +35,8 @@ export class SignInTab implements SetupTab {
35
35
  #loginAbort: AbortController | undefined;
36
36
  #loggingInProvider: string | undefined;
37
37
  #disposed = false;
38
+ /** Render line where the provider selector begins. */
39
+ #selectorRowStart = 2;
38
40
 
39
41
  constructor(private readonly host: SetupSceneHost) {
40
42
  this.#authStorage = host.ctx.session.modelRegistry.authStorage;
@@ -68,12 +70,19 @@ export class SignInTab implements SetupTab {
68
70
  this.#selector.handleInput(data);
69
71
  }
70
72
 
73
+ /** Forward mouse to the provider selector; pointer is inert during an active login or code prompt. */
74
+ routeMouse(event: SgrMouseEvent, line: number, col: number): void {
75
+ if (this.#loggingInProvider || this.#prompt) return;
76
+ this.#selector.routeMouse(event, line - this.#selectorRowStart, col);
77
+ }
78
+
71
79
  render(width: number): readonly string[] {
72
80
  const lines: string[] = [];
73
81
  if (this.#loggingInProvider) {
74
82
  lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`));
75
83
  } else {
76
84
  lines.push(theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), "");
85
+ this.#selectorRowStart = lines.length;
77
86
  lines.push(...this.#selector.render(width));
78
87
  }
79
88
 
@@ -1,4 +1,11 @@
1
- import { padding, type SelectItem, SelectList, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ padding,
3
+ type SelectItem,
4
+ SelectList,
5
+ type SgrMouseEvent,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ } from "@oh-my-pi/pi-tui";
2
9
  import {
3
10
  enableAutoTheme,
4
11
  getAvailableThemes,
@@ -89,6 +96,8 @@ class ThemeSceneController implements SetupSceneController {
89
96
  #message: string | undefined;
90
97
  #previewRequest = 0;
91
98
  #disposed = false;
99
+ /** Render line where the select list began, or -1 while it is not shown. */
100
+ #listRowStart = -1;
92
101
  readonly #originalTheme = getCurrentThemeName();
93
102
  readonly #originalSymbolPreset: SymbolPreset;
94
103
  readonly #originalColorBlindMode: boolean;
@@ -117,6 +126,22 @@ class ThemeSceneController implements SetupSceneController {
117
126
  this.#selectList.handleInput(data);
118
127
  }
119
128
 
129
+ /** Wheel moves the highlight (live preview); hover lights the row under the pointer; click confirms it. */
130
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
131
+ if (event.wheel !== null) {
132
+ this.#selectList.handleWheel(event.wheel);
133
+ return;
134
+ }
135
+ const index = this.#listRowStart >= 0 ? this.#selectList.hitTest(line - this.#listRowStart) : undefined;
136
+ if (event.motion) {
137
+ this.#selectList.setHoverIndex(index ?? null);
138
+ return;
139
+ }
140
+ if (event.leftClick && index !== undefined) {
141
+ this.#selectList.clickItem(index);
142
+ }
143
+ }
144
+
120
145
  render(width: number): readonly string[] {
121
146
  const lines = [
122
147
  theme.fg("muted", "Theme changes preview live. Nothing is saved until you press Enter."),
@@ -128,8 +153,10 @@ class ThemeSceneController implements SetupSceneController {
128
153
  "",
129
154
  ];
130
155
  if (this.#loadingAllThemes) {
156
+ this.#listRowStart = -1;
131
157
  lines.push(theme.fg("dim", "Loading themes…"));
132
158
  } else {
159
+ this.#listRowStart = lines.length;
133
160
  lines.push(...this.#selectList.render(width));
134
161
  }
135
162
  if (this.#message) {
@@ -1,4 +1,4 @@
1
- import type { Component } from "@oh-my-pi/pi-tui";
1
+ import type { Component, SgrMouseEvent } from "@oh-my-pi/pi-tui";
2
2
  import type { InteractiveModeContext } from "../../types";
3
3
 
4
4
  export type SetupSceneResult = "done" | "skipped";
@@ -17,6 +17,13 @@ export interface SetupSceneController extends Component {
17
17
  onMount?(): void | Promise<void>;
18
18
  onUnmount?(): void;
19
19
  dispose?(): void;
20
+ /**
21
+ * Route an SGR mouse report (tracking is on while the wizard holds the
22
+ * alternate screen). `line`/`col` are 0-based within this controller's
23
+ * last rendered output. When absent, the wizard falls back to synthesizing
24
+ * arrow keys from wheel notches.
25
+ */
26
+ routeMouse?(event: SgrMouseEvent, line: number, col: number): void;
20
27
  }
21
28
 
22
29
  /**
@@ -36,6 +43,8 @@ export interface SetupTab {
36
43
  invalidate(): void;
37
44
  /** Called when the tab becomes active (including initial mount). */
38
45
  onActivate?(): void;
46
+ /** Mouse routing at tab-local coordinates; see {@link SetupSceneController.routeMouse}. */
47
+ routeMouse?(event: SgrMouseEvent, line: number, col: number): void;
39
48
  dispose(): void;
40
49
  }
41
50
 
@@ -1,4 +1,4 @@
1
- import { type SelectItem, SelectList, truncateToWidth } from "@oh-my-pi/pi-tui";
1
+ import { type SelectItem, SelectList, type SgrMouseEvent, truncateToWidth } from "@oh-my-pi/pi-tui";
2
2
  import { SETTINGS_SCHEMA } from "../../../config/settings-schema";
3
3
  import { getSearchProvider, setPreferredSearchProvider } from "../../../web/search/provider";
4
4
  import { isSearchProviderPreference, type SearchProviderId } from "../../../web/search/types";
@@ -31,6 +31,8 @@ export class WebSearchTab implements SetupTab {
31
31
  #availability = new Map<SearchProviderId, Availability>();
32
32
  #status: string[] = [];
33
33
  #disposed = false;
34
+ /** Render line where the select list begins. */
35
+ #listRowStart = 0;
34
36
 
35
37
  constructor(private readonly host: SetupSceneHost) {
36
38
  this.#list = new SelectList(WEB_SEARCH_ITEMS, MAX_VISIBLE, getSelectListTheme());
@@ -55,6 +57,22 @@ export class WebSearchTab implements SetupTab {
55
57
  this.#list.handleInput(data);
56
58
  }
57
59
 
60
+ /** Wheel moves the highlight; hover lights the row under the pointer; click confirms it. */
61
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
62
+ if (event.wheel !== null) {
63
+ this.#list.handleWheel(event.wheel);
64
+ return;
65
+ }
66
+ const index = this.#list.hitTest(line - this.#listRowStart);
67
+ if (event.motion) {
68
+ this.#list.setHoverIndex(index ?? null);
69
+ return;
70
+ }
71
+ if (event.leftClick && index !== undefined) {
72
+ this.#list.clickItem(index);
73
+ }
74
+ }
75
+
58
76
  invalidate(): void {
59
77
  this.#list.invalidate();
60
78
  }
@@ -64,11 +82,9 @@ export class WebSearchTab implements SetupTab {
64
82
  }
65
83
 
66
84
  render(width: number): readonly string[] {
67
- const lines = [
68
- theme.fg("muted", "Choose the provider the web_search tool should prefer."),
69
- "",
70
- ...this.#list.render(width),
71
- ];
85
+ const lines = [theme.fg("muted", "Choose the provider the web_search tool should prefer."), ""];
86
+ this.#listRowStart = lines.length;
87
+ lines.push(...this.#list.render(width));
72
88
  const selected = this.#list.getSelectedItem();
73
89
  if (selected) {
74
90
  lines.push("", ...this.#readinessLines(selected.value).map(line => truncateToWidth(line, width)));
@@ -1,4 +1,4 @@
1
- import { type Component, matchesKey, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import { type Component, matchesKey, padding, parseSgrMouse, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
2
  import { APP_NAME } from "@oh-my-pi/pi-utils";
3
3
  import { gradientLogo, PI_LOGO } from "../components/welcome";
4
4
  import { theme } from "../theme/theme";
@@ -61,6 +61,8 @@ export class SetupWizardComponent implements Component {
61
61
  #timer: NodeJS.Timeout | undefined;
62
62
  #done = Promise.withResolvers<void>();
63
63
  #disposed = false;
64
+ /** Screen row where the active scene's body began in the last rendered frame. */
65
+ #bodyRowStart = 0;
64
66
 
65
67
  constructor(
66
68
  readonly ctx: InteractiveModeContext,
@@ -87,6 +89,10 @@ export class SetupWizardComponent implements Component {
87
89
 
88
90
  handleInput(data: string): void {
89
91
  if (this.#phase === "done") return;
92
+ if (data.startsWith("\x1b[<")) {
93
+ this.#handleMouse(data);
94
+ return;
95
+ }
90
96
  if (matchesKey(data, "ctrl+c")) {
91
97
  this.#beginOutro();
92
98
  return;
@@ -116,6 +122,36 @@ export class SetupWizardComponent implements Component {
116
122
  this.#activeScene?.handleInput?.(data);
117
123
  }
118
124
 
125
+ /**
126
+ * Mouse handling for the fullscreen wizard (SGR tracking is on while the
127
+ * overlay holds the alternate screen). The frame paints from screen row 0,
128
+ * so report coordinates index directly into the last rendered lines: scene
129
+ * body rows start at #bodyRowStart, indented by SCENE_MARGIN_X. Scenes
130
+ * that implement routeMouse get hit-tested events (wheel, hover, click);
131
+ * for the rest a wheel notch falls back to an arrow key. A left click
132
+ * advances the splash/outro like Enter. Raw reports never reach scene
133
+ * keyboard input.
134
+ */
135
+ #handleMouse(data: string): void {
136
+ const event = parseSgrMouse(data);
137
+ if (!event) return;
138
+ if (this.#phase === "splash" || this.#phase === "outro") {
139
+ if (!event.leftClick) return;
140
+ if (this.#phase === "splash") this.#beginScene();
141
+ else this.#complete();
142
+ return;
143
+ }
144
+ const scene = this.#activeScene;
145
+ if (!scene) return;
146
+ if (scene.routeMouse) {
147
+ scene.routeMouse(event, event.row - this.#bodyRowStart, event.col - SCENE_MARGIN_X);
148
+ return;
149
+ }
150
+ if (event.wheel !== null) {
151
+ scene.handleInput?.(event.wheel === -1 ? "\x1b[A" : "\x1b[B");
152
+ }
153
+ }
154
+
119
155
  render(width: number): readonly string[] {
120
156
  const safeWidth = Math.max(1, width);
121
157
  const height = Math.max(1, this.ctx.ui.terminal.rows);
@@ -163,6 +199,7 @@ export class SetupWizardComponent implements Component {
163
199
  header.push(indentLine(theme.fg("muted", subtitle), width, SCENE_MARGIN_X));
164
200
  }
165
201
  header.push("");
202
+ this.#bodyRowStart = header.length;
166
203
 
167
204
  const footer = [
168
205
  "",