@oh-my-pi/pi-coding-agent 15.11.7 → 15.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/cli.js +8106 -7708
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +7 -0
  5. package/dist/types/collab/guest.d.ts +23 -0
  6. package/dist/types/collab/host.d.ts +29 -0
  7. package/dist/types/collab/protocol.d.ts +113 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +60 -5
  11. package/dist/types/export/custom-share.d.ts +1 -2
  12. package/dist/types/export/html/index.d.ts +39 -1
  13. package/dist/types/export/share.d.ts +43 -0
  14. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  15. package/dist/types/main.d.ts +2 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +32 -1
  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/segment-track.d.ts +11 -6
  20. package/dist/types/modes/components/status-line/component.d.ts +10 -2
  21. package/dist/types/modes/components/status-line/types.d.ts +11 -0
  22. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  23. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  24. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  25. package/dist/types/modes/interactive-mode.d.ts +16 -0
  26. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  27. package/dist/types/modes/theme/theme.d.ts +2 -1
  28. package/dist/types/modes/types.d.ts +20 -0
  29. package/dist/types/session/agent-session.d.ts +13 -0
  30. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  31. package/dist/types/session/session-manager.d.ts +21 -0
  32. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  33. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  34. package/dist/types/task/executor.d.ts +7 -0
  35. package/dist/types/task/types.d.ts +9 -0
  36. package/package.json +14 -13
  37. package/scripts/bench-guard.ts +71 -0
  38. package/scripts/build-binary.ts +4 -0
  39. package/scripts/bundle-dist.ts +4 -0
  40. package/scripts/generate-share-viewer.ts +34 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli-commands.ts +1 -0
  43. package/src/collab/crypto.ts +63 -0
  44. package/src/collab/guest.ts +450 -0
  45. package/src/collab/host.ts +556 -0
  46. package/src/collab/protocol.ts +232 -0
  47. package/src/collab/relay-client.ts +216 -0
  48. package/src/commands/join.ts +39 -0
  49. package/src/config/model-registry.ts +22 -14
  50. package/src/config/settings-schema.ts +67 -5
  51. package/src/config/settings.ts +12 -0
  52. package/src/export/custom-share.ts +1 -1
  53. package/src/export/html/index.ts +122 -17
  54. package/src/export/html/share-loader.js +102 -0
  55. package/src/export/html/template.css +745 -459
  56. package/src/export/html/template.html +6 -3
  57. package/src/export/html/template.js +240 -915
  58. package/src/export/html/tool-views.generated.js +38 -0
  59. package/src/export/share.ts +268 -0
  60. package/src/extensibility/slash-commands.ts +1 -97
  61. package/src/internal-urls/docs-index.generated.ts +74 -73
  62. package/src/main.ts +33 -11
  63. package/src/modes/components/agent-hub.ts +659 -431
  64. package/src/modes/components/assistant-message.ts +126 -6
  65. package/src/modes/components/collab-prompt-message.ts +30 -0
  66. package/src/modes/components/hook-selector.ts +4 -5
  67. package/src/modes/components/segment-track.ts +44 -7
  68. package/src/modes/components/status-line/component.ts +59 -6
  69. package/src/modes/components/status-line/presets.ts +1 -1
  70. package/src/modes/components/status-line/segments.ts +18 -1
  71. package/src/modes/components/status-line/types.ts +12 -0
  72. package/src/modes/components/tips.txt +4 -1
  73. package/src/modes/controllers/command-controller.ts +55 -96
  74. package/src/modes/controllers/event-controller.ts +45 -16
  75. package/src/modes/controllers/input-controller.ts +175 -9
  76. package/src/modes/controllers/selector-controller.ts +13 -15
  77. package/src/modes/controllers/session-focus-controller.ts +112 -0
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +56 -6
  80. package/src/modes/session-observer-registry.ts +11 -0
  81. package/src/modes/theme/theme.ts +6 -0
  82. package/src/modes/types.ts +20 -0
  83. package/src/modes/utils/ui-helpers.ts +23 -13
  84. package/src/prompts/tools/job.md +1 -1
  85. package/src/sdk.ts +239 -36
  86. package/src/session/agent-session.ts +82 -7
  87. package/src/session/codex-auto-reset.ts +23 -11
  88. package/src/session/session-manager.ts +44 -0
  89. package/src/session/snapcompact-inline.ts +9 -3
  90. package/src/slash-commands/builtin-registry.ts +261 -24
  91. package/src/task/executor.ts +14 -0
  92. package/src/task/index.ts +5 -1
  93. package/src/task/render.ts +76 -5
  94. package/src/task/types.ts +9 -0
  95. package/src/tiny/worker.ts +17 -95
  96. package/src/tools/job.ts +6 -9
  97. package/src/tools/read.ts +38 -5
  98. package/src/tools/write.ts +13 -42
  99. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  100. package/dist/types/export/html/template.generated.d.ts +0 -1
  101. package/dist/types/export/html/template.macro.d.ts +0 -5
  102. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  103. package/scripts/generate-template.ts +0 -33
  104. package/src/bun-imports.d.ts +0 -28
  105. package/src/export/html/template.generated.ts +0 -2
  106. package/src/export/html/template.macro.ts +0 -25
  107. package/src/tiny/compiled-runtime.ts +0 -179
@@ -11,8 +11,9 @@ import {
11
11
  } from "@oh-my-pi/pi-ai";
12
12
  import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui";
13
13
  import { formatDuration, Snowflake } from "@oh-my-pi/pi-utils";
14
- import { $ } from "bun";
15
14
  import { shouldEnableAppendOnlyContext } from "../../config/append-only-context-mode";
15
+ import { type LoadedCustomShare, loadCustomShare } from "../../export/custom-share";
16
+ import { shareSession } from "../../export/share";
16
17
  import type { CompactOptions } from "../../extensibility/extensions/types";
17
18
  import {
18
19
  diffMentalModelContent,
@@ -117,126 +118,84 @@ export class CommandController {
117
118
  }
118
119
 
119
120
  async handleShareCommand(): Promise<void> {
120
- const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
121
- const cleanupTempFile = async () => {
122
- try {
123
- await fs.rm(tmpFile, { force: true });
124
- } catch {
125
- // Ignore cleanup errors
126
- }
127
- };
121
+ let customShare: LoadedCustomShare | null;
128
122
  try {
129
- await this.ctx.session.exportToHtml(tmpFile);
130
- } catch (error: unknown) {
131
- this.ctx.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`);
132
- return;
133
- }
134
-
135
- try {
136
- const { loadCustomShare } = await import("../../export/custom-share");
137
- const customShare = await loadCustomShare();
138
- if (customShare) {
139
- const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing...");
140
- this.ctx.editorContainer.clear();
141
- this.ctx.editorContainer.addChild(loader);
142
- this.ctx.ui.setFocus(loader);
143
- this.ctx.ui.requestRender();
144
-
145
- const restoreEditor = async () => {
146
- loader.dispose();
147
- this.ctx.editorContainer.clear();
148
- this.ctx.editorContainer.addChild(this.ctx.editor);
149
- this.ctx.ui.setFocus(this.ctx.editor);
150
- await cleanupTempFile();
151
- };
152
-
153
- try {
154
- const result = await customShare.fn(tmpFile);
155
- await restoreEditor();
156
-
157
- if (typeof result === "string") {
158
- this.ctx.showStatus(`Share URL: ${result}`);
159
- this.openInBrowser(result);
160
- } else if (result) {
161
- const parts: string[] = [];
162
- if (result.url) parts.push(`Share URL: ${result.url}`);
163
- if (result.message) parts.push(result.message);
164
- if (parts.length > 0) this.ctx.showStatus(parts.join("\n"));
165
- if (result.url) this.openInBrowser(result.url);
166
- } else {
167
- this.ctx.showStatus("Session shared");
168
- }
169
- return;
170
- } catch (err) {
171
- await restoreEditor();
172
- this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`);
173
- return;
174
- }
175
- }
123
+ customShare = await loadCustomShare();
176
124
  } catch (err) {
177
- await cleanupTempFile();
178
125
  this.ctx.showError(err instanceof Error ? err.message : String(err));
179
126
  return;
180
127
  }
181
128
 
182
- try {
183
- const authResult = await $`gh auth status`.quiet().nothrow();
184
- if (authResult.exitCode !== 0) {
185
- await cleanupTempFile();
186
- this.ctx.showError("GitHub CLI is not logged in. Run 'gh auth login' first.");
187
- return;
188
- }
189
- } catch {
190
- await cleanupTempFile();
191
- this.ctx.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/");
192
- return;
193
- }
194
-
195
- const loader = new BorderedLoader(this.ctx.ui, theme, "Creating gist...");
129
+ const loader = new BorderedLoader(this.ctx.ui, theme, "Sharing session...");
196
130
  this.ctx.editorContainer.clear();
197
131
  this.ctx.editorContainer.addChild(loader);
198
132
  this.ctx.ui.setFocus(loader);
199
133
  this.ctx.ui.requestRender();
200
134
 
201
- const restoreEditor = async () => {
135
+ const restoreEditor = () => {
202
136
  loader.dispose();
203
137
  this.ctx.editorContainer.clear();
204
138
  this.ctx.editorContainer.addChild(this.ctx.editor);
205
139
  this.ctx.ui.setFocus(this.ctx.editor);
206
- await cleanupTempFile();
207
140
  };
208
-
209
141
  loader.onAbort = () => {
210
- void restoreEditor();
142
+ restoreEditor();
211
143
  this.ctx.showStatus("Share cancelled");
212
144
  };
213
145
 
214
- try {
215
- const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow();
216
- if (loader.signal.aborted) return;
217
-
218
- await restoreEditor();
219
-
220
- if (result.exitCode !== 0) {
221
- const errorMsg = result.stderr.toString("utf-8").trim() || "Unknown error";
222
- this.ctx.showError(`Failed to create gist: ${errorMsg}`);
223
- return;
146
+ // Custom share scripts keep their legacy contract: they receive a path
147
+ // to a standalone HTML export. No fallback to the default flow on error.
148
+ if (customShare) {
149
+ const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
150
+ try {
151
+ await this.ctx.session.exportToHtml(tmpFile);
152
+ const result = await customShare.fn(tmpFile);
153
+ if (loader.signal.aborted) return;
154
+ restoreEditor();
155
+
156
+ if (typeof result === "string") {
157
+ this.ctx.showStatus(`Share URL: ${result}`);
158
+ this.openInBrowser(result);
159
+ } else if (result) {
160
+ const parts: string[] = [];
161
+ if (result.url) parts.push(`Share URL: ${result.url}`);
162
+ if (result.message) parts.push(result.message);
163
+ if (parts.length > 0) this.ctx.showStatus(parts.join("\n"));
164
+ if (result.url) this.openInBrowser(result.url);
165
+ } else {
166
+ this.ctx.showStatus("Session shared");
167
+ }
168
+ } catch (err) {
169
+ if (!loader.signal.aborted) {
170
+ restoreEditor();
171
+ this.ctx.showError(`Custom share failed: ${err instanceof Error ? err.message : String(err)}`);
172
+ }
173
+ } finally {
174
+ await fs.rm(tmpFile, { force: true }).catch(() => {});
224
175
  }
176
+ return;
177
+ }
225
178
 
226
- const gistUrl = result.stdout.toString("utf-8").trim();
227
- const gistId = gistUrl.split("/").pop();
228
- if (!gistId) {
229
- this.ctx.showError("Failed to parse gist ID from gh output");
230
- return;
231
- }
179
+ // Default: encrypted snapshot to a secret gist (preferred) or the share
180
+ // server; the key rides in the link fragment and never leaves the client.
181
+ try {
182
+ const result = await shareSession(this.ctx.session.sessionManager, {
183
+ serverUrl: this.ctx.settings.get("share.serverUrl"),
184
+ state: this.ctx.session.state,
185
+ obfuscator: this.ctx.settings.get("share.redactSecrets") ? this.ctx.session.obfuscator : undefined,
186
+ });
187
+ if (loader.signal.aborted) return;
188
+ restoreEditor();
232
189
 
233
- const previewUrl = `https://gistpreview.github.io/?${gistId}`;
234
- this.ctx.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
235
- this.openInBrowser(previewUrl);
190
+ const lines = [`Share URL: ${result.url}`];
191
+ if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`);
192
+ if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit.");
193
+ this.ctx.showStatus(lines.join("\n"));
194
+ this.openInBrowser(result.url);
236
195
  } catch (error: unknown) {
237
196
  if (!loader.signal.aborted) {
238
- await restoreEditor();
239
- this.ctx.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`);
197
+ restoreEditor();
198
+ this.ctx.showError(`Failed to share session: ${error instanceof Error ? error.message : "Unknown error"}`);
240
199
  }
241
200
  }
242
201
  }
@@ -221,6 +221,35 @@ export class EventController {
221
221
  await this.handleEvent(event);
222
222
  });
223
223
  }
224
+ /**
225
+ * Clear every transcript-anchored/turn-scoped piece of state. Used by the
226
+ * session focus proxy when re-pointing the transcript at another session:
227
+ * components, timers, and stream-reveal state all reference the previous
228
+ * session's transcript and must not bleed into the new one.
229
+ */
230
+ resetTranscriptAnchors(): void {
231
+ this.#resetReadGroup();
232
+ this.#lastVisibleBlockCount = 0;
233
+ this.#renderedCustomMessages.clear();
234
+ this.#lastIntent = undefined;
235
+ this.#backgroundToolCallIds.clear();
236
+ this.#agentTurnActive = false;
237
+ this.#interrupting = false;
238
+ this.#readToolCallArgs.clear();
239
+ this.#readToolCallAssistantComponents.clear();
240
+ this.#lastAssistantComponent = undefined;
241
+ this.#pinnedErrorComponent = undefined;
242
+ this.#cancelIdleCompaction();
243
+ for (const timer of this.#ircExpiryTimers.values()) {
244
+ clearTimeout(timer);
245
+ }
246
+ this.#ircExpiryTimers.clear();
247
+ this.#liveIrcCards.clear();
248
+ this.#displaceablePollComponent = undefined;
249
+ this.#lastTtsrNotification = undefined;
250
+ this.#streamingReveal.stop();
251
+ this.#toolArgsReveal.stop();
252
+ }
224
253
 
225
254
  async handleEvent(event: AgentSessionEvent): Promise<void> {
226
255
  if (!this.ctx.isInitialized) {
@@ -335,7 +364,7 @@ export class EventController {
335
364
  undefined,
336
365
  this.ctx.hideThinkingBlock,
337
366
  () => this.ctx.ui.requestRender(),
338
- this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
367
+ this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
339
368
  this.ctx.ui.imageBudget,
340
369
  );
341
370
  this.ctx.streamingMessage = event.message;
@@ -519,12 +548,12 @@ export class EventController {
519
548
  if (!this.ctx.pendingTools.has(content.id)) {
520
549
  this.#resolveDisplaceablePoll(content.name);
521
550
  this.#resetReadGroup();
522
- const tool = this.ctx.session.getToolByName(content.name);
551
+ const tool = this.ctx.viewSession.getToolByName(content.name);
523
552
  const component = new ToolExecutionComponent(
524
553
  content.name,
525
554
  renderArgs,
526
555
  {
527
- snapshots: getFileSnapshotStore(this.ctx.session),
556
+ snapshots: getFileSnapshotStore(this.ctx.viewSession),
528
557
  showImages: settings.get("terminal.showImages"),
529
558
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
530
559
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -556,7 +585,7 @@ export class EventController {
556
585
  this.#updateWorkingMessageFromIntent(args[INTENT_FIELD]);
557
586
  continue;
558
587
  }
559
- const tool = this.ctx.session.getToolByName(content.name);
588
+ const tool = this.ctx.viewSession.getToolByName(content.name);
560
589
  if (typeof tool?.intent !== "function") continue;
561
590
  try {
562
591
  const derived = tool.intent(args as never)?.trim();
@@ -581,7 +610,7 @@ export class EventController {
581
610
  let errorMessage: string | undefined;
582
611
  const aborted = this.ctx.streamingMessage.stopReason === "aborted";
583
612
  const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage);
584
- const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending;
613
+ const ttsrSilenced = aborted && this.ctx.viewSession.isTtsrAbortPending;
585
614
  if (aborted && !silentlyAborted && !ttsrSilenced) {
586
615
  // Resolve the operator-facing label: a user-interrupt (Esc) abort
587
616
  // carries USER_INTERRUPT_LABEL on errorMessage (threaded through the
@@ -590,7 +619,7 @@ export class EventController {
590
619
  // AgentSession.#handleAgentEvent already stamped SILENT_ABORT_MARKER for
591
620
  // the plan-compact transition before this controller ran, so reaching
592
621
  // this branch implies the abort was NOT a silent internal transition.
593
- errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.session.retryAttempt);
622
+ errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.viewSession.retryAttempt);
594
623
  this.ctx.streamingMessage.errorMessage = errorMessage;
595
624
  }
596
625
  if (silentlyAborted || ttsrSilenced) {
@@ -664,12 +693,12 @@ export class EventController {
664
693
  }
665
694
 
666
695
  this.#resetReadGroup();
667
- const tool = this.ctx.session.getToolByName(event.toolName);
696
+ const tool = this.ctx.viewSession.getToolByName(event.toolName);
668
697
  const component = new ToolExecutionComponent(
669
698
  event.toolName,
670
699
  event.args,
671
700
  {
672
- snapshots: getFileSnapshotStore(this.ctx.session),
701
+ snapshots: getFileSnapshotStore(this.ctx.viewSession),
673
702
  showImages: settings.get("terminal.showImages"),
674
703
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
675
704
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -844,7 +873,7 @@ export class EventController {
844
873
  this.#cancelIdleCompaction();
845
874
  this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
846
875
  this.ctx.editor.onEscape = () => {
847
- this.ctx.session.abortCompaction();
876
+ this.ctx.viewSession.abortCompaction();
848
877
  };
849
878
  this.ctx.statusContainer.clear();
850
879
  const reasonText =
@@ -937,7 +966,7 @@ export class EventController {
937
966
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
938
967
  this.ctx.retryEscapeHandler = this.ctx.editor.onEscape;
939
968
  this.ctx.editor.onEscape = () => {
940
- this.ctx.session.abortRetry();
969
+ this.ctx.viewSession.abortRetry();
941
970
  };
942
971
  this.ctx.statusContainer.clear();
943
972
  const delaySeconds = Math.round(event.delayMs / 1000);
@@ -1023,7 +1052,7 @@ export class EventController {
1023
1052
  this.#cancelIdleCompaction();
1024
1053
  // Don't schedule idle work while context maintenance is already running; the
1025
1054
  // maintenance flow may reset the session before this timer fires.
1026
- if (this.ctx.session.isCompacting) return;
1055
+ if (this.ctx.viewSession.isCompacting) return;
1027
1056
 
1028
1057
  const idleSettings = settings.getGroup("compaction");
1029
1058
  if (!idleSettings.idleEnabled) return;
@@ -1040,17 +1069,17 @@ export class EventController {
1040
1069
  this.#idleCompactionTimer = undefined;
1041
1070
  // Re-check conditions before firing. Pruning may have run between arming
1042
1071
  // the timer and now, dropping usage back below the idle threshold.
1043
- if (this.ctx.session.isStreaming) return;
1044
- if (this.ctx.session.isCompacting) return;
1072
+ if (this.ctx.viewSession.isStreaming) return;
1073
+ if (this.ctx.viewSession.isCompacting) return;
1045
1074
  if (this.ctx.editor.getText().trim()) return;
1046
1075
  if (this.#currentContextTokens() < threshold) return;
1047
- void this.ctx.session.runIdleCompaction();
1076
+ void this.ctx.viewSession.runIdleCompaction();
1048
1077
  }, timeoutMs);
1049
1078
  this.#idleCompactionTimer.unref?.();
1050
1079
  }
1051
1080
 
1052
1081
  #currentContextTokens(): number {
1053
- const lastAssistant = this.ctx.session.agent.state.messages
1082
+ const lastAssistant = this.ctx.viewSession.agent.state.messages
1054
1083
  .slice()
1055
1084
  .reverse()
1056
1085
  .find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
@@ -1065,7 +1094,7 @@ export class EventController {
1065
1094
  // errored — those are not "Task complete" events. Mirrors the gate
1066
1095
  // already used by #currentContextTokens, #handleMessageEnd, and the
1067
1096
  // retry / TTSR / compaction skip paths across agent-session.ts.
1068
- const last = this.ctx.session.getLastAssistantMessage?.();
1097
+ const last = this.ctx.viewSession.getLastAssistantMessage?.();
1069
1098
  if (last?.stopReason === "aborted" || last?.stopReason === "error") return;
1070
1099
 
1071
1100
  const sessionName = this.ctx.sessionManager.getSessionName();
@@ -1,8 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
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";
3
+ import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
4
+ import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
6
5
  import { isSettingsInitialized, settings } from "../../config/settings";
7
6
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
8
7
  import { renderSegmentTrack } from "../../modes/components/segment-track";
@@ -18,6 +17,7 @@ import { isTinyTitleLocalModelKey } from "../../tiny/models";
18
17
  import { isLowSignalTitleInput } from "../../tiny/text";
19
18
  import { tinyTitleClient } from "../../tiny/title-client";
20
19
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
20
+ import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
21
21
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
22
22
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
23
23
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
@@ -60,6 +60,7 @@ export class InputController {
60
60
  ) {}
61
61
 
62
62
  #enhancedPaste?: EnhancedPasteController;
63
+ #focusedLeftTapListenerInstalled = false;
63
64
 
64
65
  #showTinyTitleDownloadProgress(modelKey: string): void {
65
66
  if (!isTinyTitleLocalModelKey(modelKey)) return;
@@ -108,6 +109,16 @@ export class InputController {
108
109
 
109
110
  setupKeyHandlers(): void {
110
111
  this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
112
+ if (!this.#focusedLeftTapListenerInstalled) {
113
+ this.#focusedLeftTapListenerInstalled = true;
114
+ this.ctx.ui.addInputListener(data => {
115
+ if (!this.ctx.focusedAgentId) return undefined;
116
+ if (!matchesKey(data, "left")) return undefined;
117
+ if (this.ctx.editor.getText().trim()) return undefined;
118
+ this.#handleFocusedLeftTap();
119
+ return { consume: true };
120
+ });
121
+ }
111
122
  this.ctx.editor.onEscape = () => {
112
123
  if (this.ctx.loopModeEnabled) {
113
124
  this.ctx.pauseLoop();
@@ -125,6 +136,28 @@ export class InputController {
125
136
  if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) {
126
137
  return;
127
138
  }
139
+ if (this.ctx.focusedAgentId) {
140
+ // Esc never interrupts the focused agent's turn: clear typed text,
141
+ // else return the view to the main session. Interrupt via empty
142
+ // steer-flush submit if needed.
143
+ if (this.ctx.editor.getText().trim()) {
144
+ this.ctx.editor.setText("");
145
+ this.ctx.ui.requestRender();
146
+ } else {
147
+ void this.ctx.unfocusSession();
148
+ }
149
+ return; // double-escape backtrack (/tree, /branch) stays main-only
150
+ }
151
+ if (this.ctx.collabGuest) {
152
+ // Guest Esc: ask the host to interrupt its agent; the local replica
153
+ // session is never streaming, so the native abort path below would
154
+ // no-op.
155
+ if (this.ctx.collabGuest.state?.isStreaming || this.ctx.loadingAnimation) {
156
+ if (!this.ctx.collabGuest.readOnly) this.ctx.notifyInterrupting();
157
+ this.ctx.collabGuest.sendAbort();
158
+ }
159
+ return;
160
+ }
128
161
  if (this.ctx.loadingAnimation) {
129
162
  if (this.ctx.cancelPendingSubmission()) {
130
163
  return;
@@ -251,9 +284,14 @@ export class InputController {
251
284
  this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showAgentHub());
252
285
  }
253
286
 
254
- // Double-tap left arrow on an empty editor opens the agent hub same
255
- // 500ms window as the double-escape state machine above.
287
+ // Double-tap left arrow on an empty editor: opens the agent hub from the
288
+ // main session, or returns the focused subagent view to the main session.
289
+ // Focused ←← intentionally matches Esc.
256
290
  this.ctx.editor.onLeftAtStart = () => {
291
+ if (this.ctx.focusedAgentId) {
292
+ this.#handleFocusedLeftTap();
293
+ return;
294
+ }
257
295
  const now = Date.now();
258
296
  if (now - this.ctx.lastLeftTapTime < 500) {
259
297
  this.ctx.lastLeftTapTime = 0;
@@ -277,6 +315,16 @@ export class InputController {
277
315
  };
278
316
  }
279
317
 
318
+ #handleFocusedLeftTap(): void {
319
+ const now = Date.now();
320
+ if (now - this.ctx.lastLeftTapTime < 500) {
321
+ this.ctx.lastLeftTapTime = 0;
322
+ void this.ctx.unfocusSession();
323
+ } else {
324
+ this.ctx.lastLeftTapTime = now;
325
+ }
326
+ }
327
+
280
328
  #setupEnhancedPaste(): void {
281
329
  if (this.#enhancedPaste) return;
282
330
 
@@ -314,6 +362,14 @@ export class InputController {
314
362
  text = text.trim();
315
363
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
316
364
 
365
+ // Focused subagent session: the editor is a plain chat box for it.
366
+ // Everything below (continue shortcuts, slash/bash/python, loop,
367
+ // compaction queueing) is main-session-only.
368
+ if (this.ctx.focusedAgentId) {
369
+ await this.#submitToFocusedSession(text, "steer");
370
+ return;
371
+ }
372
+
317
373
  // Empty submit while streaming with queued steering: interrupt now and
318
374
  // immediately resume so the visible `Steer:` entry is sent without
319
375
  // waiting for the current tool/model boundary.
@@ -392,6 +448,37 @@ export class InputController {
392
448
  text = slashResult;
393
449
  }
394
450
 
451
+ // Collab guest: prompts execute on the host; local slash/skill/bash/
452
+ // python execution is host-only (builtins are gated inside
453
+ // executeBuiltinSlashCommand, which already consumed allowed ones).
454
+ if (this.ctx.collabGuest) {
455
+ if (text.startsWith("/")) {
456
+ this.ctx.showStatus(`${text.split(/\s+/, 1)[0]} is host-only during a collab session`);
457
+ this.ctx.editor.setText("");
458
+ return;
459
+ }
460
+ if (text.startsWith("!") || text.startsWith("$")) {
461
+ this.ctx.showStatus("Local execution is host-only during a collab session");
462
+ this.ctx.editor.setText("");
463
+ return;
464
+ }
465
+ if (this.ctx.collabGuest.readOnly) {
466
+ // Keep the typed text: the prompt was not consumed.
467
+ this.ctx.showStatus("This collab link is read-only — prompting is disabled");
468
+ return;
469
+ }
470
+ this.ctx.editor.addToHistory(text);
471
+ this.ctx.editor.setText("");
472
+ this.ctx.editor.imageLinks = undefined;
473
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
474
+ this.ctx.pendingImages = [];
475
+ this.ctx.pendingImageLinks = [];
476
+ // No local render: the prompt comes back from the host as a
477
+ // collab-prompt event/entry and renders with the author badge.
478
+ this.ctx.collabGuest.sendPrompt(text, images);
479
+ return;
480
+ }
481
+
395
482
  // Handle skill commands (/skill:name [args]). Enter ⇒ steer (matches the
396
483
  // free-text Enter semantics applied a few lines below at the streaming
397
484
  // branch). Ctrl+Enter routes through `handleFollowUp` and dispatches the
@@ -566,6 +653,41 @@ export class InputController {
566
653
  };
567
654
  }
568
655
 
656
+ /** Submit editor text to the focused subagent session (chat-only focus policy). */
657
+ async #submitToFocusedSession(text: string, streamingBehavior: "steer" | "followUp"): Promise<void> {
658
+ const target = this.ctx.viewSession;
659
+ if (!text) {
660
+ // Mirror the empty-submit steer flush against the focused session.
661
+ if (target.isStreaming && target.getQueuedMessages().steering.length > 0) {
662
+ await target.interruptAndFlushQueuedMessages({ reason: USER_INTERRUPT_LABEL });
663
+ this.ctx.updatePendingMessagesDisplay();
664
+ this.ctx.ui.requestRender();
665
+ }
666
+ return;
667
+ }
668
+ if (text.startsWith("/") || text.startsWith("!") || text.startsWith("$")) {
669
+ this.ctx.showStatus("Commands run in the main session — press ←← to return first");
670
+ return; // editor text not cleared: Editor does not auto-clear on submit
671
+ }
672
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
673
+ this.ctx.editor.addToHistory(text);
674
+ this.ctx.editor.setText("");
675
+ this.ctx.editor.imageLinks = undefined;
676
+ this.ctx.pendingImages = [];
677
+ this.ctx.pendingImageLinks = [];
678
+ try {
679
+ // prompt() handles idle (new turn) and streaming (queues per streamingBehavior).
680
+ await this.ctx.withLocalSubmission(text, () => target.prompt(text, { streamingBehavior, images }), {
681
+ imageCount: images?.length ?? 0,
682
+ });
683
+ } catch (error) {
684
+ this.ctx.editor.setText(text); // hand the message back, mirroring the main submit error path
685
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
686
+ }
687
+ this.ctx.updatePendingMessagesDisplay();
688
+ this.ctx.ui.requestRender();
689
+ }
690
+
569
691
  handleCtrlC(): void {
570
692
  const now = Date.now();
571
693
  if (now - this.ctx.lastSigintTime < 500) {
@@ -704,6 +826,12 @@ export class InputController {
704
826
  let text = this.ctx.editor.getText().trim();
705
827
  if (!text) return;
706
828
 
829
+ // Focused subagent session: follow-ups go to it; non-chat input is gated.
830
+ if (this.ctx.focusedAgentId) {
831
+ await this.#submitToFocusedSession(text, "followUp");
832
+ return;
833
+ }
834
+
707
835
  // Compaction first: while compacting, free text gets queued via
708
836
  // `queueCompactionMessage`, and `/skill:*` rides the same queue so a
709
837
  // skill typed during compaction is not lost or short-circuited through
@@ -874,11 +1002,41 @@ export class InputController {
874
1002
  `Unsupported pasted image format: ${image.mimeType}`,
875
1003
  );
876
1004
  } catch (error) {
1005
+ if (error instanceof ImageInputTooLargeError) {
1006
+ this.ctx.editor.pasteText(path);
1007
+ this.ctx.ui.requestRender();
1008
+ this.ctx.showStatus(error.message);
1009
+ return;
1010
+ }
1011
+ if (isEnoent(error)) {
1012
+ // #2375: the bracketed paste forwarded by a local terminal carries a
1013
+ // path on the *local* filesystem. When omp itself runs over SSH, that
1014
+ // path is unreachable here; pasting it as text would look like the
1015
+ // image was attached when in fact nothing was sent. Refuse the silent
1016
+ // degrade and tell the user how to send the bytes for real. The
1017
+ // pasted path is untrusted terminal input — strip control/ANSI/
1018
+ // newlines, collapse home to `~`, and bound the displayed length
1019
+ // before splicing it into the status string.
1020
+ const displayPath = truncateToWidth(
1021
+ shortenPath(
1022
+ sanitizeText(path)
1023
+ .replace(/[\r\n\t]+/g, " ")
1024
+ .trim(),
1025
+ ),
1026
+ TRUNCATE_LENGTHS.CONTENT,
1027
+ );
1028
+ const env = process.env;
1029
+ const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
1030
+ this.ctx.showStatus(
1031
+ overSsh
1032
+ ? `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.`
1033
+ : `Image not found at ${displayPath}`,
1034
+ );
1035
+ return;
1036
+ }
877
1037
  this.ctx.editor.pasteText(path);
878
1038
  this.ctx.ui.requestRender();
879
- this.ctx.showStatus(
880
- error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
881
- );
1039
+ this.ctx.showStatus("Failed to read pasted image path");
882
1040
  }
883
1041
  }
884
1042
 
@@ -983,6 +1141,10 @@ export class InputController {
983
1141
  }
984
1142
 
985
1143
  cycleThinkingLevel(): void {
1144
+ if (this.ctx.focusedAgentId) {
1145
+ this.ctx.showStatus("Model/thinking apply to the main session — press ←← to return first");
1146
+ return;
1147
+ }
986
1148
  const newLevel = this.ctx.session.cycleThinkingLevel();
987
1149
  if (newLevel === undefined) {
988
1150
  this.ctx.showStatus("Current model does not support thinking");
@@ -993,6 +1155,10 @@ export class InputController {
993
1155
  }
994
1156
 
995
1157
  async cycleRoleModel(direction: "forward" | "backward" = "forward"): Promise<void> {
1158
+ if (this.ctx.focusedAgentId) {
1159
+ this.ctx.showStatus("Model/thinking apply to the main session — press ←← to return first");
1160
+ return;
1161
+ }
996
1162
  try {
997
1163
  const cycleOrder = settings.get("cycleOrder");
998
1164
  const result = await this.ctx.session.cycleRoleModels(cycleOrder, direction);
@@ -1007,7 +1173,7 @@ export class InputController {
1007
1173
  // the cycle status is just a status-line-style chip track (active role
1008
1174
  // filled), matching the plan-approval model slider.
1009
1175
  const track = renderSegmentTrack(
1010
- cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
1176
+ cycleOrder.map(role => ({ label: role })),
1011
1177
  cycleOrder.indexOf(result.role),
1012
1178
  );
1013
1179
  this.ctx.showStatus(track, { dim: false });