pum-agent 0.1.2-beta.1 → 0.2.0-beta.2

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
@@ -45,7 +45,7 @@ The following screens are real OpenTUI renders captured through `tmux`. A local
45
45
  - **Parallel subagents:** Persistent agents work in isolated Git worktrees, communicate durably, and report lifecycle transitions to their direct spawners.
46
46
  - **Prompt control:** Steer active work, answer model questionnaires, use an ownership-aware message cache, attach clipboard images, and resume sessions with metadata-rich history.
47
47
  - **External triggers:** Supervise background commands such as `gh run watch` and automatically wake the exact target agent when they exit.
48
- - **Provider choice:** Use the login methods exposed by pi, or add an OpenAI-compatible custom endpoint.
48
+ - **Provider choice:** Search the providers exposed by pi, or add an OpenAI-compatible custom endpoint.
49
49
  - **Optional safeguards:** Use strict, balanced, or ask Check mode for `bash`, `edit`, `apply_patch`, and external-trigger process proposals.
50
50
  - **Terminal-first appearance:** Nine themes, semantic color overrides, Unicode glyphs, and optional animation.
51
51
 
@@ -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
 
@@ -91,11 +91,30 @@ pum
91
91
 
92
92
  The package is named `pum-agent` because the bare `pum` name is already owned. The installed command is still `pum`.
93
93
 
94
+ ## Command-line options
95
+
96
+ ```text
97
+ pum [options]
98
+ pum login [options]
99
+ ```
100
+
101
+ | Option or command | Action |
102
+ |---|---|
103
+ | `-h`, `--help` | Print the command-line manual and exit |
104
+ | `-v`, `--version` | Print the exact `pum-agent` package version and exit |
105
+ | `-r`, `--resume` | Resume the latest session for the current directory |
106
+ | `login` | Start PUM with the provider login panel open |
107
+
108
+ Help and version handling do not initialize the TUI, configuration, credentials, or sessions. Unknown options and commands return an error and a help hint.
109
+
110
+ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `pum --help` for a concise directory summary. Enter `?` on an empty in-app prompt to see all controls.
111
+
94
112
  ## Essential controls
95
113
 
96
114
  | Key | Action |
97
115
  |---|---|
98
116
  | `Enter` | Send a prompt, or steer the selected working agent |
117
+ | `↑` on an empty prompt | Recall the newest queued user message for the selected agent |
99
118
  | `Ctrl+Enter` / `Shift+Enter` | Insert a new line |
100
119
  | `Alt+Enter` | Stash the prompt without sending |
101
120
  | `Tab` | Open the prompt stash on an empty input |
@@ -112,6 +131,28 @@ The package is named `pum-agent` because the bare `pum` name is already owned. T
112
131
 
113
132
  Useful commands include `/login`, `/history`, `/triggers`, `/clear`, `/compress`, and `/worktree`.
114
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
+
115
156
  ## Parallel subagents
116
157
 
117
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:
@@ -125,6 +166,10 @@ Select a range of stashed prompts and press `Enter`. The main agent can group re
125
166
 
126
167
  Use `Ctrl+L` to select an agent transcript. Input then goes to that agent. Finished or interrupted agents remain available until PUM merges or removes them.
127
168
 
169
+ The public `spawn_subagent` tool accepts `preview: true`. PUM then shows the exact child task before it creates any worktree or session. Press `Enter` to approve. An optional note becomes a separate visible user instruction to the new child. Press `Esc` to cancel without creating a child. Cancellation discards the preview note because no child exists. PUM preserves the existing parent transcript draft.
170
+
171
+ Press `↑` on an empty single-line prompt to recall the newest queued user-authored message for the selected transcript. PUM removes the message from the authoritative queue before restoring its text. PUM does not recall inter-agent, trigger, lifecycle, cache, delivered, or image-bearing messages.
172
+
128
173
  Idle notices report settled work cycles to the direct spawner. They are not completion notices. PUM persists completion intent and delivery state so interrupted notification delivery can resume without duplicate completion messages.
129
174
 
130
175
  ## Tools and safeguards
@@ -145,9 +190,9 @@ The `message_cache_send` tool accepts stable entry IDs. Single entries use the s
145
190
 
146
191
  ### External triggers
147
192
 
148
- The trigger tools create process-local supervised commands with an executable and argument array. Trigger definitions can be listed, inspected, paused, resumed, cancelled, run manually, or fired synthetically. Definitions disappear when PUM exits.
193
+ The trigger tools create process-local supervised commands with an executable and argument array. Trigger definitions can be listed, inspected, paused, resumed, cancelled, or run manually. Definitions disappear when PUM exits.
149
194
 
150
- Use `Ctrl+T` or `/triggers` to inspect active definitions. PUM limits definitions, pending deliveries, output size, fire counts, repeat frequency, and lifetime. Output goes to a private temporary file and is removed after the triggered turn settles.
195
+ Use `Ctrl+T` or `/triggers` to inspect active definitions. PUM limits definitions, pending deliveries, output size, run counts, repeat frequency, and lifetime. Output goes to a private temporary file and is removed after the triggered turn settles.
151
196
 
152
197
  Trigger events target one exact main or retained child session. A missing session or child cancels its definitions instead of redirecting them. Check mode evaluates each process proposal without flattening its argument boundaries into shell text.
153
198
 
@@ -160,16 +205,18 @@ Trigger events target one exact main or retained child session. A missing sessio
160
205
  Select a Check mode profile in `Ctrl+P`. It applies to `bash`, `edit`, `apply_patch`, and external-trigger process execution:
161
206
 
162
207
  - **Strict:** Run deterministic hard rules, then require a clear verifier approval.
163
- - **Balanced:** Permit only narrow, recognized project-local operations after hard rules. Send all other calls to the verifier.
164
- - **Ask:** Send ambiguous calls to the verifier. If the result stays unclear or the verifier fails, ask the user.
208
+ - **Balanced:** Block deterministic hard-rule or suspicious findings. Allow ordinary complete project-local calls. Verifier review is non-blocking unless the verifier returns explicit `UNSAFE`.
209
+ - **Ask:** Show the approval popup for every checked call that passes hard rules, unless an exact session or project approval already matches. A verifier `SAFE`, unclear, error, or unavailable result still requires approval.
210
+
211
+ Every active profile hard-blocks project escape, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. These hard blocks cannot be overridden and do not open the popup. An explicit verifier `UNSAFE` verdict also blocks without a popup. The only exception is a deterministic match for direct main-agent `npm publish` or `npm dist-tag add`. The verifier category does not control this exception. The exception still requires explicit popup approval. Managed subagents cannot use the exception.
165
212
 
166
- Every active profile hard-blocks project escape, escaping links, credential access, privilege escalation, persistence, remote-script execution, destructive Git operations, and broad deletion. These hard blocks cannot be overridden. An explicit verifier `UNSAFE` verdict also cannot be overridden.
213
+ For `edit` and `apply_patch`, PUM validates the complete proposed change before any mutation. Review data includes the unified diff, changed paths, line counts, sensitivity flags, project containment, and full-content SHA-256. Invalid, stale, malformed, escaping, or incompletely analyzed input blocks the call. Patch length alone does not block a valid Balanced call.
167
214
 
168
- For `edit` and `apply_patch`, PUM validates the proposed change and sends a unified diff, changed paths, line counts, sensitivity flags, and project containment. PUM does not mutate files before the decision. Invalid or stale edit context blocks the call.
215
+ Ask mode can allow an exact call once, for the current session, or for the current project. Approvals match the authoritative main or child identity, tool, verifier model, project, and canonical complete input. Chat text is not approval. Use **Clear approvals** in Settings to remove project approvals.
169
216
 
170
- Ask mode can allow an exact call once, for the current session, or for the current project. Project approvals match the tool, verifier model, project, and canonical complete input. Use **Clear approvals** in Settings to remove project approvals.
217
+ The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict blocks malformed replies, errors, aborts, and timeouts. Balanced allows a fully validated call after an unclear, unavailable, failed, or timed-out review. Balanced still blocks explicit verifier `UNSAFE`, aborts, deterministic suspicious findings, malformed structures, and incomplete analysis. Ask requires the popup after hard rules for verifier `SAFE`, unclear, error, and unavailable results. Check mode is off by default and is not a sandbox.
171
218
 
172
- The verifier uses a structured decision schema. One unclear response can receive one adjudication under the shared 15-second watchdog. Strict and balanced block malformed replies, errors, aborts, and timeouts. Ask can present unresolved results to the user. Check mode is off by default and is not a sandbox.
219
+ Verifier prompts stay bounded. For an oversized Balanced review, PUM sends complete validation metadata, counts, findings, and SHA-256 digests. PUM does not send a raw prefix or suffix as if it were complete. Strict and Ask keep their fail-closed oversized-input behavior.
173
220
 
174
221
  ### Hosted web search
175
222
 
@@ -239,7 +286,7 @@ Use a throwaway `PUM_DIR` for local integration tests. Capture TUI output throug
239
286
 
240
287
  ## Release status
241
288
 
242
- 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.
243
290
 
244
291
  ## License
245
292
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.1.2-beta.1",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,7 @@ import {
10
10
  type StatusMetadataValues,
11
11
  } from "./status-metadata";
12
12
  import type { Theme } from "./theme";
13
+ import { PopupFrame } from "./popup-frame";
13
14
  import type { SubagentSnapshot } from "./subagents/types";
14
15
 
15
16
  export type AgentTreeRow = {
@@ -125,12 +126,19 @@ export function AgentSelectorPopup({
125
126
  const { width, height } = useTerminalDimensions();
126
127
  const scrollRef = useRef<ScrollBoxRenderable>(null);
127
128
  const narrow = width < 50;
128
- const popupWidth = narrow ? Math.max(12, width - 2) : Math.floor(width * 0.7);
129
+ const popupWidth = narrow ? Math.max(1, width - 2) : Math.max(1, Math.floor(width * 0.7));
129
130
  const popupColumns = Math.max(8, popupWidth - 5);
130
- const popupHeight = Math.min(
131
+ const popupHeight = Math.max(1, Math.min(
132
+ height,
131
133
  rows.length + (narrow ? 10 : 7),
132
134
  Math.max(7, Math.floor(height * (narrow ? 0.85 : 0.7))),
133
- );
135
+ ));
136
+ const geometry = {
137
+ top: narrow ? Math.min(1, Math.max(0, height - popupHeight)) : Math.max(0, Math.floor(height * 0.15)),
138
+ left: narrow ? Math.min(1, Math.max(0, width - popupWidth)) : Math.max(0, Math.floor(width * 0.15)),
139
+ width: popupWidth,
140
+ height: popupHeight,
141
+ };
134
142
 
135
143
  useEffect(() => {
136
144
  const timer = setTimeout(() => {
@@ -140,21 +148,13 @@ export function AgentSelectorPopup({
140
148
  }, [cursor]);
141
149
 
142
150
  return (
143
- <box
151
+ <PopupFrame
152
+ theme={theme}
153
+ terminalWidth={width}
154
+ terminalHeight={height}
155
+ geometry={geometry}
156
+ zIndex={100}
144
157
  title=" Agents "
145
- style={{
146
- position: "absolute",
147
- top: narrow ? 1 : "15%",
148
- left: narrow ? 1 : "15%",
149
- width: popupWidth,
150
- height: popupHeight,
151
- zIndex: 100,
152
- border: true,
153
- borderColor: theme.border,
154
- backgroundColor: theme.popupBg,
155
- flexDirection: "column",
156
- padding: 1,
157
- }}
158
158
  >
159
159
  <scrollbox
160
160
  ref={scrollRef}
@@ -212,6 +212,6 @@ export function AgentSelectorPopup({
212
212
  wrapMode="word"
213
213
  style={{ flexShrink: 0, width: "100%" }}
214
214
  />
215
- </box>
215
+ </PopupFrame>
216
216
  );
217
217
  }
package/src/app.tsx CHANGED
@@ -80,6 +80,9 @@ import {
80
80
  type PendingImage,
81
81
  } from "./image-paste";
82
82
  import type { SubagentManager } from "./subagents/manager";
83
+ import type { SpawnPreviewManager } from "./subagents/spawn-preview";
84
+ import { SpawnPreviewPopup } from "./subagents/spawn-preview-popup";
85
+ import { recallNewestQueuedUserMessage } from "./queue-recall";
83
86
  import { runWorktreeCommand } from "./worktree-command";
84
87
  import { CANCEL_WINDOW_MS, confirmsCancellation } from "./cancel-confirmation";
85
88
  import { buildStashBatchPrompt, selectedRange } from "./stash-batch";
@@ -329,6 +332,7 @@ export function App({
329
332
  searchProviders,
330
333
  subagentManager,
331
334
  questionnaireManager,
335
+ spawnPreviewManager,
332
336
  loginRequired = false,
333
337
  promptHistoryStore = DEFAULT_PROMPT_HISTORY_STORE,
334
338
  promptStashStore = DEFAULT_PROMPT_STASH_STORE,
@@ -349,6 +353,7 @@ export function App({
349
353
  searchProviders: string[];
350
354
  subagentManager: SubagentManager;
351
355
  questionnaireManager?: QuestionnaireManager;
356
+ spawnPreviewManager?: SpawnPreviewManager;
352
357
  loginRequired?: boolean;
353
358
  promptHistoryStore?: PromptHistoryStore;
354
359
  promptStashStore?: PromptStashStore;
@@ -406,10 +411,14 @@ export function App({
406
411
  const [agentElapsedSec, setAgentElapsedSec] = useState(0);
407
412
  const [loginOpen, setLoginOpen] = useState(loginRequired);
408
413
  const [, setQuestionnaireRevision] = useState(0);
414
+ const [, setSpawnPreviewRevision] = useState(0);
409
415
  const [loginPage, setLoginPage] = useState<LoginPage>(() => ({
410
416
  kind: "providers",
411
417
  methods: providerLoginMethods((modelRuntime as any).getProviders?.() ?? []),
412
418
  cursor: 0,
419
+ query: "",
420
+ searchFocused: true,
421
+ customVisible: true,
413
422
  }));
414
423
  const [modelQuery, setModelQuery] = useState("");
415
424
  const [modelSearchFocused, setModelSearchFocused] = useState(false);
@@ -429,6 +438,7 @@ export function App({
429
438
  ? agents.find((agent) => agent.id === activeAgentId)
430
439
  : undefined;
431
440
  const questionnaire = questionnaireManager?.current();
441
+ const spawnPreview = spawnPreviewManager?.current();
432
442
  const visibleTx = activeAgent?.transcript ?? tx;
433
443
  const visibleBusy = activeAgent
434
444
  ? activeAgent.status === "starting" || activeAgent.status === "running"
@@ -460,6 +470,7 @@ export function App({
460
470
 
461
471
  const inputRef = useRef<TextareaRenderable>(null);
462
472
  const questionnaireInputRef = useRef<TextareaRenderable>(null);
473
+ const spawnPreviewInputRef = useRef<TextareaRenderable>(null);
463
474
  const settingsOpenRef = useRef(settingsOpen);
464
475
  const triggersOpenRef = useRef(false);
465
476
  const settingsPageRef = useRef(page);
@@ -480,6 +491,10 @@ export function App({
480
491
  const imagePasteBusy = useRef(false);
481
492
  const viewDrafts = useRef(new Map<string, string>());
482
493
  const viewEditingStashIndices = useRef(new Map<string, number | null>());
494
+ const spawnPreviewRestoreView = useRef<{ active: boolean; agentId: string | null }>({
495
+ active: false,
496
+ agentId: null,
497
+ });
483
498
  /** Cache row currently checked out into the selected transcript input. */
484
499
  const editingStashIndex = useRef<number | null>(null);
485
500
  const quitTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -801,6 +816,10 @@ export function App({
801
816
  [subagentManager],
802
817
  );
803
818
 
819
+ useEffect(() => spawnPreviewManager?.subscribe(() => {
820
+ setSpawnPreviewRevision((revision) => revision + 1);
821
+ }), [spawnPreviewManager]);
822
+
804
823
  useEffect(() => {
805
824
  if (!questionnaireManager) return;
806
825
  return questionnaireManager.subscribe(() => {
@@ -937,8 +956,9 @@ export function App({
937
956
  clearTimeout(cancelTimer.current);
938
957
  clearPendingImages();
939
958
  cleanupPendingImages();
959
+ spawnPreviewManager?.cancelAll("shutdown");
940
960
  },
941
- [],
961
+ [spawnPreviewManager],
942
962
  );
943
963
 
944
964
  useEffect(() => {
@@ -1161,6 +1181,22 @@ export function App({
1161
1181
  session.abort().finally(() => setWorking(false));
1162
1182
  };
1163
1183
 
1184
+ const recallQueuedUserMessage = async (target: string | null) => {
1185
+ const recalled = target
1186
+ ? await subagentManager.recallQueuedUserMessage(target)
1187
+ : await recallNewestQueuedUserMessage(session, tx.pending);
1188
+ if (!recalled) return;
1189
+ if (!target) dropPending(recalled.id);
1190
+ const key = target ?? "main";
1191
+ viewDrafts.current.set(key, recalled.text);
1192
+ if (activeAgentIdRef.current === target) {
1193
+ setEditorText(recalled.text);
1194
+ histCursor.current = null;
1195
+ draft.current = "";
1196
+ inputRef.current?.focus();
1197
+ }
1198
+ };
1199
+
1164
1200
  /** Up walks back through sent prompts, down returns to the current draft. */
1165
1201
  const recall = (direction: -1 | 1) => {
1166
1202
  const input = inputRef.current;
@@ -1276,6 +1312,7 @@ export function App({
1276
1312
  promptText: string,
1277
1313
  displayText: string,
1278
1314
  images: ReturnType<typeof imageContent>[] = [],
1315
+ recallable = images.length === 0,
1279
1316
  ) => {
1280
1317
  const userLine: Extract<Line, { kind: "text" }> = {
1281
1318
  kind: "text",
@@ -1290,6 +1327,7 @@ export function App({
1290
1327
  id: randomUUID().slice(0, 12),
1291
1328
  line: userLine,
1292
1329
  deliveryText: promptText,
1330
+ recallable,
1293
1331
  };
1294
1332
  addPending(pending);
1295
1333
  withSearchRoute(session.sessionId, () => session.steer(promptText, images)).catch((error) => {
@@ -1386,7 +1424,7 @@ export function App({
1386
1424
  setStash(next);
1387
1425
  refreshHistoryAfterStashMutation();
1388
1426
  resetAfterCacheExecution();
1389
- deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts));
1427
+ deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts), [], false);
1390
1428
  };
1391
1429
 
1392
1430
  useEffect(() => {
@@ -1411,15 +1449,15 @@ export function App({
1411
1449
  );
1412
1450
  const prompts = entries.map((entry) => entry.text);
1413
1451
  if (prompts.length > 1) {
1414
- deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts));
1452
+ deliverMainPrompt(buildStashBatchPrompt(prompts), cachedBatchDisplay(prompts), [], false);
1415
1453
  return { count: prompts.length, route: "main" };
1416
1454
  }
1417
1455
  const prompt = prompts[0]!;
1418
1456
  if (request.requester.kind === "subagent") {
1419
- await subagentManager.sendUserMessage(request.requester.id, prompt, [], prompt);
1457
+ await subagentManager.sendUserMessage(request.requester.id, prompt, [], prompt, false);
1420
1458
  return { count: 1, route: "subagent" };
1421
1459
  }
1422
- deliverMainPrompt(prompt, prompt);
1460
+ deliverMainPrompt(prompt, prompt, [], false);
1423
1461
  return { count: 1, route: "main" };
1424
1462
  },
1425
1463
  );
@@ -1436,7 +1474,7 @@ export function App({
1436
1474
  if (action === "pause") result = triggerManager.pause(trigger.id);
1437
1475
  else if (action === "resume") result = triggerManager.resume(trigger.id);
1438
1476
  else if (action === "cancel") result = triggerManager.cancel(trigger.id);
1439
- else result = triggerManager.invoke(trigger.id, action);
1477
+ else result = triggerManager.invoke(trigger.id);
1440
1478
  Promise.resolve(result).catch((error) => append({
1441
1479
  kind: "text",
1442
1480
  role: "error",
@@ -1550,6 +1588,36 @@ export function App({
1550
1588
  return true;
1551
1589
  };
1552
1590
 
1591
+ useEffect(() => {
1592
+ if (spawnPreview) {
1593
+ if (!spawnPreviewRestoreView.current.active) {
1594
+ spawnPreviewRestoreView.current = {
1595
+ active: true,
1596
+ agentId: activeAgentIdRef.current,
1597
+ };
1598
+ }
1599
+ settingsOpenRef.current = false;
1600
+ setSettingsOpen(false);
1601
+ setHelpOpen(false);
1602
+ setHistoryOpen(false);
1603
+ setAgentSelectorOpen(false);
1604
+ setTriggerPopup(false, false);
1605
+ setLoginOpen(false);
1606
+ const target = spawnPreview.requester.agentId;
1607
+ if (target && !agents.some((agent) => agent.id === target)) {
1608
+ spawnPreviewManager?.cancel("unavailable");
1609
+ } else if (!selectAgentView(target)) {
1610
+ spawnPreviewManager?.cancel("unavailable");
1611
+ }
1612
+ return;
1613
+ }
1614
+ if (!spawnPreviewRestoreView.current.active) return;
1615
+ const target = spawnPreviewRestoreView.current.agentId;
1616
+ spawnPreviewRestoreView.current.active = false;
1617
+ if (!target || agents.some((agent) => agent.id === target)) selectAgentView(target);
1618
+ else selectAgentView(null);
1619
+ }, [spawnPreview?.id]);
1620
+
1553
1621
  const cycleAgentView = (direction: -1 | 1) => {
1554
1622
  const ids: Array<string | null> = [null, ...agents.map((agent) => agent.id)];
1555
1623
  if (ids.length === 1) return;
@@ -1582,11 +1650,27 @@ export function App({
1582
1650
  return;
1583
1651
  }
1584
1652
 
1653
+ if (spawnPreview) {
1654
+ const isPreviewReturn = ["return", "enter", "kpenter", "linefeed"].includes(key.name);
1655
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1656
+ key.stopPropagation();
1657
+ spawnPreviewInputRef.current?.setText("");
1658
+ spawnPreviewManager?.cancel();
1659
+ } else if (isPreviewReturn && !key.shift && !key.ctrl && !key.meta && !key.option) {
1660
+ key.stopPropagation();
1661
+ const note = spawnPreviewInputRef.current?.plainText ?? "";
1662
+ spawnPreviewInputRef.current?.setText("");
1663
+ spawnPreviewManager?.approve(note);
1664
+ }
1665
+ return;
1666
+ }
1667
+
1585
1668
  if (key.ctrl && key.name === "c") {
1586
1669
  key.stopPropagation();
1587
1670
  resetCancelArm();
1588
1671
  const promptOwnsInput =
1589
1672
  !questionnaire &&
1673
+ !spawnPreview &&
1590
1674
  !loginOpen &&
1591
1675
  !triggersOpenRef.current &&
1592
1676
  !agentSelectorOpen &&
@@ -1658,8 +1742,7 @@ export function App({
1658
1742
  }
1659
1743
 
1660
1744
  if (loginOpen) {
1661
- key.stopPropagation();
1662
- loginControllerRef.current?.handleKey(key);
1745
+ if (loginControllerRef.current?.handleKey(key)) key.stopPropagation();
1663
1746
  return;
1664
1747
  }
1665
1748
 
@@ -2015,7 +2098,6 @@ export function App({
2015
2098
  moveStash(key.name === "up" ? -1 : 1, key.shift);
2016
2099
  return;
2017
2100
  }
2018
- if (activeAgentId) return;
2019
2101
  if (commandMatches.length > 0) {
2020
2102
  key.stopPropagation();
2021
2103
  const next = moveCommandSelection(
@@ -2029,6 +2111,22 @@ export function App({
2029
2111
  }
2030
2112
  // Keep arrow navigation inside multiline or visually wrapped prompts.
2031
2113
  if ((inputRef.current?.editorView.getTotalVirtualLineCount() ?? 1) > 1) return;
2114
+ if (key.name === "up" && !inputValue && pendingImages.current.length === 0) {
2115
+ const queued = visibleTx.pending.some((pending) =>
2116
+ !pending.delivered &&
2117
+ pending.line.kind === "text" &&
2118
+ pending.line.role === "user" &&
2119
+ Boolean(pending.deliveryText),
2120
+ );
2121
+ if (queued) {
2122
+ key.stopPropagation();
2123
+ void recallQueuedUserMessage(activeAgentIdRef.current).catch((error) =>
2124
+ append({ kind: "text", role: "error", text: String(error) }),
2125
+ );
2126
+ return;
2127
+ }
2128
+ }
2129
+ if (activeAgentId) return;
2032
2130
  key.stopPropagation();
2033
2131
  recall(key.name === "up" ? -1 : 1);
2034
2132
  return;
@@ -2267,7 +2365,7 @@ export function App({
2267
2365
  selectionBg={theme.selectionBg}
2268
2366
  wrapMode="char"
2269
2367
  scrollMargin={1}
2270
- focused={!settingsOpen && !helpOpen && !historyOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !questionnaire && !checkApproval}
2368
+ focused={!settingsOpen && !helpOpen && !historyOpen && !agentSelectorOpen && !triggersOpen && !loginOpen && !questionnaire && !spawnPreview && !checkApproval}
2271
2369
  onContentChange={handleTextareaChange}
2272
2370
  onCursorChange={scheduleInputMetrics}
2273
2371
  onSubmit={() => submitPrompt()}
@@ -2287,7 +2385,23 @@ export function App({
2287
2385
  role="inputBottom"
2288
2386
  />
2289
2387
  {loginOpen ? (
2290
- <LoginPopup theme={theme} page={loginPage} terminalWidth={width} terminalHeight={height} />
2388
+ <LoginPopup
2389
+ theme={theme}
2390
+ page={loginPage}
2391
+ terminalWidth={width}
2392
+ terminalHeight={height}
2393
+ onProviderSearchChange={(value) => loginControllerRef.current?.setProviderQuery(value)}
2394
+ />
2395
+ ) : null}
2396
+ {spawnPreview ? (
2397
+ <SpawnPreviewPopup
2398
+ theme={theme}
2399
+ syntaxStyle={syntaxStyle}
2400
+ request={spawnPreview}
2401
+ terminalWidth={width}
2402
+ terminalHeight={height}
2403
+ inputRef={spawnPreviewInputRef}
2404
+ />
2291
2405
  ) : null}
2292
2406
  {questionnaire ? (
2293
2407
  <QuestionnairePopup
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Theme } from "./theme";
2
+ import { PopupFrame } from "./popup-frame";
2
3
 
3
4
  export type CheckApprovalDecision =
4
5
  | "allowOnce"
@@ -149,21 +150,19 @@ export function CheckApprovalPopup(props: CheckApprovalPopupProps) {
149
150
  const previewLabel = request.preview.kind === "command" ? "Command" : "Diff";
150
151
 
151
152
  return (
152
- <box
153
- title=" Approval required "
154
- style={{
155
- position: "absolute",
153
+ <PopupFrame
154
+ theme={theme}
155
+ terminalWidth={terminalWidth}
156
+ terminalHeight={terminalHeight}
157
+ geometry={{
156
158
  top: Math.max(0, Math.floor((terminalHeight - layout.popupHeight) / 2)),
157
159
  left: layout.margin,
158
160
  width: layout.popupWidth,
159
161
  height: layout.popupHeight,
160
- zIndex: 140,
161
- border: true,
162
- borderColor: theme.warn,
163
- backgroundColor: theme.popupBg,
164
- flexDirection: "column",
165
- padding: 1,
166
162
  }}
163
+ zIndex={140}
164
+ title=" Approval required "
165
+ borderColor={theme.warn}
167
166
  >
168
167
  <box style={{ height: 1, flexShrink: 0, flexDirection: "row" }}>
169
168
  <text content={request.tool} fg={theme.warn} bg={theme.popupBg} wrapMode="none" />
@@ -220,6 +219,6 @@ export function CheckApprovalPopup(props: CheckApprovalPopupProps) {
220
219
  wrapMode="none"
221
220
  style={{ height: 1, flexShrink: 0 }}
222
221
  />
223
- </box>
222
+ </PopupFrame>
224
223
  );
225
224
  }