pum-agent 0.2.30-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
@@ -27,6 +27,17 @@ Select a model in `Ctrl+P`. GPT-6 Astra is listed for OpenAI and Codex;
27
27
  using it requires access on your provider account. Press `r` in the model
28
28
  list to refresh providers that support catalog discovery.
29
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
+
30
41
  > [!WARNING]
31
42
  > PUM can read, write, and delete files. Check mode adds deterministic policy
32
43
  > checks, and supported hosts can enforce native Bash isolation, but the
@@ -107,6 +118,13 @@ bun run start
107
118
 
108
119
  Use `bun run start -r` to resume the latest session for the current directory,
109
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.
110
128
 
111
129
  The published package is called `pum-agent` because the bare `pum` name is
112
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.30-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",
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;
@@ -1268,7 +1267,7 @@ export function App({
1268
1267
  )
1269
1268
  : EMPTY_STATS_SNAPSHOT), [statsOpen, statsManager, session, statsRevision]);
1270
1269
  const inputHint = transcriptFocused
1271
- ? " transcript j/k move enter details c copy esc prompt "
1270
+ ? " transcript ↑/↓ move enter details type for prompt "
1272
1271
  : cancelArmed
1273
1272
  ? " esc again to cancel "
1274
1273
  : quitArmed
@@ -1291,8 +1290,7 @@ export function App({
1291
1290
  commandInput,
1292
1291
  activeAgentId ? "subagent" : "main",
1293
1292
  );
1294
- // /providers and /settings are the commands whose arguments come from closed
1295
- // 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.
1296
1294
  const argumentSuggestionsOff = shellMode || stashOpen || commandSuggestionsDismissed
1297
1295
  || Boolean(activeAgentId);
1298
1296
  const providersSuggestions = argumentSuggestionsOff
@@ -1300,7 +1298,10 @@ export function App({
1300
1298
  : providersCompletions(commandInput, inputCursorOffset, managedProviders);
1301
1299
  const settingsSuggestions = argumentSuggestionsOff
1302
1300
  ? []
1303
- : settingsCompletions(commandInput, inputCursorOffset, cwd);
1301
+ : [
1302
+ ...settingsCompletions(commandInput, inputCursorOffset, cwd),
1303
+ ...modelCommandCompletions(commandInput, inputCursorOffset, modelRuntime.getAvailableSnapshot(), session.agent.state.model),
1304
+ ];
1304
1305
  const pathSuggestions = (!shellMode && activeAgentId) || stashOpen || commandSuggestionsDismissed
1305
1306
  || isCommandInput(commandInput)
1306
1307
  || !shouldAutoShowPathCompletions(commandInput, inputCursorOffset)
@@ -1361,9 +1362,6 @@ export function App({
1361
1362
  const lastInputValue = useRef("");
1362
1363
  const pendingPastedTexts = useRef<PendingPastedText[]>([]);
1363
1364
  const nextPastedTextId = useRef(1);
1364
- /** Pasted-text files consumed by a send whose turn has not settled yet. */
1365
- const postTurnPastedTexts = useRef(new Map<string, PendingPastedText[]>());
1366
- const previousSubagentStatus = useRef(new Map<string, string>());
1367
1365
  const imagePasteBusy = useRef(false);
1368
1366
  const loginTextPasteBusy = useRef(false);
1369
1367
  const viewDrafts = useRef(new Map<string, string>());
@@ -1584,18 +1582,10 @@ export function App({
1584
1582
  };
1585
1583
 
1586
1584
  const clearPendingPastedTexts = () => {
1587
- for (const pasted of pendingPastedTexts.current) removePendingPastedText(pasted);
1588
1585
  pendingPastedTexts.current = [];
1589
1586
  nextPastedTextId.current = 1;
1590
1587
  };
1591
1588
 
1592
- const releasePostTurnPastedTexts = (targetKey: string) => {
1593
- const files = postTurnPastedTexts.current.get(targetKey);
1594
- if (!files) return;
1595
- postTurnPastedTexts.current.delete(targetKey);
1596
- for (const pasted of files) removePendingPastedText(pasted);
1597
- };
1598
-
1599
1589
  const syncInputMetrics = () => {
1600
1590
  const input = inputRef.current;
1601
1591
  // A queued frame or microtask can still run after the renderer destroys the
@@ -1648,8 +1638,7 @@ export function App({
1648
1638
  pathCompletionCycle.current = null;
1649
1639
  const edit = { previous: lastInputValue.current, next: nextValue };
1650
1640
 
1651
- // Editing any part of a marker deletes the whole attachment and its temp
1652
- // 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
1653
1642
  // prune against what the images already cut, and both are re-anchored
1654
1643
  // afterwards against the final value.
1655
1644
  const imagePrune = pruneEditedMarkers(pendingImages.current, {
@@ -1671,7 +1660,6 @@ export function App({
1671
1660
  pendingPastedTexts.current = pastes.kept;
1672
1661
 
1673
1662
  for (const image of [...imagePrune.removed, ...images.removed]) removePendingImage(image);
1674
- for (const pasted of [...pastedPrune.removed, ...pastes.removed]) removePendingPastedText(pasted);
1675
1663
  const removedImages = imagePrune.removed.length + images.removed.length;
1676
1664
  const removedPastedTexts = pastedPrune.removed.length + pastes.removed.length;
1677
1665
 
@@ -1775,7 +1763,6 @@ export function App({
1775
1763
  });
1776
1764
  pendingPastedTexts.current = pendingPastedTexts.current.filter((pasted) => {
1777
1765
  if (!intersects(pasted)) return true;
1778
- removePendingPastedText(pasted);
1779
1766
  return false;
1780
1767
  });
1781
1768
 
@@ -1821,7 +1808,7 @@ export function App({
1821
1808
 
1822
1809
  /**
1823
1810
  * Replace one large or multiline paste with a `[Pasted text #n]` marker. The text is
1824
- * 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.
1825
1812
  */
1826
1813
  const stageLargePastedText = (event: PasteEvent) => {
1827
1814
  if (
@@ -1847,13 +1834,32 @@ export function App({
1847
1834
  const staged = stagePastedText(text);
1848
1835
  const current = input.plainText;
1849
1836
  const selection = input.hasSelection() ? input.getSelection() : null;
1850
- const start = selection ? selection.start : input.cursorOffset;
1851
- 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);
1852
1858
  const value = `${current.slice(0, start)}${marker}${current.slice(end)}`;
1853
1859
  pendingPastedTexts.current.push({
1854
1860
  id,
1855
1861
  marker,
1856
- path: staged.path,
1862
+ text: staged.text,
1857
1863
  bytes: staged.bytes,
1858
1864
  start,
1859
1865
  end: start + marker.length,
@@ -2200,7 +2206,6 @@ export function App({
2200
2206
  answerBufRef.current = "";
2201
2207
  userTurnActiveRef.current = false;
2202
2208
  turnPromptsRef.current = [];
2203
- releasePostTurnPastedTexts("main");
2204
2209
  streamingRef.current = false;
2205
2210
  setWorking(false);
2206
2211
  // A `/goalf` interview settles into a proposal, never into goal work.
@@ -2234,12 +2239,7 @@ export function App({
2234
2239
  clearTimeout(cancelTimer.current);
2235
2240
  clearPendingImages();
2236
2241
  cleanupPendingImages();
2237
- const pastedTempFiles = [...pendingPastedTexts.current];
2238
- for (const files of postTurnPastedTexts.current.values()) pastedTempFiles.push(...files);
2239
- for (const pasted of pastedTempFiles) removePendingPastedText(pasted);
2240
2242
  pendingPastedTexts.current = [];
2241
- postTurnPastedTexts.current.clear();
2242
- cleanupPendingPastedTexts();
2243
2243
  spawnPreviewManager?.cancelAll("shutdown");
2244
2244
  },
2245
2245
  [spawnPreviewManager],
@@ -2249,19 +2249,7 @@ export function App({
2249
2249
  resetCancelArm();
2250
2250
  }, [activeAgentId, visibleBusy]);
2251
2251
 
2252
- // Release pasted-text temp files once the subagent turn that consumed them settles.
2253
2252
  useEffect(() => {
2254
- for (const agent of agents) {
2255
- const previous = previousSubagentStatus.current.get(agent.id);
2256
- previousSubagentStatus.current.set(agent.id, agent.status);
2257
- if (
2258
- (previous === "starting" || previous === "running") &&
2259
- agent.status !== "starting" &&
2260
- agent.status !== "running"
2261
- ) {
2262
- releasePostTurnPastedTexts(agent.id);
2263
- }
2264
- }
2265
2253
  // A judge that settles without calling goal_verdict has said nothing. Drop
2266
2254
  // it, or `judgeInFlight` would stay true and stall the goal for good.
2267
2255
  const judge = judgeRef.current;
@@ -2729,14 +2717,14 @@ export function App({
2729
2717
  // the running bash tool has to see the new policy on the next call.
2730
2718
  if (patch.bashOutput !== undefined) setBashOutputSettingsIfPresent(patch.bashOutput);
2731
2719
  // Session-scoped: the popup never writes the global config, which the
2732
- // 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.
2733
2721
  const overrides = sessionSettingsDiff(globalSettingsRef.current, next);
2734
2722
  sessionOverridesRef.current = overrides;
2735
2723
  setSessionOverrides(overrides);
2736
2724
  saveSessionSettings(session.sessionFile, overrides);
2737
2725
  };
2738
2726
 
2739
- /** 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. */
2740
2728
  const promoteSessionSettings = () => {
2741
2729
  const next = settingsRef.current;
2742
2730
  globalSettingsRef.current = next;
@@ -3525,7 +3513,7 @@ export function App({
3525
3513
  /**
3526
3514
  * Session scope by default, exactly like the popup. `--global` also rewrites
3527
3515
  * that one key in pum.json, so the rest of the global file and this session's
3528
- * other overrides are left alone — only `s` in the popup promotes everything.
3516
+ * other overrides are left alone — popup `s` and /store promote everything.
3529
3517
  */
3530
3518
  const applySettingsPatch = (patch: Partial<PumSettings>, global: boolean) => {
3531
3519
  if (global) {
@@ -3594,20 +3582,27 @@ export function App({
3594
3582
  const runCommand = (text: string): boolean => {
3595
3583
  const trimmed = text.trim();
3596
3584
  if (runGoalCommand(trimmed)) return true;
3585
+ if (trimmed === "/s" || trimmed === "/store") {
3586
+ setEditorText("");
3587
+ promoteSessionSettings();
3588
+ return true;
3589
+ }
3597
3590
  const compress = /^\/compress(?:\s+(.*))?$/s.exec(trimmed);
3598
3591
  const clear = /^\/(?:clear|new)$/.test(trimmed);
3599
- const historyCommand = trimmed === "/history";
3592
+ const historyCommand = trimmed === "/history" || trimmed === "/resume";
3600
3593
  const loginCommand = trimmed === "/login";
3601
3594
  const providersCommand = parseProvidersCommand(trimmed);
3602
3595
  const checkPathCommand = /^\/check-path(?:\s|$)/.test(trimmed);
3603
3596
  const settingsCommand = /^\/settings(?:\s|$)/.test(trimmed);
3597
+ const modelCommand = /^\/model(?:\s|$)/.test(trimmed);
3598
+ const effortCommand = /^\/effort(?:\s|$)/.test(trimmed);
3604
3599
  const triggersCommand = trimmed === "/triggers";
3605
3600
  const processesCommand = trimmed === "/processes";
3606
3601
  const newsCommand = trimmed === "/news";
3607
3602
  const todoCommand = trimmed === "/todo";
3608
3603
  const statsCommand = trimmed === "/stats";
3609
3604
  const worktreeCommand = parseWorktreeCommand(trimmed);
3610
- 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;
3611
3606
  setEditingStash(null);
3612
3607
 
3613
3608
  if (historyCommand) {
@@ -3615,6 +3610,16 @@ export function App({
3615
3610
  openHistory();
3616
3611
  return true;
3617
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
+ }
3618
3623
  if (loginCommand) {
3619
3624
  setEditorText("");
3620
3625
  openLogin();
@@ -3685,6 +3690,25 @@ export function App({
3685
3690
  })
3686
3691
  .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
3687
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));
3688
3712
  } else if (settingsCommand) {
3689
3713
  runSettingsCommand(trimmed)
3690
3714
  .catch((err) => append({ kind: "text", role: "error", text: String(err) }))
@@ -4249,21 +4273,18 @@ export function App({
4249
4273
  const targetKey = selectedAgentId ?? "main";
4250
4274
  const rawDisplayText = value ?? inputRef.current?.plainText ?? "";
4251
4275
  const commandEligible = isCommandInput(rawDisplayText);
4252
- const displayText = rawDisplayText.trim();
4253
4276
  const attachments = value === undefined ? [...pendingImages.current] : [];
4254
4277
  const pastedTexts = value === undefined ? [...pendingPastedTexts.current] : [];
4255
4278
  const submittedEditingIndex = editingStashIndex.current;
4256
- let promptText = rawDisplayText;
4257
- for (const image of attachments) promptText = promptText.replace(image.marker, "");
4258
- for (const pasted of pastedTexts) {
4259
- promptText = promptText.replace(pasted.marker, pastedTextReadBlock(pasted));
4260
- }
4261
- promptText = promptText.trim();
4262
-
4263
- // History and stash keep the draft form, never the ephemeral temp path.
4264
- let persistedPrompt = rawDisplayText;
4265
- for (const pasted of pastedTexts) persistedPrompt = persistedPrompt.replace(pasted.marker, "");
4266
- 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;
4267
4288
 
4268
4289
  if (!promptText && attachments.length === 0 && pastedTexts.length === 0) return;
4269
4290
 
@@ -4306,17 +4327,13 @@ export function App({
4306
4327
  return;
4307
4328
  }
4308
4329
 
4309
- // Detach files from editor cleanup until delivery succeeds. A failed send
4330
+ // Detach attachments from editor cleanup until delivery succeeds. A failed send
4310
4331
  // can then restore the exact draft and each still-valid attachment marker.
4311
4332
  if (value === undefined) {
4312
4333
  pendingImages.current = [];
4313
4334
  nextImageId.current = 1;
4314
4335
  pendingPastedTexts.current = [];
4315
4336
  nextPastedTextId.current = 1;
4316
- if (pastedTexts.length > 0) {
4317
- const existing = postTurnPastedTexts.current.get(targetKey) ?? [];
4318
- postTurnPastedTexts.current.set(targetKey, [...existing, ...pastedTexts]);
4319
- }
4320
4337
  }
4321
4338
 
4322
4339
  setEditorText("");
@@ -4325,18 +4342,11 @@ export function App({
4325
4342
  for (const image of attachments) removePendingImage(image);
4326
4343
  };
4327
4344
  const restoreFailedDraft = () => {
4328
- const postTurn = postTurnPastedTexts.current.get(targetKey) ?? [];
4329
- const submittedPaths = new Set(pastedTexts.map((pasted) => pasted.path));
4330
- const remaining = postTurn.filter((pasted) => !submittedPaths.has(pasted.path));
4331
- if (remaining.length > 0) postTurnPastedTexts.current.set(targetKey, remaining);
4332
- else postTurnPastedTexts.current.delete(targetKey);
4333
-
4334
4345
  const canRestore =
4335
4346
  activeAgentIdRef.current === selectedAgentId &&
4336
4347
  !(inputRef.current?.plainText ?? "");
4337
4348
  if (!canRestore) {
4338
4349
  finishAttachments();
4339
- for (const pasted of pastedTexts) removePendingPastedText(pasted);
4340
4350
  append({
4341
4351
  kind: "text",
4342
4352
  role: "error",
@@ -4368,6 +4378,13 @@ export function App({
4368
4378
  return;
4369
4379
  }
4370
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
+
4371
4388
  // /background belongs to the selected transcript, unlike the main-only
4372
4389
  // command router below, and must never become an ordinary child message.
4373
4390
  if (
@@ -4433,7 +4450,7 @@ export function App({
4433
4450
  };
4434
4451
 
4435
4452
  const submitShellCommand = () => {
4436
- const command = inputRef.current?.plainText ?? "";
4453
+ const command = expandPastedTexts(inputRef.current?.plainText ?? "", pendingPastedTexts.current);
4437
4454
  if (!command.trim()) return;
4438
4455
  const selectedAgentId = activeAgentIdRef.current;
4439
4456
  setEditorText("");
@@ -4666,24 +4683,24 @@ export function App({
4666
4683
  ? " (no truecolor)"
4667
4684
  : "";
4668
4685
  const rowValues: Record<SettingRowId, string> = {
4669
- theme: `‹ ${theme.name} ›`,
4670
- providers: "login and custom setup",
4671
- animations: `‹ ${settings.animations ? "on" : "off"} ›`,
4672
- workingRuleAnimation: `‹ ${WORKING_RULE_ANIMATION_LABELS[settings.workingRuleAnimation]} ›${settings.workingRuleAnimation === "off" ? "" : animationUnavailable}`,
4673
- outputMode: `‹ ${OUTPUT_MODE_LABELS[settings.outputMode ?? "normal"]} ›`,
4674
- showAgentMessages: `‹ ${settings.showAgentMessages === false ? "off" : "on"} ›`,
4675
- webSearch: `‹ ${settings.webSearch ? "on" : "off"} ›${searchProviders.length ? "" : " (not on provider)"}`,
4676
- writingStyle: `‹ ${settings.writingStyle} ›`,
4677
- explanationStrength: `‹ ${settings.explanationStrength} ›`,
4678
- checkMode: `‹ ${settings.checkMode} ›`,
4679
- sandboxMode: `‹ ${settings.sandboxMode ?? "auto"} ›`,
4680
- checkModel: `${settings.checkModel} ›`,
4681
- checkPaths: `${checkPathsForProject(settings, cwd).length} additional · /check-path ›`,
4682
- thinkingLevel: `‹ ${thinkingLevel} ›`,
4683
- showThinking: `‹ ${settings.showThinking ? "on" : "off"} ›`,
4684
- maxActiveSubagents: `‹ ${settings.maxActiveSubagents} ›`,
4685
- goalRetryLimit: `‹ ${normalizeGoalRetryLimit(settings.goalRetryLimit) || "unlimited"} ›`,
4686
- 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}`,
4687
4704
  };
4688
4705
 
4689
4706
  const updateSettingsQuery = (query: string) => {
@@ -4897,18 +4914,6 @@ export function App({
4897
4914
  // the life of the app. The ref carries the current closure behind it.
4898
4915
  clickTranscriptDisclosureRef.current = clickTranscriptDisclosure;
4899
4916
 
4900
- const copyTranscriptRow = () => {
4901
- const line = visibleLines[transcriptCursorRef.current];
4902
- if (!line) return;
4903
- copyTranscriptText(projectedLineRawText(line), {
4904
- osc52: (value) => renderer.copyToClipboardOSC52(value),
4905
- }).catch((error) => append({
4906
- kind: "text",
4907
- role: "error",
4908
- text: `copy failed: ${String(error)}`,
4909
- }));
4910
- };
4911
-
4912
4917
  usePaste((event) => {
4913
4918
  const controller = loginControllerRef.current;
4914
4919
  if (loginOpen) {
@@ -4922,6 +4927,12 @@ export function App({
4922
4927
  });
4923
4928
 
4924
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
+ : "";
4925
4936
  if (spawnPreview) {
4926
4937
  const isPreviewReturn = ["return", "enter", "kpenter", "linefeed"].includes(key.name);
4927
4938
  if (key.name === "escape" || (key.ctrl && key.name === "c")) {
@@ -5095,13 +5106,12 @@ export function App({
5095
5106
  return;
5096
5107
  }
5097
5108
 
5098
- if (transcriptFocusedRef.current) {
5109
+ if (transcriptFocusedRef.current && !typedText) {
5099
5110
  key.stopPropagation();
5100
5111
  if (key.name === "escape") setTranscriptFocus(false);
5101
- else if (key.name === "j" || key.name === "down") moveTranscriptCursor(1);
5102
- 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);
5103
5114
  else if (["return", "enter", "kpenter", "linefeed"].includes(key.name)) toggleTranscriptDetail();
5104
- else if (key.name === "c" && !key.ctrl && !key.meta && !key.option) copyTranscriptRow();
5105
5115
  return;
5106
5116
  }
5107
5117
 
@@ -5277,6 +5287,18 @@ export function App({
5277
5287
  return;
5278
5288
  }
5279
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
+
5280
5302
  // `?` on an empty prompt opens help instead of typing a question mark.
5281
5303
  // With text already in the line it is just a character.
5282
5304
  if (key.sequence === "?" && !settingsOpen && !inputRef.current?.plainText) {
@@ -5451,7 +5473,10 @@ export function App({
5451
5473
  : providersCompletions(inputValue, inputCursor, managedProvidersRef.current);
5452
5474
  const settingsMatches = argumentMatchesOff
5453
5475
  ? []
5454
- : settingsCompletions(inputValue, inputCursor, cwd);
5476
+ : [
5477
+ ...settingsCompletions(inputValue, inputCursor, cwd),
5478
+ ...modelCommandCompletions(inputValue, inputCursor, modelRuntime.getAvailableSnapshot(), session.agent.state.model),
5479
+ ];
5455
5480
  // Every source carries path-completion offsets, so one insert path serves all.
5456
5481
  const closedSetMatches = providersMatches.length > 0 ? providersMatches : settingsMatches;
5457
5482
  const argumentMatches = closedSetMatches.length > 0 ? closedSetMatches : pathMatches;
@@ -5786,6 +5811,7 @@ export function App({
5786
5811
  settingsOpenRef.current = true;
5787
5812
  setSettingsOpen(true);
5788
5813
  }
5814
+ if (recoverTypedInput) inputRef.current?.insertText(typedText);
5789
5815
  });
5790
5816
 
5791
5817
  const lastLine = visibleLines[visibleLines.length - 1];
@@ -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
+ }
package/src/commands.ts CHANGED
@@ -37,6 +37,10 @@ export const COMMANDS: Command[] = [
37
37
  name: "/history",
38
38
  description: "Browse saved sessions for this directory",
39
39
  },
40
+ {
41
+ name: "/resume",
42
+ description: "Alias for /history",
43
+ },
40
44
  {
41
45
  name: "/login",
42
46
  description: "Add or update a provider login",
@@ -57,6 +61,22 @@ export const COMMANDS: Command[] = [
57
61
  name: "/stats",
58
62
  description: "Show session statistics",
59
63
  },
64
+ {
65
+ name: "/model",
66
+ description: "Choose a main-agent model: /model <name or provider/id> [effort]",
67
+ },
68
+ {
69
+ name: "/store",
70
+ description: "Save current settings as global defaults (same as Settings s)",
71
+ },
72
+ {
73
+ name: "/s",
74
+ description: "Alias for /store: save current settings globally",
75
+ },
76
+ {
77
+ name: "/effort",
78
+ description: "Show or change main-agent reasoning effort: /effort [level]",
79
+ },
60
80
  {
61
81
  name: "/settings",
62
82
  description: "Show or change a setting: /settings <name> <value> [--global]",
@@ -92,7 +112,10 @@ export function matchingCommands(input: string): Command[] {
92
112
  // an absolute path. Leaving it to prefix matching would let Tab on /u turn
93
113
  // /usr/lib into /new.
94
114
  if (/[/\\]/.test(input.slice(1))) return [];
95
- return COMMANDS.filter((command) => command.name.startsWith(input));
115
+ const matches = COMMANDS.filter((command) => command.name.startsWith(input));
116
+ // A complete short alias such as /s must not execute /stats on Enter.
117
+ const exact = matches.find((command) => command.name === input);
118
+ return exact ? [exact, ...matches.filter((command) => command !== exact)] : matches;
96
119
  }
97
120
 
98
121
  export function matchingCommandsForTarget(