pum-agent 0.1.0-beta.3
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/LICENSE +21 -0
- package/README.md +196 -0
- package/package.json +69 -0
- package/src/agent-selector.tsx +217 -0
- package/src/agent-usage.ts +93 -0
- package/src/animation.tsx +476 -0
- package/src/app.tsx +1953 -0
- package/src/apply-patch.ts +583 -0
- package/src/cancel-confirmation.ts +14 -0
- package/src/check-mode.ts +630 -0
- package/src/commands.ts +45 -0
- package/src/config.ts +24 -0
- package/src/explanation-strength.ts +47 -0
- package/src/git-branch.ts +54 -0
- package/src/help-popup.tsx +279 -0
- package/src/history.ts +57 -0
- package/src/image-paste.ts +204 -0
- package/src/index.tsx +133 -0
- package/src/login-controller.ts +267 -0
- package/src/login-flow.ts +170 -0
- package/src/login-popup.tsx +154 -0
- package/src/platform.ts +94 -0
- package/src/prompt-stash.ts +130 -0
- package/src/replay.ts +188 -0
- package/src/session-history-popup.tsx +68 -0
- package/src/settings-popup.tsx +283 -0
- package/src/settings.ts +81 -0
- package/src/shutdown.ts +23 -0
- package/src/stash-batch.ts +28 -0
- package/src/status-bar.tsx +143 -0
- package/src/status-metadata.ts +110 -0
- package/src/subagents/manager.ts +1196 -0
- package/src/subagents/types.ts +86 -0
- package/src/syntax.ts +60 -0
- package/src/theme.ts +346 -0
- package/src/tool-line.ts +72 -0
- package/src/transcript.tsx +393 -0
- package/src/web-search.ts +157 -0
- package/src/worktree-command.ts +39 -0
- package/src/worktree.ts +219 -0
- package/src/writing-style.ts +54 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import type { Theme } from "./theme";
|
|
5
|
+
|
|
6
|
+
/** The seven levels pi accepts. setThinkingLevel() clamps to model capability. */
|
|
7
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
8
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
9
|
+
|
|
10
|
+
export type SettingRowId =
|
|
11
|
+
| "theme"
|
|
12
|
+
| "animations"
|
|
13
|
+
| "workingRuleAnimation"
|
|
14
|
+
| "webSearch"
|
|
15
|
+
| "writingStyle"
|
|
16
|
+
| "explanationStrength"
|
|
17
|
+
| "checkMode"
|
|
18
|
+
| "checkModel"
|
|
19
|
+
| "thinkingLevel"
|
|
20
|
+
| "showThinking"
|
|
21
|
+
| "providers"
|
|
22
|
+
| "model";
|
|
23
|
+
|
|
24
|
+
export type SettingRow = {
|
|
25
|
+
id: SettingRowId;
|
|
26
|
+
label: string;
|
|
27
|
+
category: "Appearance" | "Agent" | "Safety";
|
|
28
|
+
keywords: string;
|
|
29
|
+
description: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const SETTINGS_ROWS: readonly SettingRow[] = [
|
|
33
|
+
{ id: "theme", label: "Theme", category: "Appearance", keywords: "color palette semantic", description: "Change the semantic color preset. theme.json overrides remain active." },
|
|
34
|
+
{ id: "animations", label: "Animations", category: "Appearance", keywords: "motion global truecolor", description: "Enable interface motion. PUM disables motion when true color is unavailable." },
|
|
35
|
+
{ id: "workingRuleAnimation", label: "Working animation", category: "Appearance", keywords: "rules input header coordinated off motion", description: "Choose how the header and input rules animate while an agent works." },
|
|
36
|
+
{ id: "providers", label: "Providers", category: "Agent", keywords: "login oauth api key custom endpoint", description: "Open provider login or add an OpenAI-compatible custom endpoint." },
|
|
37
|
+
{ id: "model", label: "Model", category: "Agent", keywords: "provider llm active search", description: "Select the model used by the main agent. Search matches provider and model names." },
|
|
38
|
+
{ id: "thinkingLevel", label: "Thinking level", category: "Agent", keywords: "reasoning effort clamp capability", description: "Set reasoning effort. Pi clamps the level to the selected model capability." },
|
|
39
|
+
{ id: "showThinking", label: "Show thinking", category: "Agent", keywords: "reasoning visible transcript trace", description: "Show or hide streamed reasoning traces in the transcript." },
|
|
40
|
+
{ id: "writingStyle", label: "Writing style", category: "Agent", keywords: "response prose ste simplified technical english", description: "Add per-turn response guidance. STE requests concise Simplified Technical English." },
|
|
41
|
+
{ id: "explanationStrength", label: "Explanations", category: "Agent", keywords: "progress updates output none simple detailed rationale", description: "Choose how much regular output explains the agent plan, actions, decisions, and results." },
|
|
42
|
+
{ id: "webSearch", label: "Web search", category: "Agent", keywords: "internet hosted codex provider", description: "Allow hosted web search on supported Codex providers. Other providers are unchanged." },
|
|
43
|
+
{ id: "checkMode", label: "Check mode", category: "Safety", keywords: "verify tools safe fail closed bash edit", description: "Fail-closed safety check for every bash and edit call before execution." },
|
|
44
|
+
{ id: "checkModel", label: "Check model", category: "Safety", keywords: "verifier tools safety model", description: "Select the separate verifier model used by Check mode." },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function filterSettingsRows(query: string): SettingRow[] {
|
|
48
|
+
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
49
|
+
if (terms.length === 0) return [...SETTINGS_ROWS];
|
|
50
|
+
return SETTINGS_ROWS.filter((row) => {
|
|
51
|
+
const haystack = `${row.label} ${row.category} ${row.keywords} ${row.description}`.toLocaleLowerCase();
|
|
52
|
+
return terms.every((term) => haystack.includes(term));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function filterModels(
|
|
57
|
+
models: readonly Model<any>[],
|
|
58
|
+
query: string,
|
|
59
|
+
providerName: (providerId: string) => string = () => "",
|
|
60
|
+
): Model<any>[] {
|
|
61
|
+
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
62
|
+
if (terms.length === 0) return [...models];
|
|
63
|
+
return models.filter((model) => {
|
|
64
|
+
const haystack = `${model.provider} ${providerName(model.provider)} ${model.id} ${model.name ?? ""}`.toLocaleLowerCase();
|
|
65
|
+
return terms.every((term) => haystack.includes(term));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function moveSettingSelection(
|
|
70
|
+
rows: readonly SettingRow[],
|
|
71
|
+
selectedId: SettingRowId | null,
|
|
72
|
+
step: -1 | 1,
|
|
73
|
+
): SettingRowId | null {
|
|
74
|
+
if (rows.length === 0) return null;
|
|
75
|
+
const current = rows.findIndex((row) => row.id === selectedId);
|
|
76
|
+
const start = current < 0 ? (step > 0 ? -1 : 0) : current;
|
|
77
|
+
return rows[(start + step + rows.length) % rows.length]!.id;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isModelSearchShortcut(
|
|
81
|
+
key: { name: string; sequence: string; ctrl?: boolean; meta?: boolean; option?: boolean },
|
|
82
|
+
searchFocused: boolean,
|
|
83
|
+
): boolean {
|
|
84
|
+
return !searchFocused && !key.ctrl && !key.meta && !key.option &&
|
|
85
|
+
(key.name === "/" || key.sequence === "/");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isSettingsSearchShortcut(
|
|
89
|
+
key: { name: string; sequence: string; ctrl?: boolean; meta?: boolean; option?: boolean },
|
|
90
|
+
searchFocused: boolean,
|
|
91
|
+
): boolean {
|
|
92
|
+
return !searchFocused && !key.ctrl && !key.meta && !key.option &&
|
|
93
|
+
(key.name === "/" || key.sequence === "/");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type PopupProps = {
|
|
97
|
+
theme: Theme;
|
|
98
|
+
page: "main" | "models" | "checkModels";
|
|
99
|
+
rows: readonly SettingRow[];
|
|
100
|
+
selectedId: SettingRowId | null;
|
|
101
|
+
values: Readonly<Record<SettingRowId, string>>;
|
|
102
|
+
query: string;
|
|
103
|
+
searchFocused: boolean;
|
|
104
|
+
terminalWidth: number;
|
|
105
|
+
terminalHeight: number;
|
|
106
|
+
models: readonly Model<any>[];
|
|
107
|
+
modelQuery?: string;
|
|
108
|
+
modelSearchFocused?: boolean;
|
|
109
|
+
onSearchChange: (value: string) => void;
|
|
110
|
+
onModelSearchChange?: (value: string) => void;
|
|
111
|
+
onSelectModel: (model: Model<any>) => void;
|
|
112
|
+
onSelectCheckModel: (model: Model<any>) => void;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The popup owns layout only. The keyboard handler in app.tsx owns all menu
|
|
117
|
+
* navigation and focus transitions.
|
|
118
|
+
*/
|
|
119
|
+
export function SettingsPopup({
|
|
120
|
+
theme,
|
|
121
|
+
page,
|
|
122
|
+
rows,
|
|
123
|
+
selectedId,
|
|
124
|
+
values,
|
|
125
|
+
query,
|
|
126
|
+
searchFocused,
|
|
127
|
+
terminalWidth,
|
|
128
|
+
terminalHeight,
|
|
129
|
+
models,
|
|
130
|
+
modelQuery = "",
|
|
131
|
+
modelSearchFocused = false,
|
|
132
|
+
onSearchChange,
|
|
133
|
+
onModelSearchChange = () => {},
|
|
134
|
+
onSelectModel,
|
|
135
|
+
onSelectCheckModel,
|
|
136
|
+
}: PopupProps) {
|
|
137
|
+
const listRef = useRef<ScrollBoxRenderable>(null);
|
|
138
|
+
const narrow = terminalWidth < 64;
|
|
139
|
+
const margin = narrow ? 1 : Math.max(2, Math.floor(terminalWidth * 0.1));
|
|
140
|
+
const popupWidth = Math.max(24, terminalWidth - margin * 2);
|
|
141
|
+
const popupHeight = Math.max(8, Math.min(terminalHeight - 2, page === "main" ? 20 : 18));
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (selectedId) listRef.current?.scrollChildIntoView(`setting-${selectedId}`);
|
|
145
|
+
}, [selectedId, rows.length]);
|
|
146
|
+
|
|
147
|
+
let lastCategory: SettingRow["category"] | null = null;
|
|
148
|
+
const selectedRow = rows.find((row) => row.id === selectedId);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<box
|
|
152
|
+
title={page === "main" ? " Settings " : page === "models" ? " Model " : " Check model "}
|
|
153
|
+
style={{
|
|
154
|
+
position: "absolute",
|
|
155
|
+
top: Math.max(1, Math.floor((terminalHeight - popupHeight) / 2)),
|
|
156
|
+
left: margin,
|
|
157
|
+
width: popupWidth,
|
|
158
|
+
height: popupHeight,
|
|
159
|
+
zIndex: 100,
|
|
160
|
+
border: true,
|
|
161
|
+
borderColor: theme.border,
|
|
162
|
+
backgroundColor: theme.popupBg,
|
|
163
|
+
flexDirection: "column",
|
|
164
|
+
padding: 1,
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{page === "main" ? (
|
|
168
|
+
<>
|
|
169
|
+
<box style={{ height: 1, flexShrink: 0, flexDirection: "row" }}>
|
|
170
|
+
<box style={{ width: narrow ? 7 : 9, flexShrink: 0 }}>
|
|
171
|
+
<text content="Search" fg={searchFocused ? theme.accent : theme.dim} bg={theme.popupBg} />
|
|
172
|
+
</box>
|
|
173
|
+
<input
|
|
174
|
+
value={query}
|
|
175
|
+
placeholder="type to filter"
|
|
176
|
+
placeholderColor={theme.dim}
|
|
177
|
+
textColor={theme.fg}
|
|
178
|
+
cursorColor={theme.accent}
|
|
179
|
+
focused={searchFocused}
|
|
180
|
+
onInput={onSearchChange}
|
|
181
|
+
style={{ flexGrow: 1, minWidth: 0 }}
|
|
182
|
+
/>
|
|
183
|
+
</box>
|
|
184
|
+
<box style={{ height: 1, flexShrink: 0 }}>
|
|
185
|
+
<text content={"─".repeat(Math.max(0, popupWidth - 4))} fg={theme.border} bg={theme.popupBg} />
|
|
186
|
+
</box>
|
|
187
|
+
<scrollbox
|
|
188
|
+
ref={listRef}
|
|
189
|
+
style={{ flexGrow: 1, minHeight: 1 }}
|
|
190
|
+
verticalScrollbarOptions={{ visible: true }}
|
|
191
|
+
>
|
|
192
|
+
<box style={{ flexDirection: "column", width: "100%", flexShrink: 0 }}>
|
|
193
|
+
{rows.length === 0 ? (
|
|
194
|
+
<text content="No matching settings" fg={theme.dim} bg={theme.popupBg} />
|
|
195
|
+
) : rows.map((row) => {
|
|
196
|
+
const showCategory = row.category !== lastCategory;
|
|
197
|
+
lastCategory = row.category;
|
|
198
|
+
const selected = selectedId === row.id && !searchFocused;
|
|
199
|
+
return (
|
|
200
|
+
<box key={row.id} style={{ flexDirection: "column", flexShrink: 0 }}>
|
|
201
|
+
{showCategory ? (
|
|
202
|
+
<text content={row.category} fg={theme.dim} bg={theme.popupBg} />
|
|
203
|
+
) : null}
|
|
204
|
+
<box
|
|
205
|
+
id={`setting-${row.id}`}
|
|
206
|
+
style={{ height: 1, flexShrink: 0, flexDirection: "row" }}
|
|
207
|
+
>
|
|
208
|
+
<box style={{ width: 2, flexShrink: 0 }}>
|
|
209
|
+
{selected ? <text content="› " fg={theme.accent} bg={theme.popupBg} /> : null}
|
|
210
|
+
</box>
|
|
211
|
+
<text
|
|
212
|
+
content={row.label}
|
|
213
|
+
fg={selected ? theme.accent : theme.fg}
|
|
214
|
+
bg={theme.popupBg}
|
|
215
|
+
wrapMode="none"
|
|
216
|
+
style={{ width: 18, flexShrink: 0 }}
|
|
217
|
+
/>
|
|
218
|
+
<text
|
|
219
|
+
content={values[row.id]}
|
|
220
|
+
fg={selected ? theme.accent : theme.fg}
|
|
221
|
+
bg={theme.popupBg}
|
|
222
|
+
wrapMode="none"
|
|
223
|
+
style={{ flexGrow: 1, minWidth: 0 }}
|
|
224
|
+
/>
|
|
225
|
+
</box>
|
|
226
|
+
</box>
|
|
227
|
+
);
|
|
228
|
+
})}
|
|
229
|
+
</box>
|
|
230
|
+
</scrollbox>
|
|
231
|
+
<box style={{ minHeight: 2, maxHeight: narrow ? 3 : 2, flexShrink: 0 }}>
|
|
232
|
+
<text
|
|
233
|
+
content={selectedRow?.description ?? "Type in Search to filter settings."}
|
|
234
|
+
fg={theme.dim}
|
|
235
|
+
bg={theme.popupBg}
|
|
236
|
+
wrapMode="word"
|
|
237
|
+
/>
|
|
238
|
+
</box>
|
|
239
|
+
<text
|
|
240
|
+
content={narrow ? "/ search ↑↓ move ←→ change esc back" : "/ search ↑↓ move ←→ change ⏎ open esc back"}
|
|
241
|
+
fg={theme.dim}
|
|
242
|
+
bg={theme.popupBg}
|
|
243
|
+
wrapMode="none"
|
|
244
|
+
style={{ flexShrink: 0 }}
|
|
245
|
+
/>
|
|
246
|
+
</>
|
|
247
|
+
) : (
|
|
248
|
+
<>
|
|
249
|
+
<input
|
|
250
|
+
value={modelQuery}
|
|
251
|
+
placeholder="Search provider or model"
|
|
252
|
+
placeholderColor={theme.dim}
|
|
253
|
+
textColor={theme.fg}
|
|
254
|
+
cursorColor={theme.accent}
|
|
255
|
+
focused={modelSearchFocused}
|
|
256
|
+
onInput={onModelSearchChange}
|
|
257
|
+
style={{ flexShrink: 0 }}
|
|
258
|
+
/>
|
|
259
|
+
{models.length === 0 ? <text content="No matching models" fg={theme.dim} bg={theme.popupBg} /> : <select
|
|
260
|
+
focused={!modelSearchFocused}
|
|
261
|
+
style={{ flexGrow: 1 }}
|
|
262
|
+
backgroundColor={theme.popupBg}
|
|
263
|
+
focusedBackgroundColor={theme.popupBg}
|
|
264
|
+
textColor={theme.fg}
|
|
265
|
+
focusedTextColor={theme.fg}
|
|
266
|
+
selectedBackgroundColor={theme.selectionBg}
|
|
267
|
+
selectedTextColor={theme.accent}
|
|
268
|
+
descriptionColor={theme.dim}
|
|
269
|
+
selectedDescriptionColor={theme.fg}
|
|
270
|
+
options={models.map((m) => ({ name: m.id, description: m.provider, value: m }))}
|
|
271
|
+
onSelect={(_index, option) => {
|
|
272
|
+
if (!option) return;
|
|
273
|
+
const model = option.value as Model<any>;
|
|
274
|
+
if (page === "checkModels") onSelectCheckModel(model);
|
|
275
|
+
else onSelectModel(model);
|
|
276
|
+
}}
|
|
277
|
+
/>}
|
|
278
|
+
<text content="/ search ↑↓ move enter select esc back" fg={theme.dim} bg={theme.popupBg} />
|
|
279
|
+
</>
|
|
280
|
+
)}
|
|
281
|
+
</box>
|
|
282
|
+
);
|
|
283
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { AGENT_DIR } from "./config";
|
|
4
|
+
import { DEFAULT_CHECK_MODEL } from "./check-mode";
|
|
5
|
+
import { isWritingStyle, type WritingStyle } from "./writing-style";
|
|
6
|
+
import {
|
|
7
|
+
isExplanationStrength,
|
|
8
|
+
type ExplanationStrength,
|
|
9
|
+
} from "./explanation-strength";
|
|
10
|
+
|
|
11
|
+
export const WORKING_RULE_ANIMATION_MODES = ["off", "input-only", "coordinated"] as const;
|
|
12
|
+
export type WorkingRuleAnimationMode = (typeof WORKING_RULE_ANIMATION_MODES)[number];
|
|
13
|
+
|
|
14
|
+
export function isWorkingRuleAnimationMode(value: unknown): value is WorkingRuleAnimationMode {
|
|
15
|
+
return WORKING_RULE_ANIMATION_MODES.includes(value as WorkingRuleAnimationMode);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* PUM's own settings. Model and thinking level are deliberately not here — pi
|
|
20
|
+
* already persists those to <AGENT_DIR>/settings.json via setModel() and
|
|
21
|
+
* setThinkingLevel(), and restores them when the session is created.
|
|
22
|
+
*/
|
|
23
|
+
export type PumSettings = {
|
|
24
|
+
showThinking: boolean;
|
|
25
|
+
theme: string;
|
|
26
|
+
animations: boolean;
|
|
27
|
+
/** Animation used for the rules while an agent works. */
|
|
28
|
+
workingRuleAnimation: WorkingRuleAnimationMode;
|
|
29
|
+
webSearch: boolean;
|
|
30
|
+
writingStyle: WritingStyle;
|
|
31
|
+
explanationStrength: ExplanationStrength;
|
|
32
|
+
checkMode: boolean;
|
|
33
|
+
checkModel: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const SETTINGS_PATH = join(AGENT_DIR, "pum.json");
|
|
37
|
+
const DEFAULTS: PumSettings = {
|
|
38
|
+
showThinking: false,
|
|
39
|
+
theme: "tokyonight",
|
|
40
|
+
animations: true,
|
|
41
|
+
// Preserve the rule-only behavior used before this setting existed.
|
|
42
|
+
workingRuleAnimation: "input-only",
|
|
43
|
+
webSearch: true,
|
|
44
|
+
writingStyle: "none",
|
|
45
|
+
explanationStrength: "simple",
|
|
46
|
+
checkMode: false,
|
|
47
|
+
checkModel: DEFAULT_CHECK_MODEL,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function normalizeSettings(parsed: unknown): PumSettings {
|
|
51
|
+
const source = parsed && typeof parsed === "object" ? parsed as Partial<PumSettings> : {};
|
|
52
|
+
const merged = { ...DEFAULTS, ...source };
|
|
53
|
+
return {
|
|
54
|
+
...merged,
|
|
55
|
+
animations: typeof merged.animations === "boolean" ? merged.animations : DEFAULTS.animations,
|
|
56
|
+
workingRuleAnimation: isWorkingRuleAnimationMode(merged.workingRuleAnimation)
|
|
57
|
+
? merged.workingRuleAnimation
|
|
58
|
+
: DEFAULTS.workingRuleAnimation,
|
|
59
|
+
writingStyle: isWritingStyle(merged.writingStyle) ? merged.writingStyle : DEFAULTS.writingStyle,
|
|
60
|
+
explanationStrength: isExplanationStrength(merged.explanationStrength)
|
|
61
|
+
? merged.explanationStrength
|
|
62
|
+
: DEFAULTS.explanationStrength,
|
|
63
|
+
checkMode: typeof merged.checkMode === "boolean" ? merged.checkMode : DEFAULTS.checkMode,
|
|
64
|
+
checkModel:
|
|
65
|
+
typeof merged.checkModel === "string" && merged.checkModel.includes("/")
|
|
66
|
+
? merged.checkModel
|
|
67
|
+
: DEFAULTS.checkModel,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function loadSettings(): PumSettings {
|
|
72
|
+
try {
|
|
73
|
+
return normalizeSettings(JSON.parse(readFileSync(SETTINGS_PATH, "utf8")));
|
|
74
|
+
} catch {
|
|
75
|
+
return { ...DEFAULTS };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveSettings(settings: PumSettings): void {
|
|
80
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
81
|
+
}
|
package/src/shutdown.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ShutdownActions = {
|
|
2
|
+
unmount(): void;
|
|
3
|
+
cleanup(): void;
|
|
4
|
+
dispose(): Promise<void>;
|
|
5
|
+
destroy(): void;
|
|
6
|
+
exit(code: number): void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function createShutdown(actions: ShutdownActions): (code: number) => Promise<void> {
|
|
10
|
+
let exiting = false;
|
|
11
|
+
return async (code: number): Promise<void> => {
|
|
12
|
+
if (exiting) return;
|
|
13
|
+
exiting = true;
|
|
14
|
+
try {
|
|
15
|
+
actions.unmount();
|
|
16
|
+
actions.cleanup();
|
|
17
|
+
await actions.dispose();
|
|
18
|
+
} finally {
|
|
19
|
+
actions.destroy();
|
|
20
|
+
actions.exit(code);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function selectedRange(anchor: number, cursor: number): Set<number> {
|
|
2
|
+
const start = Math.min(anchor, cursor);
|
|
3
|
+
const end = Math.max(anchor, cursor);
|
|
4
|
+
return new Set(Array.from({ length: end - start + 1 }, (_, offset) => start + offset));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildStashBatchPrompt(prompts: string[]): string {
|
|
8
|
+
const tasks = prompts.map((prompt, index) => `<task ${index + 1}>\n${prompt}\n</task ${index + 1}>`).join("\n\n");
|
|
9
|
+
return `Coordinate the following cached tasks with managed worktree subagents.
|
|
10
|
+
|
|
11
|
+
Rules:
|
|
12
|
+
- Count only starting and running subagents as active. The active limit is five.
|
|
13
|
+
- Use spawn_subagent for implementation work while fewer than five subagents are active.
|
|
14
|
+
- At five active subagents, queue related work to an appropriate running subagent with message_agent.
|
|
15
|
+
- message_agent uses the durable recipient-side message and steering queue.
|
|
16
|
+
- Do not route unrelated work to an arbitrary subagent. Keep it pending when no appropriate recipient is clear.
|
|
17
|
+
- You may group related tasks into one subagent when grouping reduces conflicts or duplicated work.
|
|
18
|
+
- Run independent task groups in parallel.
|
|
19
|
+
- Keep each subagent task complete and self-contained.
|
|
20
|
+
- Track every unfinished task group through completion notifications.
|
|
21
|
+
- Merge each successful subagent with the worktree tool as soon as it settles.
|
|
22
|
+
- Wait to merge only for a concrete dependency, known conflict risk, or required integration order. State that reason explicitly.
|
|
23
|
+
- A successful managed merge closes that subagent and removes its worktree and branch.
|
|
24
|
+
- Do not force-remove an unmerged or failed subagent. Report failures and merge conflicts.
|
|
25
|
+
|
|
26
|
+
Selected tasks:
|
|
27
|
+
${tasks}`;
|
|
28
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { StyledText, fg, type TextChunk } from "@opentui/core";
|
|
2
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
3
|
+
import { useShimmerText, useSpinner } from "./animation";
|
|
4
|
+
import {
|
|
5
|
+
statusMetadataChunks,
|
|
6
|
+
statusMetadataItems,
|
|
7
|
+
} from "./status-metadata";
|
|
8
|
+
import type { Theme } from "./theme";
|
|
9
|
+
|
|
10
|
+
export type StatusProps = {
|
|
11
|
+
theme: Theme;
|
|
12
|
+
modelId: string;
|
|
13
|
+
thinkingLevel: string;
|
|
14
|
+
branch: string | null;
|
|
15
|
+
outgoingTokens: number;
|
|
16
|
+
incomingTokens: number;
|
|
17
|
+
cacheReadTokens: number;
|
|
18
|
+
cost: number;
|
|
19
|
+
contextPct: number | null;
|
|
20
|
+
busy: boolean;
|
|
21
|
+
elapsedSec: number;
|
|
22
|
+
agentCount: number;
|
|
23
|
+
runningAgentCount: number;
|
|
24
|
+
activeAgentName?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const fmtElapsed = (seconds: number) => {
|
|
28
|
+
const minutes = Math.floor(seconds / 60);
|
|
29
|
+
const remaining = seconds % 60;
|
|
30
|
+
return minutes > 0 ? `${minutes}m ${remaining}s` : `${remaining}s`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function WorkingPulse({ theme }: { theme: Theme }) {
|
|
34
|
+
const spinner = useSpinner(true);
|
|
35
|
+
return <text ref={spinner} fg={theme.accent} />;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Working({ theme, elapsedSec }: { theme: Theme; elapsedSec: number }) {
|
|
39
|
+
const label = useShimmerText({
|
|
40
|
+
text: "working",
|
|
41
|
+
color: theme.accent,
|
|
42
|
+
highlight: theme.highlight,
|
|
43
|
+
active: true,
|
|
44
|
+
});
|
|
45
|
+
return (
|
|
46
|
+
<box style={{ flexDirection: "row" }}>
|
|
47
|
+
<WorkingPulse theme={theme} />
|
|
48
|
+
<text content=" " />
|
|
49
|
+
<text ref={label} />
|
|
50
|
+
<text content={` ${fmtElapsed(elapsedSec)} `} fg={theme.dim} />
|
|
51
|
+
</box>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function StatusBar(props: StatusProps) {
|
|
56
|
+
const {
|
|
57
|
+
theme,
|
|
58
|
+
modelId,
|
|
59
|
+
thinkingLevel,
|
|
60
|
+
branch,
|
|
61
|
+
outgoingTokens,
|
|
62
|
+
incomingTokens,
|
|
63
|
+
cacheReadTokens,
|
|
64
|
+
cost,
|
|
65
|
+
contextPct,
|
|
66
|
+
busy,
|
|
67
|
+
agentCount,
|
|
68
|
+
runningAgentCount,
|
|
69
|
+
activeAgentName,
|
|
70
|
+
} = props;
|
|
71
|
+
const { width } = useTerminalDimensions();
|
|
72
|
+
|
|
73
|
+
const left = [
|
|
74
|
+
fg(theme.accent)(" pum "),
|
|
75
|
+
fg(theme.dim)(" "),
|
|
76
|
+
fg(theme.fg)(modelId),
|
|
77
|
+
fg(theme.dim)(" · "),
|
|
78
|
+
fg(theme.dim)(thinkingLevel),
|
|
79
|
+
];
|
|
80
|
+
const idleAgentCount = Math.max(0, agentCount - runningAgentCount);
|
|
81
|
+
const agentPrefix = agentCount > 0 ? " · " : "";
|
|
82
|
+
const idleAgentText = idleAgentCount > 0 ? `◇ ${idleAgentCount}` : "";
|
|
83
|
+
const workingAgentText = runningAgentCount > 0 ? `${idleAgentCount > 0 ? " " : ""}• ${runningAgentCount}` : "";
|
|
84
|
+
const activeAgentText = activeAgentName ? ` · ${activeAgentName}` : "";
|
|
85
|
+
|
|
86
|
+
const right: TextChunk[] = statusMetadataChunks(statusMetadataItems({
|
|
87
|
+
branch,
|
|
88
|
+
outgoingTokens,
|
|
89
|
+
incomingTokens,
|
|
90
|
+
cacheReadTokens,
|
|
91
|
+
cost,
|
|
92
|
+
contextPct,
|
|
93
|
+
}), theme);
|
|
94
|
+
|
|
95
|
+
const plainLen = (chunks: { text: string }[]) => chunks.reduce((n, c) => n + c.text.length, 0);
|
|
96
|
+
// The working indicator is its own element, so allow for it when measuring.
|
|
97
|
+
const needed = plainLen(left) + agentPrefix.length + idleAgentText.length + workingAgentText.length +
|
|
98
|
+
activeAgentText.length + plainLen(right) + (busy ? 16 : 0) + 2;
|
|
99
|
+
const stacked = needed > width;
|
|
100
|
+
|
|
101
|
+
const leftRow = (
|
|
102
|
+
<box style={{ flexDirection: "row", flexGrow: 1, minWidth: 0 }}>
|
|
103
|
+
<text content={new StyledText(left)} />
|
|
104
|
+
{agentCount > 0 ? <text content={agentPrefix} fg={theme.dim} /> : null}
|
|
105
|
+
{idleAgentCount > 0 ? <text content={idleAgentText} fg={theme.success} /> : null}
|
|
106
|
+
{runningAgentCount > 0 ? (
|
|
107
|
+
<box style={{ flexDirection: "row" }}>
|
|
108
|
+
{idleAgentCount > 0 ? <text content=" " /> : null}
|
|
109
|
+
<WorkingPulse theme={theme} />
|
|
110
|
+
<text content={` ${runningAgentCount}`} fg={theme.accent} />
|
|
111
|
+
</box>
|
|
112
|
+
) : null}
|
|
113
|
+
{activeAgentName ? <text content={activeAgentText} fg={theme.dim} /> : null}
|
|
114
|
+
</box>
|
|
115
|
+
);
|
|
116
|
+
const rightRow = (
|
|
117
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
118
|
+
{busy ? <Working theme={theme} elapsedSec={props.elapsedSec} /> : null}
|
|
119
|
+
<text content={new StyledText(right)} />
|
|
120
|
+
<text content=" " />
|
|
121
|
+
</box>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
// flexShrink 0: an auto-sized box shrinks by default, and when stacked its
|
|
126
|
+
// two rows must remain between the explicit header rules in App.
|
|
127
|
+
<box style={{ flexDirection: "column", flexShrink: 0 }}>
|
|
128
|
+
{stacked ? (
|
|
129
|
+
<>
|
|
130
|
+
<box style={{ flexDirection: "row", height: 1 }}>{leftRow}</box>
|
|
131
|
+
<box style={{ flexDirection: "row", height: 1, justifyContent: "flex-end" }}>
|
|
132
|
+
{rightRow}
|
|
133
|
+
</box>
|
|
134
|
+
</>
|
|
135
|
+
) : (
|
|
136
|
+
<box style={{ flexDirection: "row", height: 1, justifyContent: "space-between" }}>
|
|
137
|
+
{leftRow}
|
|
138
|
+
{rightRow}
|
|
139
|
+
</box>
|
|
140
|
+
)}
|
|
141
|
+
</box>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { fg, type TextChunk } from "@opentui/core";
|
|
2
|
+
import type { Theme } from "./theme";
|
|
3
|
+
|
|
4
|
+
export type StatusMetadataValues = {
|
|
5
|
+
branch: string | null;
|
|
6
|
+
outgoingTokens: number;
|
|
7
|
+
incomingTokens: number;
|
|
8
|
+
cacheReadTokens: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
contextPct: number | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type StatusMetadataItem = {
|
|
14
|
+
key: "branch" | "outgoing" | "incoming" | "cacheRead" | "cost" | "context";
|
|
15
|
+
text: string;
|
|
16
|
+
tone: "branch" | "dim" | "warn";
|
|
17
|
+
priority: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const formatTokens = (value: number): string => {
|
|
21
|
+
if (value < 1000) return `${value}`;
|
|
22
|
+
if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`;
|
|
23
|
+
return `${(value / 1_000_000).toFixed(1)}m`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const formatCost = (value: number): string =>
|
|
27
|
+
`$${value < 1 ? value.toFixed(3) : value.toFixed(2)}`;
|
|
28
|
+
|
|
29
|
+
export function statusMetadataItems(values: StatusMetadataValues): StatusMetadataItem[] {
|
|
30
|
+
const items: StatusMetadataItem[] = [];
|
|
31
|
+
if (values.branch) {
|
|
32
|
+
items.push({ key: "branch", text: values.branch, tone: "branch", priority: 90 });
|
|
33
|
+
}
|
|
34
|
+
if (values.outgoingTokens) {
|
|
35
|
+
items.push({
|
|
36
|
+
key: "outgoing",
|
|
37
|
+
text: `↑ ${formatTokens(values.outgoingTokens)}`,
|
|
38
|
+
tone: "dim",
|
|
39
|
+
priority: 80,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (values.incomingTokens) {
|
|
43
|
+
items.push({
|
|
44
|
+
key: "incoming",
|
|
45
|
+
text: `↓ ${formatTokens(values.incomingTokens)}`,
|
|
46
|
+
tone: "dim",
|
|
47
|
+
priority: 70,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (values.cacheReadTokens) {
|
|
51
|
+
items.push({
|
|
52
|
+
key: "cacheRead",
|
|
53
|
+
text: `↺ ${formatTokens(values.cacheReadTokens)}`,
|
|
54
|
+
tone: "dim",
|
|
55
|
+
priority: 50,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (values.cost) {
|
|
59
|
+
items.push({ key: "cost", text: formatCost(values.cost), tone: "dim", priority: 60 });
|
|
60
|
+
}
|
|
61
|
+
if (values.contextPct !== null) {
|
|
62
|
+
items.push({
|
|
63
|
+
key: "context",
|
|
64
|
+
text: `${values.contextPct}%`,
|
|
65
|
+
tone: values.contextPct > 75 ? "warn" : "dim",
|
|
66
|
+
priority: 100,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return items;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function statusMetadataWidth(items: readonly StatusMetadataItem[]): number {
|
|
73
|
+
return items.reduce((width, item, index) => width + item.text.length + (index ? 3 : 0), 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Keep the highest-priority values that fit, then restore StatusBar display order. */
|
|
77
|
+
export function fitStatusMetadata(
|
|
78
|
+
items: readonly StatusMetadataItem[],
|
|
79
|
+
maxWidth: number,
|
|
80
|
+
): StatusMetadataItem[] {
|
|
81
|
+
if (maxWidth <= 0) return [];
|
|
82
|
+
if (statusMetadataWidth(items) <= maxWidth) return [...items];
|
|
83
|
+
|
|
84
|
+
const selected = new Set<StatusMetadataItem>();
|
|
85
|
+
let used = 0;
|
|
86
|
+
for (const item of [...items].sort((a, b) => b.priority - a.priority)) {
|
|
87
|
+
const added = item.text.length + (selected.size ? 3 : 0);
|
|
88
|
+
if (used + added > maxWidth) continue;
|
|
89
|
+
selected.add(item);
|
|
90
|
+
used += added;
|
|
91
|
+
}
|
|
92
|
+
return items.filter((item) => selected.has(item));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function statusMetadataChunks(
|
|
96
|
+
items: readonly StatusMetadataItem[],
|
|
97
|
+
theme: Theme,
|
|
98
|
+
): TextChunk[] {
|
|
99
|
+
const chunks: TextChunk[] = [];
|
|
100
|
+
for (const item of items) {
|
|
101
|
+
if (chunks.length) chunks.push(fg(theme.dim)(" · "));
|
|
102
|
+
const color = item.tone === "branch"
|
|
103
|
+
? theme.toolArg
|
|
104
|
+
: item.tone === "warn"
|
|
105
|
+
? theme.warn
|
|
106
|
+
: theme.dim;
|
|
107
|
+
chunks.push(fg(color)(item.text));
|
|
108
|
+
}
|
|
109
|
+
return chunks;
|
|
110
|
+
}
|