pum-agent 0.1.3-beta.2 → 0.2.0-beta.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.
package/README.md CHANGED
@@ -74,7 +74,7 @@ bun run login
74
74
  bun run start
75
75
  ```
76
76
 
77
- PUM opens the login panel automatically when no provider is available. Use `/login` later to add or update a provider.
77
+ PUM opens the login panel automatically when no provider is available. Use `/login` later to add or update a provider. During browser-based login, PUM opens credential-free HTTP(S) authentication URLs with the platform browser. The URL remains selectable when automatic launch is unavailable.
78
78
 
79
79
  Resume the latest session for the current directory:
80
80
 
@@ -131,6 +131,28 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
131
131
 
132
132
  Useful commands include `/login`, `/history`, `/triggers`, `/clear`, `/compress`, and `/worktree`.
133
133
 
134
+ ### Copy transcript text
135
+
136
+ Drag across transcript text with the left mouse button. PUM copies the completed selection when you release the button.
137
+
138
+ - On local Windows, PUM first uses the native clipboard module. PUM then tries `clip.exe`.
139
+ - On local macOS, PUM first uses the native clipboard module. PUM then tries `pbcopy`.
140
+ - On local Linux, PUM tries `wl-copy`, `xclip`, or `xsel` when the matching display is available.
141
+ - Over SSH or Mosh, PUM sends OSC 52 through OpenTUI. OpenTUI wraps OSC 52 for detected `tmux` sessions.
142
+
143
+ Windows Terminal accepts OSC 52 from remote sessions. The terminal can still ask for clipboard-write approval.
144
+
145
+ For `tmux`, enable clipboard integration and passthrough when the server configuration blocks OSC 52:
146
+
147
+ ```tmux
148
+ set -g set-clipboard on
149
+ set -g allow-passthrough on
150
+ ```
151
+
152
+ Reload the `tmux` configuration after this change. Use the terminal's Shift-drag selection as a manual fallback.
153
+
154
+ PUM limits remote OSC 52 payloads to 100,000 Base64 characters. This limit prevents large selections from corrupting terminal output.
155
+
134
156
  ## Parallel subagents
135
157
 
136
158
  PUM runs up to 10 active subagents by default. Configure a limit from 1 through 25 in Settings. Only starting and running agents count toward the limit. Each subagent has these resources:
@@ -264,7 +286,7 @@ Use a throwaway `PUM_DIR` for local integration tests. Capture TUI output throug
264
286
 
265
287
  ## Release status
266
288
 
267
- PUM `0.1` is in beta. Interfaces and persisted formats can still change before the stable release. Review the release notes before upgrading sessions or custom configuration.
289
+ PUM `0.2` is in beta. Interfaces and persisted formats can still change before the stable release. Review the release notes before upgrading sessions or custom configuration.
268
290
 
269
291
  ## License
270
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.1.3-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,68 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export type BrowserLaunchCommand = {
4
+ executable: string;
5
+ args: string[];
6
+ };
7
+
8
+ export type BrowserProcessSpawner = (
9
+ executable: string,
10
+ args: readonly string[],
11
+ ) => Promise<void>;
12
+
13
+ export function credentialFreeHttpUrl(value: string): string | null {
14
+ try {
15
+ const url = new URL(value);
16
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
17
+ if (url.username || url.password) return null;
18
+ return url.href;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function browserLaunchCommand(
25
+ platform: NodeJS.Platform,
26
+ url: string,
27
+ ): BrowserLaunchCommand | null {
28
+ if (platform === "win32") {
29
+ return {
30
+ executable: "rundll32.exe",
31
+ args: ["url.dll,FileProtocolHandler", url],
32
+ };
33
+ }
34
+ if (platform === "darwin") return { executable: "open", args: [url] };
35
+ if (platform === "linux") return { executable: "xdg-open", args: [url] };
36
+ return null;
37
+ }
38
+
39
+ const spawnBrowserProcess: BrowserProcessSpawner = (executable, args) =>
40
+ new Promise<void>((resolve, reject) => {
41
+ const child = spawn(executable, [...args], {
42
+ detached: true,
43
+ stdio: "ignore",
44
+ windowsHide: true,
45
+ });
46
+ child.once("spawn", resolve);
47
+ child.once("error", reject);
48
+ child.unref();
49
+ });
50
+
51
+ export async function launchBrowserUrl(
52
+ value: string,
53
+ options: {
54
+ platform?: NodeJS.Platform;
55
+ spawn?: BrowserProcessSpawner;
56
+ } = {},
57
+ ): Promise<boolean> {
58
+ const url = credentialFreeHttpUrl(value);
59
+ if (!url) return false;
60
+ const command = browserLaunchCommand(options.platform ?? process.platform, url);
61
+ if (!command) return false;
62
+ try {
63
+ await (options.spawn ?? spawnBrowserProcess)(command.executable, command.args);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
@@ -0,0 +1,152 @@
1
+ import { CliRenderEvents, type CliRenderer, type Selection } from "@opentui/core";
2
+ import { spawn } from "node:child_process";
3
+
4
+ const MAX_OSC52_ENCODED_LENGTH = 100_000;
5
+
6
+ type Environment = Record<string, string | undefined>;
7
+ type NativeClipboard = { setText(text: string): Promise<void> };
8
+ type CommandRunner = (command: string, args: string[], input: string) => Promise<void>;
9
+ type Osc52Writer = (text: string) => boolean;
10
+
11
+ export type ClipboardRoute = "native" | "command" | "osc52";
12
+
13
+ export type ClipboardCopyOptions = {
14
+ platform?: NodeJS.Platform;
15
+ env?: Environment;
16
+ nativeClipboard?: NativeClipboard | null;
17
+ runner?: CommandRunner;
18
+ osc52?: Osc52Writer;
19
+ };
20
+
21
+ export type SelectionClipboardBinding = {
22
+ dispose(): void;
23
+ flush(): Promise<void>;
24
+ };
25
+
26
+ function isRemoteSession(env: Environment): boolean {
27
+ return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
28
+ }
29
+
30
+ function osc52PayloadFits(text: string): boolean {
31
+ return Math.ceil(Buffer.byteLength(text, "utf8") / 3) * 4 <= MAX_OSC52_ENCODED_LENGTH;
32
+ }
33
+
34
+ async function loadNativeClipboard(): Promise<NativeClipboard | null> {
35
+ try {
36
+ return await import("@mariozechner/clipboard");
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function runClipboardCommand(command: string, args: string[], input: string): Promise<void> {
43
+ return new Promise((resolve, reject) => {
44
+ const child = spawn(command, args, {
45
+ stdio: ["pipe", "ignore", "ignore"],
46
+ windowsHide: true,
47
+ });
48
+ const timer = setTimeout(() => {
49
+ child.kill();
50
+ reject(new Error(`${command} timed out`));
51
+ }, 5000);
52
+ child.on("error", (error) => {
53
+ clearTimeout(timer);
54
+ reject(error);
55
+ });
56
+ child.on("close", (code) => {
57
+ clearTimeout(timer);
58
+ if (code === 0) resolve();
59
+ else reject(new Error(`${command} failed`));
60
+ });
61
+ child.stdin.on("error", () => {});
62
+ child.stdin.end(input);
63
+ });
64
+ }
65
+
66
+ async function tryCommand(
67
+ runner: CommandRunner,
68
+ command: string,
69
+ args: string[],
70
+ text: string,
71
+ ): Promise<boolean> {
72
+ try {
73
+ await runner(command, args, text);
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /** Copy completed OpenTUI selections without assuming local graphical access. */
81
+ export async function copySelectionText(
82
+ text: string,
83
+ options: ClipboardCopyOptions = {},
84
+ ): Promise<ClipboardRoute> {
85
+ if (!text) throw new Error("Cannot copy an empty selection");
86
+
87
+ const platform = options.platform ?? process.platform;
88
+ const env = options.env ?? process.env;
89
+ const osc52 = options.osc52 ?? (() => false);
90
+
91
+ if (isRemoteSession(env)) {
92
+ if (osc52PayloadFits(text) && osc52(text)) return "osc52";
93
+ throw new Error("The remote terminal did not accept the OSC 52 clipboard copy");
94
+ }
95
+
96
+ if (platform === "win32" || platform === "darwin") {
97
+ const clipboard = options.nativeClipboard === undefined
98
+ ? await loadNativeClipboard()
99
+ : options.nativeClipboard;
100
+ try {
101
+ if (clipboard) {
102
+ await clipboard.setText(text);
103
+ return "native";
104
+ }
105
+ } catch {
106
+ // Use the platform command before the terminal fallback.
107
+ }
108
+ }
109
+
110
+ const runner = options.runner ?? runClipboardCommand;
111
+ if (platform === "win32" && await tryCommand(runner, "clip.exe", [], text)) return "command";
112
+ if (platform === "darwin" && await tryCommand(runner, "pbcopy", [], text)) return "command";
113
+ if (platform === "linux" && env.WAYLAND_DISPLAY
114
+ && await tryCommand(runner, "wl-copy", [], text)) return "command";
115
+ if (platform === "linux" && env.DISPLAY) {
116
+ if (await tryCommand(runner, "xclip", ["-selection", "clipboard"], text)) return "command";
117
+ if (await tryCommand(runner, "xsel", ["--clipboard", "--input"], text)) return "command";
118
+ }
119
+
120
+ if (osc52PayloadFits(text) && osc52(text)) return "osc52";
121
+ throw new Error("No supported clipboard route accepted the selected text");
122
+ }
123
+
124
+ export function installSelectionClipboard(
125
+ renderer: CliRenderer,
126
+ options: Omit<ClipboardCopyOptions, "osc52"> & {
127
+ onError?: (error: unknown) => void;
128
+ } = {},
129
+ ): SelectionClipboardBinding {
130
+ let pending = Promise.resolve();
131
+ const onSelection = (selection: Selection) => {
132
+ const text = selection.getSelectedText();
133
+ if (!text) return;
134
+ pending = pending
135
+ .then(() => copySelectionText(text, {
136
+ ...options,
137
+ osc52: (value) => renderer.copyToClipboardOSC52(value),
138
+ }))
139
+ .then(() => undefined)
140
+ .catch((error) => options.onError?.(error));
141
+ };
142
+
143
+ renderer.on(CliRenderEvents.SELECTION, onSelection);
144
+ return {
145
+ dispose() {
146
+ renderer.off(CliRenderEvents.SELECTION, onSelection);
147
+ },
148
+ flush() {
149
+ return pending;
150
+ },
151
+ };
152
+ }
@@ -35,7 +35,7 @@ function ensureImageDir(): string {
35
35
  type CommandRunner = (command: string, args: string[]) => Promise<Buffer>;
36
36
  type NativeClipboard = {
37
37
  hasImage(): boolean;
38
- getImageBinary(): Promise<Array<number>>;
38
+ getImageBinary(): Promise<ArrayLike<number>>;
39
39
  };
40
40
 
41
41
  export type ClipboardBackend = "windows" | "wayland" | "x11";
@@ -107,10 +107,17 @@ async function readWindowsClipboard(
107
107
  nativeClipboard: NativeClipboard | null | undefined,
108
108
  ): Promise<{ data: Buffer; mimeType: string; ext: string }> {
109
109
  const clipboard = nativeClipboard === undefined ? await loadNativeClipboard() : nativeClipboard;
110
- if (clipboard?.hasImage()) {
111
- const bytes = await clipboard.getImageBinary();
112
- if (bytes.length > 0) {
113
- return { data: Buffer.from(bytes), mimeType: "image/png", ext: "png" };
110
+ if (clipboard) {
111
+ try {
112
+ if (clipboard.hasImage()) {
113
+ const bytes = await clipboard.getImageBinary();
114
+ if (bytes.length > 0) {
115
+ return { data: Buffer.from(bytes), mimeType: "image/png", ext: "png" };
116
+ }
117
+ }
118
+ } catch {
119
+ // Native access can fail while another Windows process owns the clipboard.
120
+ // PowerShell gives Windows a second clipboard path before the paste fails.
114
121
  }
115
122
  }
116
123
 
@@ -172,6 +179,9 @@ export async function captureClipboardImage(options: {
172
179
  else if (backend === "x11") image = await readX11Clipboard(runner);
173
180
  else throw new Error("No supported graphical clipboard is available");
174
181
 
182
+ if (image.data.length > MAX_IMAGE_BYTES) {
183
+ throw new Error("Clipboard image is larger than 25 MB");
184
+ }
175
185
  const path = join(ensureImageDir(), `image-${++fileSequence}.${image.ext}`);
176
186
  writeFileSync(path, image.data);
177
187
  return { path, mimeType: image.mimeType };
@@ -1,5 +1,6 @@
1
1
  import type { AuthEvent, AuthPrompt } from "@earendil-works/pi-ai";
2
2
  import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
3
+ import { credentialFreeHttpUrl, launchBrowserUrl } from "./browser-launch";
3
4
  import type { LoginPage } from "./login-popup";
4
5
  import {
5
6
  customProviderId,
@@ -52,6 +53,8 @@ export class LoginController {
52
53
  private providerQuery = "";
53
54
  private providerSearchFocused = true;
54
55
  private retry?: () => void;
56
+ private latestBrowserAuthEvent?: Extract<AuthEvent, { type: "auth_url" | "device_code" }>;
57
+ private launchedAuthUrls = new Set<string>();
55
58
 
56
59
  constructor(
57
60
  private runtime: ModelRuntime,
@@ -59,6 +62,7 @@ export class LoginController {
59
62
  private show: (page: LoginPage) => void,
60
63
  private complete: (modelId?: string) => void,
61
64
  private closePopup: () => void,
65
+ private launchUrl: (url: string) => Promise<boolean> = launchBrowserUrl,
62
66
  ) {
63
67
  this.page = this.providerPage();
64
68
  }
@@ -86,6 +90,8 @@ export class LoginController {
86
90
 
87
91
  open() {
88
92
  this.cancelOperation();
93
+ this.latestBrowserAuthEvent = undefined;
94
+ this.launchedAuthUrls.clear();
89
95
  this.retry = undefined;
90
96
  this.providerCursor = 0;
91
97
  this.providerQuery = "";
@@ -153,11 +159,26 @@ export class LoginController {
153
159
  }
154
160
  const controller = new AbortController();
155
161
  this.cancelOperation();
162
+ this.latestBrowserAuthEvent = undefined;
163
+ this.launchedAuthUrls.clear();
156
164
  this.controller = controller;
157
165
  this.setPage({ kind: "working", providerName: method.providerName });
158
166
  void this.runtime.login(method.providerId, method.authType, {
159
167
  signal: controller.signal,
160
168
  notify: (event: AuthEvent) => {
169
+ if (event.type === "auth_url" || event.type === "device_code") {
170
+ this.latestBrowserAuthEvent = event;
171
+ }
172
+ const eventUrl = event.type === "auth_url"
173
+ ? event.url
174
+ : event.type === "device_code"
175
+ ? event.verificationUri
176
+ : undefined;
177
+ const safeUrl = eventUrl ? credentialFreeHttpUrl(eventUrl) : null;
178
+ if (safeUrl && !this.launchedAuthUrls.has(safeUrl)) {
179
+ this.launchedAuthUrls.add(safeUrl);
180
+ void this.launchUrl(safeUrl).catch(() => {});
181
+ }
161
182
  if (!this.promptWaiter) this.setPage({ kind: "working", providerName: method.providerName, event });
162
183
  },
163
184
  prompt: (prompt: AuthPrompt) => new Promise<string>((resolve, reject) => {
@@ -173,7 +194,15 @@ export class LoginController {
173
194
  const onAbort = () => rejectPrompt(new Error("Login cancelled"));
174
195
  prompt.signal?.addEventListener("abort", onAbort, { once: true });
175
196
  this.promptWaiter = { prompt, resolve: resolvePrompt, reject: rejectPrompt };
176
- this.setPage({ kind: "prompt", providerName: method.providerName, prompt, cursor: 0, value: "", secretLength: 0 });
197
+ this.setPage({
198
+ kind: "prompt",
199
+ providerName: method.providerName,
200
+ prompt,
201
+ event: this.latestBrowserAuthEvent,
202
+ cursor: 0,
203
+ value: "",
204
+ secretLength: 0,
205
+ });
177
206
  }),
178
207
  }).then(() => {
179
208
  this.promptWaiter = undefined;
@@ -7,7 +7,7 @@ import { PopupFrame } from "./popup-frame";
7
7
 
8
8
  export type LoginPage =
9
9
  | { kind: "providers"; methods: readonly LoginMethod[]; cursor: number; query: string; searchFocused: boolean; customVisible: boolean }
10
- | { kind: "prompt"; providerName: string; prompt: AuthPrompt; cursor: number; value: string; secretLength: number }
10
+ | { kind: "prompt"; providerName: string; prompt: AuthPrompt; event?: AuthEvent; cursor: number; value: string; secretLength: number }
11
11
  | { kind: "working"; providerName: string; event?: AuthEvent }
12
12
  | { kind: "custom-endpoint"; endpoint: string }
13
13
  | { kind: "custom-key"; endpoint: string; secretLength: number }
@@ -151,6 +151,9 @@ export function LoginPopup({ theme, page, terminalWidth, terminalHeight, onProvi
151
151
  <text content={terminalWidth < 48 ? "/ search ↑↓ move enter esc" : "/ search ↑↓ move enter select esc close"} fg={theme.dim} bg={theme.popupBg} wrapMode="none" style={{ height: 1, flexShrink: 0 }} />
152
152
  </> : page.kind === "prompt" ? <>
153
153
  <text content={page.providerName} fg={theme.accent} bg={theme.popupBg} />
154
+ {page.event?.type === "auth_url" || page.event?.type === "device_code"
155
+ ? <EventDetails theme={theme} event={page.event} />
156
+ : null}
154
157
  <text content={page.prompt.message} fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
155
158
  <box style={{ height: 1, flexShrink: 0 }} />
156
159
  {page.prompt.type === "select" ? page.prompt.options.map((option, index) => {
@@ -165,7 +168,7 @@ export function LoginPopup({ theme, page, terminalWidth, terminalHeight, onProvi
165
168
  <text content={page.providerName} fg={theme.accent} bg={theme.popupBg} />
166
169
  <box style={{ height: 1, flexShrink: 0 }} />
167
170
  <EventDetails theme={theme} event={page.event} />
168
- <text content="URLs and codes are selectable for copying. Esc cancels." fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
171
+ <text content="PUM opens safe URLs automatically. URLs and codes remain selectable. Esc cancels." fg={theme.dim} bg={theme.popupBg} style={{ marginTop: 1 }} />
169
172
  </> : page.kind === "custom-endpoint" ? <>
170
173
  <text content="Enter the server endpoint. PUM probes /models and configures OpenAI Chat Completions only after that probe succeeds." fg={theme.fg} bg={theme.popupBg} wrapMode="word" />
171
174
  <box style={{ height: 1, flexShrink: 0 }} />
package/src/main.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { createCliRenderer } from "@opentui/core";
1
+ import { createCliRenderer, destroyTreeSitterClient } from "@opentui/core";
2
2
  import { createRoot } from "@opentui/react";
3
3
  import {
4
4
  createAgentSessionFromServices,
@@ -28,6 +28,7 @@ import { SubagentManager } from "./subagents/manager";
28
28
  import { cleanupPendingImages } from "./image-paste";
29
29
  import { shutdownSignals } from "./platform";
30
30
  import { createShutdown } from "./shutdown";
31
+ import { settleSyntaxHighlighting } from "./syntax";
31
32
  import { applyPatchExtension } from "./apply-patch";
32
33
  import { QuestionnaireManager } from "./questionnaire";
33
34
  import { SpawnPreviewManager } from "./subagents/spawn-preview";
@@ -40,6 +41,7 @@ import {
40
41
  systemTriggerClock,
41
42
  } from "./triggers/process";
42
43
  import type { StartupOptions } from "./cli";
44
+ import { installSelectionClipboard } from "./clipboard";
43
45
 
44
46
  export async function start(options: StartupOptions): Promise<void> {
45
47
  mkdirSync(AGENT_DIR, { recursive: true });
@@ -183,13 +185,23 @@ export async function start(options: StartupOptions): Promise<void> {
183
185
  if (sessionRuntime.modelFallbackMessage) console.error(sessionRuntime.modelFallbackMessage);
184
186
 
185
187
  const renderer = await createCliRenderer({ exitOnCtrlC: false });
188
+ const selectionClipboard = installSelectionClipboard(renderer);
186
189
  const root = createRoot(renderer);
187
190
  const shutdown = createShutdown({
188
- unmount: () => root.unmount(),
189
- cleanup: cleanupPendingImages,
191
+ unmount: async () => {
192
+ await settleSyntaxHighlighting(renderer.root);
193
+ root.unmount();
194
+ },
195
+ cleanup: () => {
196
+ selectionClipboard.dispose();
197
+ cleanupPendingImages();
198
+ },
190
199
  shutdownTriggers: () => triggerManager.shutdown(),
191
200
  dispose: () => sessionRuntime.dispose(),
192
- destroy: () => renderer.destroy(),
201
+ destroy: async () => {
202
+ renderer.destroy();
203
+ await destroyTreeSitterClient();
204
+ },
193
205
  exit: (code) => process.exit(code),
194
206
  });
195
207
  for (const signal of shutdownSignals()) {
package/src/shutdown.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export type ShutdownActions = {
2
- unmount(): void;
2
+ unmount(): void | Promise<void>;
3
3
  cleanup(): void;
4
4
  shutdownTriggers?(): Promise<void>;
5
5
  dispose(): Promise<void>;
6
- destroy(): void;
6
+ destroy(): void | Promise<void>;
7
7
  exit(code: number): void;
8
8
  };
9
9
 
@@ -13,16 +13,22 @@ export function createShutdown(actions: ShutdownActions): (code: number) => Prom
13
13
  if (exiting) return;
14
14
  exiting = true;
15
15
  try {
16
- actions.unmount();
17
- actions.cleanup();
18
16
  try {
19
- await actions.shutdownTriggers?.();
17
+ await actions.unmount();
20
18
  } finally {
21
- await actions.dispose();
19
+ actions.cleanup();
20
+ try {
21
+ await actions.shutdownTriggers?.();
22
+ } finally {
23
+ await actions.dispose();
24
+ }
22
25
  }
23
26
  } finally {
24
- actions.destroy();
25
- actions.exit(code);
27
+ try {
28
+ await actions.destroy();
29
+ } finally {
30
+ actions.exit(code);
31
+ }
26
32
  }
27
33
  };
28
34
  }
package/src/syntax.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { SyntaxStyle } from "@opentui/core";
1
+ import { CodeRenderable, SyntaxStyle, type BaseRenderable } from "@opentui/core";
2
2
  import type { Theme } from "./theme";
3
3
 
4
+ /** Wait for every highlight request that belongs to a renderable tree. */
5
+ export async function settleSyntaxHighlighting(root: BaseRenderable): Promise<void> {
6
+ const pending: Promise<void>[] = [];
7
+ const visit = (renderable: BaseRenderable) => {
8
+ if (renderable instanceof CodeRenderable) pending.push(renderable.highlightingDone);
9
+ for (const child of renderable.getChildren()) visit(child);
10
+ };
11
+ visit(root);
12
+
13
+ const results = await Promise.allSettled(pending);
14
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
15
+ if (failure) throw failure.reason;
16
+ }
17
+
4
18
  /**
5
19
  * OpenTUI's <markdown> and <code> need a SyntaxStyle and ship no default. The
6
20
  * keys are tree-sitter capture names from the bundled highlight queries;
@@ -1,4 +1,5 @@
1
- import { StyledText, fg, type SyntaxStyle } from "@opentui/core";
1
+ import { StyledText, fg, type MarkdownRenderable, type SyntaxStyle } from "@opentui/core";
2
+ import type { MarkdownProps } from "@opentui/react";
2
3
  import {
3
4
  useBlinkingText,
4
5
  useMarkdownCaret,
@@ -32,6 +33,16 @@ export type PendingTranscriptState = {
32
33
  pending: PendingLine[];
33
34
  };
34
35
 
36
+ /** OpenTUI 0.5.1 supports Markdown selection at runtime but omits the React prop. */
37
+ function SelectableMarkdown({ ref, ...props }: MarkdownProps) {
38
+ const setRef = (renderable: MarkdownRenderable | null) => {
39
+ if (renderable) renderable.selectable = true;
40
+ if (typeof ref === "function") ref(renderable);
41
+ else if (ref) ref.current = renderable;
42
+ };
43
+ return <markdown {...props} ref={setRef} />;
44
+ }
45
+
35
46
  /** Resolve a delivered message without splitting the active streamed output. */
36
47
  export function resolvePendingDelivery<T extends PendingTranscriptState>(value: T, id: string): T {
37
48
  const pending = value.pending.find((item) => item.id === id);
@@ -187,7 +198,7 @@ export function TextLine({
187
198
  glyphColor={color}
188
199
  background={isUser ? theme.userBg : undefined}
189
200
  >
190
- <markdown
201
+ <SelectableMarkdown
191
202
  ref={isAssistant && workingCaret ? markdownCaret.ref : undefined}
192
203
  content={isAssistant && workingCaret ? markdownCaret.content : text}
193
204
  streaming={false}
@@ -205,6 +216,7 @@ export function TextLine({
205
216
  ref={workingCaret ? textCaret : undefined}
206
217
  content={workingCaret ? undefined : displayText}
207
218
  fg={color}
219
+ selectable
208
220
  wrapMode="word"
209
221
  style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
210
222
  />
@@ -238,7 +250,7 @@ export function StreamLine({
238
250
  return (
239
251
  <Row glyph={GUTTER} glyphColor={color}>
240
252
  {role === "assistant" ? (
241
- <markdown
253
+ <SelectableMarkdown
242
254
  ref={markdown.ref}
243
255
  content={markdown.content}
244
256
  streaming
@@ -249,6 +261,7 @@ export function StreamLine({
249
261
  ) : (
250
262
  <text
251
263
  ref={shimmer}
264
+ selectable
252
265
  wrapMode="word"
253
266
  style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
254
267
  />
@@ -274,10 +287,11 @@ export function PendingMessageLine({
274
287
  <text
275
288
  content={`${line.sender} → ${line.recipient} · queued`}
276
289
  fg={theme.dim}
290
+ selectable
277
291
  wrapMode="word"
278
292
  style={{ width: "100%", flexShrink: 1, minWidth: 0 }}
279
293
  />
280
- <markdown
294
+ <SelectableMarkdown
281
295
  content={line.text}
282
296
  streaming={false}
283
297
  syntaxStyle={syntaxStyle}
@@ -291,7 +305,7 @@ export function PendingMessageLine({
291
305
 
292
306
  return (
293
307
  <Row glyph="○ " glyphColor={theme.dim} background={theme.userBg}>
294
- <markdown
308
+ <SelectableMarkdown
295
309
  content={line.text}
296
310
  streaming={false}
297
311
  syntaxStyle={syntaxStyle}
@@ -317,10 +331,11 @@ export function AgentMessageLine({
317
331
  <text
318
332
  content={`${line.sender} → ${line.recipient}`}
319
333
  fg={theme.agentMessage}
334
+ selectable
320
335
  wrapMode="word"
321
336
  style={{ width: "100%", flexShrink: 1, minWidth: 0 }}
322
337
  />
323
- <markdown
338
+ <SelectableMarkdown
324
339
  content={line.text}
325
340
  streaming={false}
326
341
  syntaxStyle={syntaxStyle}
@@ -376,10 +391,11 @@ export function ToolLine({
376
391
  background={rejected ? theme.rejectionBg : undefined}
377
392
  >
378
393
  <box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
379
- {prefix ? <text content={prefix} style={{ flexShrink: 0 }} /> : null}
394
+ {prefix ? <text content={prefix} selectable style={{ flexShrink: 0 }} /> : null}
380
395
  <text
381
396
  ref={workingCaret ? caret : undefined}
382
397
  content={workingCaret ? undefined : new StyledText(bodyChunks)}
398
+ selectable
383
399
  wrapMode="word"
384
400
  style={{ flexGrow: 1, flexShrink: 1, minWidth: 0 }}
385
401
  />