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 +22 -0
- package/package.json +4 -3
- package/src/app.tsx +165 -111
- package/src/bash-tail-text.ts +15 -0
- package/src/commands.ts +24 -1
- package/src/headless.ts +15 -7
- package/src/help-popup.tsx +5 -2
- package/src/main.tsx +20 -17
- package/src/model-catalog.ts +47 -0
- 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/settings-popup.tsx +7 -1
- 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
|
@@ -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.
|
|
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.
|
|
54
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
:
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
/**
|
|
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 —
|
|
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
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
//
|
|
4242
|
-
|
|
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
|
|
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]}
|
|
4651
|
-
outputMode: `‹ ${OUTPUT_MODE_LABELS[settings.outputMode ?? "normal"]}
|
|
4652
|
-
showAgentMessages: `‹ ${settings.showAgentMessages === false ? "off" : "on"}
|
|
4653
|
-
webSearch: `‹ ${settings.webSearch ? "on" : "off"}
|
|
4654
|
-
writingStyle: `‹ ${settings.writingStyle}
|
|
4655
|
-
explanationStrength: `‹ ${settings.explanationStrength}
|
|
4656
|
-
checkMode: `‹ ${settings.checkMode}
|
|
4657
|
-
sandboxMode: `‹ ${settings.sandboxMode ?? "auto"}
|
|
4658
|
-
checkModel:
|
|
4659
|
-
checkPaths:
|
|
4660
|
-
thinkingLevel: `‹ ${thinkingLevel}
|
|
4661
|
-
showThinking: `‹ ${settings.showThinking ? "on" : "off"}
|
|
4662
|
-
maxActiveSubagents: `‹ ${settings.maxActiveSubagents}
|
|
4663
|
-
goalRetryLimit: `‹ ${normalizeGoalRetryLimit(settings.goalRetryLimit) || "unlimited"}
|
|
4664
|
-
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}`,
|
|
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 === "
|
|
5080
|
-
else if (key.name === "
|
|
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
|
-
:
|
|
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
|
+
}
|