pum-agent 0.2.29-beta.1 → 0.2.31-beta.1

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
@@ -23,6 +23,21 @@ pum
23
23
  PUM opens the login panel automatically on the first start. Press `?` on an
24
24
  empty prompt to see every control.
25
25
 
26
+ Select a model in `Ctrl+P`. GPT-6 Astra is listed for OpenAI and Codex;
27
+ using it requires access on your provider account. Press `r` in the model
28
+ list to refresh providers that support catalog discovery.
29
+
30
+ Use `/model <name or provider/model-id> [effort]` to change the main-agent model.
31
+ Names and IDs match exactly, without case sensitivity. Display names can contain spaces.
32
+ Use the provider-qualified ID when a name matches more than one model.
33
+ `/model` opens the model picker. `/effort <level>` changes only reasoning effort.
34
+ `/effort` shows the current effort and supported levels.
35
+ Both commands autocomplete available values and reject unsupported efforts before changing the model.
36
+ Model and effort changes use the same authentication and persistence as Settings.
37
+ Run these commands from the main transcript while the agent is idle; child models remain unchanged.
38
+ Use `/store` or `/s` to save current settings as global defaults, exactly like `s` in Settings.
39
+ These aliases promote PUM settings and clear session overrides. Pi already persists model and effort separately.
40
+
26
41
  > [!WARNING]
27
42
  > PUM can read, write, and delete files. Check mode adds deterministic policy
28
43
  > checks, and supported hosts can enforce native Bash isolation, but the
@@ -103,6 +118,13 @@ bun run start
103
118
 
104
119
  Use `bun run start -r` to resume the latest session for the current directory,
105
120
  and `/login` to add or update a provider later.
121
+ Inside the TUI, `/history` or its alias `/resume` opens the saved-session browser.
122
+
123
+ Only one PUM instance can own a saved session at a time. Close the owning
124
+ session before resuming it elsewhere. This also applies to headless runs,
125
+ managed agents, and worktree resume aliases. A locked history selection leaves
126
+ the current session unchanged. PUM automatically recovers locks from dead
127
+ processes when it can verify the owner. It never expires a live process lock.
106
128
 
107
129
  The published package is called `pum-agent` because the bare `pum` name is
108
130
  taken; the installed command is still `pum`. `npm i -g pum-agent` copies the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.29-beta.1",
3
+ "version": "0.2.31-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,8 +50,9 @@
50
50
  "pack:check": "bun run scripts/validate-pack.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@earendil-works/pi-ai": "^0.84.1",
54
- "@earendil-works/pi-coding-agent": "^0.84.1",
53
+ "@earendil-works/pi-ai": "^0.85.0",
54
+ "@earendil-works/pi-coding-agent": "^0.85.0",
55
+ "@earendil-works/pi-server": "^0.85.0",
55
56
  "@opentui/core": "^0.5.1",
56
57
  "@opentui/react": "^0.5.1",
57
58
  "react": "^19.2.8",
package/src/app.tsx CHANGED
@@ -151,6 +151,7 @@ import {
151
151
  } from "./path-autocomplete";
152
152
  import { setBashOutputSettingsIfPresent } from "./bash-output";
153
153
  import { settingsCompletions } from "./settings-autocomplete";
154
+ import { modelCommandCompletions, modelReference, parseModelSelection, validateEffort } from "./model-command";
154
155
  import {
155
156
  applySettingChange,
156
157
  listSettingsMessage,
@@ -176,9 +177,7 @@ import {
176
177
  type PendingImage,
177
178
  } from "./image-paste";
178
179
  import {
179
- cleanupPendingPastedTexts,
180
- pastedTextReadBlock,
181
- removePendingPastedText,
180
+ expandPastedTexts,
182
181
  shouldStagePastedText,
183
182
  stagePastedText as stagePastedTextDefault,
184
183
  type PendingPastedText,
@@ -861,7 +860,7 @@ export function App({
861
860
  promptStashStore?: PromptStashStore;
862
861
  captureImage?: typeof captureClipboardImage;
863
862
  readPastedText?: typeof readClipboardText;
864
- /** Store large or multiline pasted text in a temp file and show a marker. */
863
+ /** Keep large or multiline pasted text in memory and show a marker. */
865
864
  stagePastedText?: typeof stagePastedTextDefault;
866
865
  /** Copies the selected news answer for the popup. */
867
866
  copyNewsAnswerText?: typeof copyTextToClipboard;
@@ -1041,6 +1040,8 @@ export function App({
1041
1040
  }));
1042
1041
  const [modelQuery, setModelQuery] = useState("");
1043
1042
  const [modelSearchFocused, setModelSearchFocused] = useState(false);
1043
+ const [modelRefreshing, setModelRefreshing] = useState(false);
1044
+ const [modelRevision, setModelRevision] = useState(0);
1044
1045
  const [, setAgentRevision] = useState(0);
1045
1046
  const [triggersOpen, setTriggersOpen] = useState(false);
1046
1047
  const [processTab, setProcessTab] = useState<ProcessTab>("triggers");
@@ -1266,7 +1267,7 @@ export function App({
1266
1267
  )
1267
1268
  : EMPTY_STATS_SNAPSHOT), [statsOpen, statsManager, session, statsRevision]);
1268
1269
  const inputHint = transcriptFocused
1269
- ? " transcript j/k move enter details c copy esc prompt "
1270
+ ? " transcript ↑/↓ move enter details type for prompt "
1270
1271
  : cancelArmed
1271
1272
  ? " esc again to cancel "
1272
1273
  : quitArmed
@@ -1289,8 +1290,7 @@ export function App({
1289
1290
  commandInput,
1290
1291
  activeAgentId ? "subagent" : "main",
1291
1292
  );
1292
- // /providers and /settings are the commands whose arguments come from closed
1293
- // sets, so they complete them. Every other command still stops at its name.
1293
+ // Commands with closed argument sets share the path-completion insert path.
1294
1294
  const argumentSuggestionsOff = shellMode || stashOpen || commandSuggestionsDismissed
1295
1295
  || Boolean(activeAgentId);
1296
1296
  const providersSuggestions = argumentSuggestionsOff
@@ -1298,7 +1298,10 @@ export function App({
1298
1298
  : providersCompletions(commandInput, inputCursorOffset, managedProviders);
1299
1299
  const settingsSuggestions = argumentSuggestionsOff
1300
1300
  ? []
1301
- : settingsCompletions(commandInput, inputCursorOffset, cwd);
1301
+ : [
1302
+ ...settingsCompletions(commandInput, inputCursorOffset, cwd),
1303
+ ...modelCommandCompletions(commandInput, inputCursorOffset, modelRuntime.getAvailableSnapshot(), session.agent.state.model),
1304
+ ];
1302
1305
  const pathSuggestions = (!shellMode && activeAgentId) || stashOpen || commandSuggestionsDismissed
1303
1306
  || isCommandInput(commandInput)
1304
1307
  || !shouldAutoShowPathCompletions(commandInput, inputCursorOffset)
@@ -1321,7 +1324,7 @@ export function App({
1321
1324
  modelRuntime.getAvailableSnapshot(),
1322
1325
  modelQuery,
1323
1326
  (providerId) => (modelRuntime as any).getProvider?.(providerId)?.name ?? "",
1324
- ), [modelRuntime, modelId, modelQuery, loginPage]);
1327
+ ), [modelRuntime, modelId, modelQuery, loginPage, modelRevision]);
1325
1328
 
1326
1329
  const inputRef = useRef<TextareaRenderable>(null);
1327
1330
  const inputModeRef = useRef(false);
@@ -1334,6 +1337,7 @@ export function App({
1334
1337
  const processTabRef = useRef<ProcessTab>("triggers");
1335
1338
  const settingsPageRef = useRef(page);
1336
1339
  const settingsSearchFocusedRef = useRef(settingsSearchFocused);
1340
+ const modelRefreshInFlightRef = useRef(false);
1337
1341
  const focusInputAfterSwitch = useRef(false);
1338
1342
  const activeAgentIdRef = useRef<string | null>(null);
1339
1343
  const agentSelectorCursorRef = useRef(0);
@@ -1358,9 +1362,6 @@ export function App({
1358
1362
  const lastInputValue = useRef("");
1359
1363
  const pendingPastedTexts = useRef<PendingPastedText[]>([]);
1360
1364
  const nextPastedTextId = useRef(1);
1361
- /** Pasted-text files consumed by a send whose turn has not settled yet. */
1362
- const postTurnPastedTexts = useRef(new Map<string, PendingPastedText[]>());
1363
- const previousSubagentStatus = useRef(new Map<string, string>());
1364
1365
  const imagePasteBusy = useRef(false);
1365
1366
  const loginTextPasteBusy = useRef(false);
1366
1367
  const viewDrafts = useRef(new Map<string, string>());
@@ -1581,18 +1582,10 @@ export function App({
1581
1582
  };
1582
1583
 
1583
1584
  const clearPendingPastedTexts = () => {
1584
- for (const pasted of pendingPastedTexts.current) removePendingPastedText(pasted);
1585
1585
  pendingPastedTexts.current = [];
1586
1586
  nextPastedTextId.current = 1;
1587
1587
  };
1588
1588
 
1589
- const releasePostTurnPastedTexts = (targetKey: string) => {
1590
- const files = postTurnPastedTexts.current.get(targetKey);
1591
- if (!files) return;
1592
- postTurnPastedTexts.current.delete(targetKey);
1593
- for (const pasted of files) removePendingPastedText(pasted);
1594
- };
1595
-
1596
1589
  const syncInputMetrics = () => {
1597
1590
  const input = inputRef.current;
1598
1591
  // A queued frame or microtask can still run after the renderer destroys the
@@ -1645,8 +1638,7 @@ export function App({
1645
1638
  pathCompletionCycle.current = null;
1646
1639
  const edit = { previous: lastInputValue.current, next: nextValue };
1647
1640
 
1648
- // Editing any part of a marker deletes the whole attachment and its temp
1649
- // file at once. Both collections share one running draft, so the pastes
1641
+ // Editing any part of a marker deletes the whole attachment at once. Both collections share one running draft, so the pastes
1650
1642
  // prune against what the images already cut, and both are re-anchored
1651
1643
  // afterwards against the final value.
1652
1644
  const imagePrune = pruneEditedMarkers(pendingImages.current, {
@@ -1668,7 +1660,6 @@ export function App({
1668
1660
  pendingPastedTexts.current = pastes.kept;
1669
1661
 
1670
1662
  for (const image of [...imagePrune.removed, ...images.removed]) removePendingImage(image);
1671
- for (const pasted of [...pastedPrune.removed, ...pastes.removed]) removePendingPastedText(pasted);
1672
1663
  const removedImages = imagePrune.removed.length + images.removed.length;
1673
1664
  const removedPastedTexts = pastedPrune.removed.length + pastes.removed.length;
1674
1665
 
@@ -1772,7 +1763,6 @@ export function App({
1772
1763
  });
1773
1764
  pendingPastedTexts.current = pendingPastedTexts.current.filter((pasted) => {
1774
1765
  if (!intersects(pasted)) return true;
1775
- removePendingPastedText(pasted);
1776
1766
  return false;
1777
1767
  });
1778
1768
 
@@ -1818,7 +1808,7 @@ export function App({
1818
1808
 
1819
1809
  /**
1820
1810
  * Replace one large or multiline paste with a `[Pasted text #n]` marker. The text is
1821
- * written to a private temp file that the agent can `read` during the turn.
1811
+ * kept in memory and expanded into the submitted conversation text.
1822
1812
  */
1823
1813
  const stageLargePastedText = (event: PasteEvent) => {
1824
1814
  if (
@@ -1844,13 +1834,32 @@ export function App({
1844
1834
  const staged = stagePastedText(text);
1845
1835
  const current = input.plainText;
1846
1836
  const selection = input.hasSelection() ? input.getSelection() : null;
1847
- const start = selection ? selection.start : input.cursorOffset;
1848
- const end = selection ? selection.end : start;
1837
+ let start = selection ? Math.min(selection.start, selection.end) : input.cursorOffset;
1838
+ let end = selection ? Math.max(selection.start, selection.end) : start;
1839
+ const intersects = (item: { start: number; end: number }) => start === end
1840
+ ? start > item.start && start < item.end
1841
+ : start < item.end && end > item.start;
1842
+ for (const item of [...pendingImages.current, ...pendingPastedTexts.current]) {
1843
+ if (!intersects(item)) continue;
1844
+ start = Math.min(start, item.start);
1845
+ end = Math.max(end, item.end);
1846
+ }
1847
+ pendingImages.current = pendingImages.current.filter((image) => {
1848
+ if (!intersects(image)) return true;
1849
+ removePendingImage(image);
1850
+ return false;
1851
+ });
1852
+ pendingPastedTexts.current = pendingPastedTexts.current.filter((paste) => !intersects(paste));
1853
+ const shift = marker.length - (end - start);
1854
+ const reposition = <T extends { start: number; end: number }>(item: T): T =>
1855
+ item.start >= end ? { ...item, start: item.start + shift, end: item.end + shift } : item;
1856
+ pendingImages.current = pendingImages.current.map(reposition);
1857
+ pendingPastedTexts.current = pendingPastedTexts.current.map(reposition);
1849
1858
  const value = `${current.slice(0, start)}${marker}${current.slice(end)}`;
1850
1859
  pendingPastedTexts.current.push({
1851
1860
  id,
1852
1861
  marker,
1853
- path: staged.path,
1862
+ text: staged.text,
1854
1863
  bytes: staged.bytes,
1855
1864
  start,
1856
1865
  end: start + marker.length,
@@ -2197,7 +2206,6 @@ export function App({
2197
2206
  answerBufRef.current = "";
2198
2207
  userTurnActiveRef.current = false;
2199
2208
  turnPromptsRef.current = [];
2200
- releasePostTurnPastedTexts("main");
2201
2209
  streamingRef.current = false;
2202
2210
  setWorking(false);
2203
2211
  // A `/goalf` interview settles into a proposal, never into goal work.
@@ -2231,12 +2239,7 @@ export function App({
2231
2239
  clearTimeout(cancelTimer.current);
2232
2240
  clearPendingImages();
2233
2241
  cleanupPendingImages();
2234
- const pastedTempFiles = [...pendingPastedTexts.current];
2235
- for (const files of postTurnPastedTexts.current.values()) pastedTempFiles.push(...files);
2236
- for (const pasted of pastedTempFiles) removePendingPastedText(pasted);
2237
2242
  pendingPastedTexts.current = [];
2238
- postTurnPastedTexts.current.clear();
2239
- cleanupPendingPastedTexts();
2240
2243
  spawnPreviewManager?.cancelAll("shutdown");
2241
2244
  },
2242
2245
  [spawnPreviewManager],
@@ -2246,19 +2249,7 @@ export function App({
2246
2249
  resetCancelArm();
2247
2250
  }, [activeAgentId, visibleBusy]);
2248
2251
 
2249
- // Release pasted-text temp files once the subagent turn that consumed them settles.
2250
2252
  useEffect(() => {
2251
- for (const agent of agents) {
2252
- const previous = previousSubagentStatus.current.get(agent.id);
2253
- previousSubagentStatus.current.set(agent.id, agent.status);
2254
- if (
2255
- (previous === "starting" || previous === "running") &&
2256
- agent.status !== "starting" &&
2257
- agent.status !== "running"
2258
- ) {
2259
- releasePostTurnPastedTexts(agent.id);
2260
- }
2261
- }
2262
2253
  // A judge that settles without calling goal_verdict has said nothing. Drop
2263
2254
  // it, or `judgeInFlight` would stay true and stall the goal for good.
2264
2255
  const judge = judgeRef.current;
@@ -2726,14 +2717,14 @@ export function App({
2726
2717
  // the running bash tool has to see the new policy on the next call.
2727
2718
  if (patch.bashOutput !== undefined) setBashOutputSettingsIfPresent(patch.bashOutput);
2728
2719
  // Session-scoped: the popup never writes the global config, which the
2729
- // sandboxes keep read-only. `s` in the popup is the one way to promote.
2720
+ // sandboxes keep read-only. Popup `s` and /store explicitly promote.
2730
2721
  const overrides = sessionSettingsDiff(globalSettingsRef.current, next);
2731
2722
  sessionOverridesRef.current = overrides;
2732
2723
  setSessionOverrides(overrides);
2733
2724
  saveSessionSettings(session.sessionFile, overrides);
2734
2725
  };
2735
2726
 
2736
- /** Promote this session's settings to the global defaults, on `s` in the popup. */
2727
+ /** Explicit global promotion shared by popup `s`, /s, and /store. */
2737
2728
  const promoteSessionSettings = () => {
2738
2729
  const next = settingsRef.current;
2739
2730
  globalSettingsRef.current = next;
@@ -2870,6 +2861,25 @@ export function App({
2870
2861
  void loadManagedProviders();
2871
2862
  }, [commandInput, managedProviders.length]);
2872
2863
 
2864
+ const refreshModels = async () => {
2865
+ if (modelRefreshInFlightRef.current) return;
2866
+ modelRefreshInFlightRef.current = true;
2867
+ setModelRefreshing(true);
2868
+ try {
2869
+ const result = await modelRuntime.refresh({ allowNetwork: true, force: true });
2870
+ if (result.errors.size > 0) {
2871
+ const details = [...result.errors].map(([provider, error]) => `${provider}: ${error.message}`).join("; ");
2872
+ append({ kind: "text", role: "error", text: `model refresh completed with errors: ${details}` });
2873
+ }
2874
+ } catch (error) {
2875
+ append({ kind: "text", role: "error", text: `model refresh failed: ${String(error)}` });
2876
+ } finally {
2877
+ modelRefreshInFlightRef.current = false;
2878
+ setModelRefreshing(false);
2879
+ setModelRevision((revision) => revision + 1);
2880
+ }
2881
+ };
2882
+
2873
2883
  const selectModel = (model: Model<any>) => {
2874
2884
  settingsPageRef.current = "main";
2875
2885
  setPage("main");
@@ -3503,7 +3513,7 @@ export function App({
3503
3513
  /**
3504
3514
  * Session scope by default, exactly like the popup. `--global` also rewrites
3505
3515
  * that one key in pum.json, so the rest of the global file and this session's
3506
- * other overrides are left alone — only `s` in the popup promotes everything.
3516
+ * other overrides are left alone — popup `s` and /store promote everything.
3507
3517
  */
3508
3518
  const applySettingsPatch = (patch: Partial<PumSettings>, global: boolean) => {
3509
3519
  if (global) {
@@ -3572,20 +3582,27 @@ export function App({
3572
3582
  const runCommand = (text: string): boolean => {
3573
3583
  const trimmed = text.trim();
3574
3584
  if (runGoalCommand(trimmed)) return true;
3585
+ if (trimmed === "/s" || trimmed === "/store") {
3586
+ setEditorText("");
3587
+ promoteSessionSettings();
3588
+ return true;
3589
+ }
3575
3590
  const compress = /^\/compress(?:\s+(.*))?$/s.exec(trimmed);
3576
3591
  const clear = /^\/(?:clear|new)$/.test(trimmed);
3577
- const historyCommand = trimmed === "/history";
3592
+ const historyCommand = trimmed === "/history" || trimmed === "/resume";
3578
3593
  const loginCommand = trimmed === "/login";
3579
3594
  const providersCommand = parseProvidersCommand(trimmed);
3580
3595
  const checkPathCommand = /^\/check-path(?:\s|$)/.test(trimmed);
3581
3596
  const settingsCommand = /^\/settings(?:\s|$)/.test(trimmed);
3597
+ const modelCommand = /^\/model(?:\s|$)/.test(trimmed);
3598
+ const effortCommand = /^\/effort(?:\s|$)/.test(trimmed);
3582
3599
  const triggersCommand = trimmed === "/triggers";
3583
3600
  const processesCommand = trimmed === "/processes";
3584
3601
  const newsCommand = trimmed === "/news";
3585
3602
  const todoCommand = trimmed === "/todo";
3586
3603
  const statsCommand = trimmed === "/stats";
3587
3604
  const worktreeCommand = parseWorktreeCommand(trimmed);
3588
- if (!compress && !clear && !historyCommand && !loginCommand && !providersCommand && !checkPathCommand && !settingsCommand && !triggersCommand && !processesCommand && !newsCommand && !todoCommand && !statsCommand && !worktreeCommand) return false;
3605
+ if (!compress && !clear && !historyCommand && !loginCommand && !providersCommand && !checkPathCommand && !settingsCommand && !modelCommand && !effortCommand && !triggersCommand && !processesCommand && !newsCommand && !todoCommand && !statsCommand && !worktreeCommand) return false;
3589
3606
  setEditingStash(null);
3590
3607
 
3591
3608
  if (historyCommand) {
@@ -3593,6 +3610,16 @@ export function App({
3593
3610
  openHistory();
3594
3611
  return true;
3595
3612
  }
3613
+ if (modelCommand && trimmed === "/model") {
3614
+ setEditorText("");
3615
+ setModelQuery("");
3616
+ setModelSearchFocused(true);
3617
+ settingsPageRef.current = "models";
3618
+ setPage("models");
3619
+ settingsOpenRef.current = true;
3620
+ setSettingsOpen(true);
3621
+ return true;
3622
+ }
3596
3623
  if (loginCommand) {
3597
3624
  setEditorText("");
3598
3625
  openLogin();
@@ -3663,6 +3690,25 @@ export function App({
3663
3690
  })
3664
3691
  .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
3665
3692
  .finally(() => setWorking(streamingRef.current));
3693
+ } else if (modelCommand || effortCommand) {
3694
+ const targetSession = session;
3695
+ Promise.resolve().then(async () => {
3696
+ if (modelCommand) {
3697
+ const selection = parseModelSelection(trimmed.slice("/model".length), modelRuntime.getAvailableSnapshot());
3698
+ // Validate before setModel: an invalid effort must not change the model.
3699
+ // setModel owns authentication, persistence, and model-change events.
3700
+ await targetSession.setModel(selection.model);
3701
+ if (selection.effort !== undefined) targetSession.setThinkingLevel(selection.effort);
3702
+ } else {
3703
+ const value = trimmed.slice("/effort".length).trim();
3704
+ if (value) targetSession.setThinkingLevel(validateEffort(value, targetSession.agent.state.model));
3705
+ }
3706
+ setModelId(targetSession.agent.state.model!.id);
3707
+ setThinkingLevel(targetSession.agent.state.thinkingLevel as ThinkingLevel);
3708
+ append({ kind: "text", role: "system", text: `Model: ${modelReference(targetSession.agent.state.model!)}; effort: ${targetSession.agent.state.thinkingLevel}. Supported efforts: ${getSupportedThinkingLevels(targetSession.agent.state.model).join(", ")}` });
3709
+ })
3710
+ .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
3711
+ .finally(() => setWorking(streamingRef.current));
3666
3712
  } else if (settingsCommand) {
3667
3713
  runSettingsCommand(trimmed)
3668
3714
  .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
@@ -4227,21 +4273,18 @@ export function App({
4227
4273
  const targetKey = selectedAgentId ?? "main";
4228
4274
  const rawDisplayText = value ?? inputRef.current?.plainText ?? "";
4229
4275
  const commandEligible = isCommandInput(rawDisplayText);
4230
- const displayText = rawDisplayText.trim();
4231
4276
  const attachments = value === undefined ? [...pendingImages.current] : [];
4232
4277
  const pastedTexts = value === undefined ? [...pendingPastedTexts.current] : [];
4233
4278
  const submittedEditingIndex = editingStashIndex.current;
4234
- let promptText = rawDisplayText;
4235
- for (const image of attachments) promptText = promptText.replace(image.marker, "");
4236
- for (const pasted of pastedTexts) {
4237
- promptText = promptText.replace(pasted.marker, pastedTextReadBlock(pasted));
4238
- }
4239
- promptText = promptText.trim();
4240
-
4241
- // History and stash keep the draft form, never the ephemeral temp path.
4242
- let persistedPrompt = rawDisplayText;
4243
- for (const pasted of pastedTexts) persistedPrompt = persistedPrompt.replace(pasted.marker, "");
4244
- persistedPrompt = persistedPrompt.trim();
4279
+ const expandedText = expandPastedTexts(rawDisplayText, pastedTexts);
4280
+ const displayText = pastedTexts.length ? expandedText : expandedText.trim();
4281
+ const expandedPrompt = expandPastedTexts(rawDisplayText, [
4282
+ ...pastedTexts,
4283
+ ...attachments.map((image) => ({ ...image, text: "" })),
4284
+ ]);
4285
+ const promptText = pastedTexts.length ? expandedPrompt : expandedPrompt.trim();
4286
+ // Recall and persisted transcript text remain usable without draft payloads.
4287
+ const persistedPrompt = displayText;
4245
4288
 
4246
4289
  if (!promptText && attachments.length === 0 && pastedTexts.length === 0) return;
4247
4290
 
@@ -4284,17 +4327,13 @@ export function App({
4284
4327
  return;
4285
4328
  }
4286
4329
 
4287
- // Detach files from editor cleanup until delivery succeeds. A failed send
4330
+ // Detach attachments from editor cleanup until delivery succeeds. A failed send
4288
4331
  // can then restore the exact draft and each still-valid attachment marker.
4289
4332
  if (value === undefined) {
4290
4333
  pendingImages.current = [];
4291
4334
  nextImageId.current = 1;
4292
4335
  pendingPastedTexts.current = [];
4293
4336
  nextPastedTextId.current = 1;
4294
- if (pastedTexts.length > 0) {
4295
- const existing = postTurnPastedTexts.current.get(targetKey) ?? [];
4296
- postTurnPastedTexts.current.set(targetKey, [...existing, ...pastedTexts]);
4297
- }
4298
4337
  }
4299
4338
 
4300
4339
  setEditorText("");
@@ -4303,18 +4342,11 @@ export function App({
4303
4342
  for (const image of attachments) removePendingImage(image);
4304
4343
  };
4305
4344
  const restoreFailedDraft = () => {
4306
- const postTurn = postTurnPastedTexts.current.get(targetKey) ?? [];
4307
- const submittedPaths = new Set(pastedTexts.map((pasted) => pasted.path));
4308
- const remaining = postTurn.filter((pasted) => !submittedPaths.has(pasted.path));
4309
- if (remaining.length > 0) postTurnPastedTexts.current.set(targetKey, remaining);
4310
- else postTurnPastedTexts.current.delete(targetKey);
4311
-
4312
4345
  const canRestore =
4313
4346
  activeAgentIdRef.current === selectedAgentId &&
4314
4347
  !(inputRef.current?.plainText ?? "");
4315
4348
  if (!canRestore) {
4316
4349
  finishAttachments();
4317
- for (const pasted of pastedTexts) removePendingPastedText(pasted);
4318
4350
  append({
4319
4351
  kind: "text",
4320
4352
  role: "error",
@@ -4346,6 +4378,13 @@ export function App({
4346
4378
  return;
4347
4379
  }
4348
4380
 
4381
+ // Model controls follow Settings: only main owns model mutations.
4382
+ if (selectedAgentId && attachments.length === 0 && commandEligible
4383
+ && /^\/(?:model|effort|s|store)(?:\s|$)/.test(promptText)) {
4384
+ appendRequesterLine(selectedAgentId, { kind: "text", role: "error", text: "Model, effort, and global settings commands require the main transcript. Select the main transcript first." });
4385
+ return;
4386
+ }
4387
+
4349
4388
  // /background belongs to the selected transcript, unlike the main-only
4350
4389
  // command router below, and must never become an ordinary child message.
4351
4390
  if (
@@ -4411,7 +4450,7 @@ export function App({
4411
4450
  };
4412
4451
 
4413
4452
  const submitShellCommand = () => {
4414
- const command = inputRef.current?.plainText ?? "";
4453
+ const command = expandPastedTexts(inputRef.current?.plainText ?? "", pendingPastedTexts.current);
4415
4454
  if (!command.trim()) return;
4416
4455
  const selectedAgentId = activeAgentIdRef.current;
4417
4456
  setEditorText("");
@@ -4644,24 +4683,24 @@ export function App({
4644
4683
  ? " (no truecolor)"
4645
4684
  : "";
4646
4685
  const rowValues: Record<SettingRowId, string> = {
4647
- theme: `‹ ${theme.name} ›`,
4648
- providers: "login and custom setup",
4649
- animations: `‹ ${settings.animations ? "on" : "off"} ›`,
4650
- workingRuleAnimation: `‹ ${WORKING_RULE_ANIMATION_LABELS[settings.workingRuleAnimation]} ›${settings.workingRuleAnimation === "off" ? "" : animationUnavailable}`,
4651
- outputMode: `‹ ${OUTPUT_MODE_LABELS[settings.outputMode ?? "normal"]} ›`,
4652
- showAgentMessages: `‹ ${settings.showAgentMessages === false ? "off" : "on"} ›`,
4653
- webSearch: `‹ ${settings.webSearch ? "on" : "off"} ›${searchProviders.length ? "" : " (not on provider)"}`,
4654
- writingStyle: `‹ ${settings.writingStyle} ›`,
4655
- explanationStrength: `‹ ${settings.explanationStrength} ›`,
4656
- checkMode: `‹ ${settings.checkMode} ›`,
4657
- sandboxMode: `‹ ${settings.sandboxMode ?? "auto"} ›`,
4658
- checkModel: `${settings.checkModel} ›`,
4659
- checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
4660
- thinkingLevel: `‹ ${thinkingLevel} ›`,
4661
- showThinking: `‹ ${settings.showThinking ? "on" : "off"} ›`,
4662
- maxActiveSubagents: `‹ ${settings.maxActiveSubagents} ›`,
4663
- goalRetryLimit: `‹ ${normalizeGoalRetryLimit(settings.goalRetryLimit) || "unlimited"} ›`,
4664
- model: `${modelId} ›`,
4686
+ theme: `‹ ${theme.name}`,
4687
+ providers: "login and custom setup",
4688
+ animations: `‹ ${settings.animations ? "on" : "off"}`,
4689
+ workingRuleAnimation: `‹ ${WORKING_RULE_ANIMATION_LABELS[settings.workingRuleAnimation]}${settings.workingRuleAnimation === "off" ? "" : animationUnavailable}`,
4690
+ outputMode: `‹ ${OUTPUT_MODE_LABELS[settings.outputMode ?? "normal"]}`,
4691
+ showAgentMessages: `‹ ${settings.showAgentMessages === false ? "off" : "on"}`,
4692
+ webSearch: `‹ ${settings.webSearch ? "on" : "off"}${searchProviders.length ? "" : " (not on provider)"}`,
4693
+ writingStyle: `‹ ${settings.writingStyle}`,
4694
+ explanationStrength: `‹ ${settings.explanationStrength}`,
4695
+ checkMode: `‹ ${settings.checkMode}`,
4696
+ sandboxMode: `‹ ${settings.sandboxMode ?? "auto"}`,
4697
+ checkModel: `› ${settings.checkModel}`,
4698
+ checkPaths: `› ${checkPathsForProject(settings, cwd).length} additional · /check-path`,
4699
+ thinkingLevel: `‹ ${thinkingLevel}`,
4700
+ showThinking: `‹ ${settings.showThinking ? "on" : "off"}`,
4701
+ maxActiveSubagents: `‹ ${settings.maxActiveSubagents}`,
4702
+ goalRetryLimit: `‹ ${normalizeGoalRetryLimit(settings.goalRetryLimit) || "unlimited"}`,
4703
+ model: `› ${modelId}`,
4665
4704
  };
4666
4705
 
4667
4706
  const updateSettingsQuery = (query: string) => {
@@ -4875,18 +4914,6 @@ export function App({
4875
4914
  // the life of the app. The ref carries the current closure behind it.
4876
4915
  clickTranscriptDisclosureRef.current = clickTranscriptDisclosure;
4877
4916
 
4878
- const copyTranscriptRow = () => {
4879
- const line = visibleLines[transcriptCursorRef.current];
4880
- if (!line) return;
4881
- copyTranscriptText(projectedLineRawText(line), {
4882
- osc52: (value) => renderer.copyToClipboardOSC52(value),
4883
- }).catch((error) => append({
4884
- kind: "text",
4885
- role: "error",
4886
- text: `copy failed: ${String(error)}`,
4887
- }));
4888
- };
4889
-
4890
4917
  usePaste((event) => {
4891
4918
  const controller = loginControllerRef.current;
4892
4919
  if (loginOpen) {
@@ -4900,6 +4927,12 @@ export function App({
4900
4927
  });
4901
4928
 
4902
4929
  useKeyboard((key) => {
4930
+ // Match Textarea's printable sequence handling, but leave all modified
4931
+ // commands (including Option) with their existing owner.
4932
+ const typedText = !key.ctrl && !key.meta && !key.option && !key.super && !key.hyper
4933
+ ? key.name === "space" ? " " : key.sequence && !/[\u0000-\u001f\u007f-\u009f]/u.test(key.sequence)
4934
+ ? key.sequence : ""
4935
+ : "";
4903
4936
  if (spawnPreview) {
4904
4937
  const isPreviewReturn = ["return", "enter", "kpenter", "linefeed"].includes(key.name);
4905
4938
  if (key.name === "escape" || (key.ctrl && key.name === "c")) {
@@ -5073,13 +5106,12 @@ export function App({
5073
5106
  return;
5074
5107
  }
5075
5108
 
5076
- if (transcriptFocusedRef.current) {
5109
+ if (transcriptFocusedRef.current && !typedText) {
5077
5110
  key.stopPropagation();
5078
5111
  if (key.name === "escape") setTranscriptFocus(false);
5079
- else if (key.name === "j" || key.name === "down") moveTranscriptCursor(1);
5080
- else if (key.name === "k" || key.name === "up") moveTranscriptCursor(-1);
5112
+ else if (key.name === "down") moveTranscriptCursor(1);
5113
+ else if (key.name === "up") moveTranscriptCursor(-1);
5081
5114
  else if (["return", "enter", "kpenter", "linefeed"].includes(key.name)) toggleTranscriptDetail();
5082
- else if (key.name === "c" && !key.ctrl && !key.meta && !key.option) copyTranscriptRow();
5083
5115
  return;
5084
5116
  }
5085
5117
 
@@ -5255,6 +5287,18 @@ export function App({
5255
5287
  return;
5256
5288
  }
5257
5289
 
5290
+ // Popups above retain typing ownership. Settings handles its fields below.
5291
+ // A clicked transcript row or a native blur must not consume the first
5292
+ // typed character. Stop dispatch before focusing: OpenTUI can otherwise
5293
+ // deliver this same event to the newly focused textarea a second time.
5294
+ const recoverTypedInput = Boolean(typedText && !settingsOpenRef.current && !settingsOpen
5295
+ && inputRef.current && (!inputRef.current.focused || transcriptFocusedRef.current));
5296
+ if (recoverTypedInput) {
5297
+ key.stopPropagation();
5298
+ if (transcriptFocusedRef.current) setTranscriptFocus(false);
5299
+ inputRef.current?.focus();
5300
+ }
5301
+
5258
5302
  // `?` on an empty prompt opens help instead of typing a question mark.
5259
5303
  // With text already in the line it is just a character.
5260
5304
  if (key.sequence === "?" && !settingsOpen && !inputRef.current?.plainText) {
@@ -5284,6 +5328,11 @@ export function App({
5284
5328
  }
5285
5329
  return;
5286
5330
  }
5331
+ if (!key.ctrl && !key.meta && !key.option && printableKey === "r") {
5332
+ key.stopPropagation();
5333
+ void refreshModels();
5334
+ return;
5335
+ }
5287
5336
  if (isModelSearchShortcut(key, modelSearchFocused)) {
5288
5337
  key.stopPropagation();
5289
5338
  setModelSearchFocused(true);
@@ -5424,7 +5473,10 @@ export function App({
5424
5473
  : providersCompletions(inputValue, inputCursor, managedProvidersRef.current);
5425
5474
  const settingsMatches = argumentMatchesOff
5426
5475
  ? []
5427
- : settingsCompletions(inputValue, inputCursor, cwd);
5476
+ : [
5477
+ ...settingsCompletions(inputValue, inputCursor, cwd),
5478
+ ...modelCommandCompletions(inputValue, inputCursor, modelRuntime.getAvailableSnapshot(), session.agent.state.model),
5479
+ ];
5428
5480
  // Every source carries path-completion offsets, so one insert path serves all.
5429
5481
  const closedSetMatches = providersMatches.length > 0 ? providersMatches : settingsMatches;
5430
5482
  const argumentMatches = closedSetMatches.length > 0 ? closedSetMatches : pathMatches;
@@ -5759,6 +5811,7 @@ export function App({
5759
5811
  settingsOpenRef.current = true;
5760
5812
  setSettingsOpen(true);
5761
5813
  }
5814
+ if (recoverTypedInput) inputRef.current?.insertText(typedText);
5762
5815
  });
5763
5816
 
5764
5817
  const lastLine = visibleLines[visibleLines.length - 1];
@@ -6170,6 +6223,7 @@ export function App({
6170
6223
  models={visibleModels}
6171
6224
  modelQuery={modelQuery}
6172
6225
  modelSearchFocused={modelSearchFocused}
6226
+ modelRefreshing={modelRefreshing}
6173
6227
  onSearchChange={updateSettingsQuery}
6174
6228
  onModelSearchChange={setModelQuery}
6175
6229
  onSelectModel={selectModel}
@@ -0,0 +1,15 @@
1
+ import { TextRenderable } from "@opentui/core";
2
+ import { extend } from "@opentui/react";
3
+
4
+ /** A fixed tail viewport: wheel events belong to the enclosing transcript. */
5
+ export class BashTailTextRenderable extends TextRenderable {
6
+ protected override handleScroll(): void {}
7
+ }
8
+
9
+ extend({ bash_tail_text: BashTailTextRenderable });
10
+
11
+ declare module "@opentui/react" {
12
+ interface OpenTUIComponents {
13
+ bash_tail_text: typeof BashTailTextRenderable;
14
+ }
15
+ }