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 +18 -0
- package/package.json +1 -1
- package/src/app.tsx +136 -110
- package/src/bash-tail-text.ts +15 -0
- package/src/commands.ts +24 -1
- package/src/headless.ts +13 -7
- package/src/help-popup.tsx +5 -2
- package/src/main.tsx +18 -17
- package/src/model-command.ts +80 -0
- package/src/pasted-text.ts +19 -65
- package/src/session-lock-runtime.ts +63 -0
- package/src/session-lock.ts +137 -0
- package/src/session-resume-alias.ts +2 -7
- package/src/status-bar.tsx +16 -16
- package/src/subagents/fork-session.ts +5 -1
- package/src/subagents/manager.ts +28 -1
- package/src/transcript.tsx +65 -26
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
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
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
|
-
:
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
1851
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
/**
|
|
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 —
|
|
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
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
//
|
|
4264
|
-
|
|
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
|
|
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]}
|
|
4673
|
-
outputMode: `‹ ${OUTPUT_MODE_LABELS[settings.outputMode ?? "normal"]}
|
|
4674
|
-
showAgentMessages: `‹ ${settings.showAgentMessages === false ? "off" : "on"}
|
|
4675
|
-
webSearch: `‹ ${settings.webSearch ? "on" : "off"}
|
|
4676
|
-
writingStyle: `‹ ${settings.writingStyle}
|
|
4677
|
-
explanationStrength: `‹ ${settings.explanationStrength}
|
|
4678
|
-
checkMode: `‹ ${settings.checkMode}
|
|
4679
|
-
sandboxMode: `‹ ${settings.sandboxMode ?? "auto"}
|
|
4680
|
-
checkModel:
|
|
4681
|
-
checkPaths:
|
|
4682
|
-
thinkingLevel: `‹ ${thinkingLevel}
|
|
4683
|
-
showThinking: `‹ ${settings.showThinking ? "on" : "off"}
|
|
4684
|
-
maxActiveSubagents: `‹ ${settings.maxActiveSubagents}
|
|
4685
|
-
goalRetryLimit: `‹ ${normalizeGoalRetryLimit(settings.goalRetryLimit) || "unlimited"}
|
|
4686
|
-
model:
|
|
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 === "
|
|
5102
|
-
else if (key.name === "
|
|
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
|
-
:
|
|
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
|
-
|
|
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(
|