pum-agent 0.2.8-beta.1 → 0.2.9-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 +14 -1
- package/package.json +1 -1
- package/src/app.tsx +67 -19
- package/src/clipboard.ts +17 -1
- package/src/commands.ts +4 -0
- package/src/help-popup.tsx +1 -0
- package/src/news-popup.tsx +41 -33
- package/src/news.ts +18 -1
- package/src/settings.ts +7 -2
- package/src/status-bar.tsx +11 -2
package/README.md
CHANGED
|
@@ -140,6 +140,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
|
|
|
140
140
|
| `Ctrl+L` | Open the agent transcript selector |
|
|
141
141
|
| `Shift+Tab` / `Ctrl+Shift+Tab` | Cycle through agent transcripts |
|
|
142
142
|
| `Ctrl+H` | Open session history when the terminal reports the key distinctly |
|
|
143
|
+
| `Ctrl+N` | Open recent answers (News) |
|
|
143
144
|
| `Ctrl+End` | Scroll to the end of the selected transcript |
|
|
144
145
|
| `Ctrl+P` | Open settings |
|
|
145
146
|
| `Ctrl+T` | Open supervised external triggers |
|
|
@@ -147,7 +148,7 @@ Set `PUM_DIR` to override PUM's complete configuration and data directory. Run `
|
|
|
147
148
|
| `Ctrl+C` | Clear the selected non-empty draft; on an empty draft, press twice to quit |
|
|
148
149
|
| `?` | Show all controls when the prompt is empty |
|
|
149
150
|
|
|
150
|
-
Useful commands include `/login`, `/history`, `/triggers`, `/check-path`, `/clear`, `/compress`, and `/worktree`.
|
|
151
|
+
Useful commands include `/login`, `/history`, `/news`, `/triggers`, `/check-path`, `/clear`, `/compress`, and `/worktree`.
|
|
151
152
|
|
|
152
153
|
### Copy transcript text
|
|
153
154
|
|
|
@@ -171,6 +172,18 @@ Reload the `tmux` configuration after this change. Use the terminal's Shift-drag
|
|
|
171
172
|
|
|
172
173
|
PUM limits remote OSC 52 payloads to 100,000 Base64 characters. This limit prevents large selections from corrupting terminal output.
|
|
173
174
|
|
|
175
|
+
### Recent answers (News)
|
|
176
|
+
|
|
177
|
+
Open the News popup with `Ctrl+N` or `/news`. It lists the final answers of user-initiated turns, newest first. Each entry shows the user prompt and any follow-up steers that produced the answer, above the answer itself.
|
|
178
|
+
|
|
179
|
+
- `←` / `→` — move between answers
|
|
180
|
+
- `Space` — toggle an answer between read and unread
|
|
181
|
+
- `c` — copy the current answer to the clipboard
|
|
182
|
+
- `Enter` — reply to the current answer with a quoted draft
|
|
183
|
+
- `Esc` — close the popup
|
|
184
|
+
|
|
185
|
+
PUM marks an answer read automatically only when a new user prompt follows it directly in the transcript. If anything else appears between the answer and the next prompt — a subagent message, a trigger event, a queued message, or an in-progress stream — the answer stays unread.
|
|
186
|
+
|
|
174
187
|
## Parallel subagents
|
|
175
188
|
|
|
176
189
|
PUM runs up to 10 active subagents by default. Configure a limit from 1 through 25 in Settings. Only starting and running agents count toward the limit. Each subagent has these resources:
|
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
10
10
|
import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
|
|
11
11
|
import type { AgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { AnimationProvider, supportsTrueColor, useWorkingRule, type WorkingRuleRole } from "./animation";
|
|
14
14
|
import {
|
|
15
15
|
filterModels,
|
|
@@ -135,6 +135,7 @@ import {
|
|
|
135
135
|
} from "./triggers/popup";
|
|
136
136
|
import type { TerminalTitleController } from "./terminal-title";
|
|
137
137
|
import { readClipboardText } from "./text-paste";
|
|
138
|
+
import { copyTextToClipboard } from "./clipboard";
|
|
138
139
|
import { NewsPopup } from "./news-popup";
|
|
139
140
|
import {
|
|
140
141
|
NEWS_CAPACITY,
|
|
@@ -142,6 +143,7 @@ import {
|
|
|
142
143
|
saveNewsItems,
|
|
143
144
|
tagNewsLines,
|
|
144
145
|
type NewsItem,
|
|
146
|
+
type NewsPrompt,
|
|
145
147
|
} from "./news";
|
|
146
148
|
|
|
147
149
|
type Stream = { kind: "assistant" | "thinking"; text: string } | null;
|
|
@@ -387,6 +389,7 @@ export function App({
|
|
|
387
389
|
captureImage = captureClipboardImage,
|
|
388
390
|
readPastedText = readClipboardText,
|
|
389
391
|
stagePastedText = stagePastedTextDefault,
|
|
392
|
+
copyNewsAnswerText = copyTextToClipboard,
|
|
390
393
|
onExit = () => process.exit(0),
|
|
391
394
|
checkApprovalCoordinator,
|
|
392
395
|
checkApprovalStore,
|
|
@@ -415,6 +418,8 @@ export function App({
|
|
|
415
418
|
readPastedText?: typeof readClipboardText;
|
|
416
419
|
/** Store oversized pasted text in a temp file and show a marker in its place. */
|
|
417
420
|
stagePastedText?: typeof stagePastedTextDefault;
|
|
421
|
+
/** Copies the selected news answer for the popup. */
|
|
422
|
+
copyNewsAnswerText?: typeof copyTextToClipboard;
|
|
418
423
|
onExit?: () => void | Promise<void>;
|
|
419
424
|
checkApprovalCoordinator?: CheckApprovalCoordinator;
|
|
420
425
|
checkApprovalStore?: CheckApprovalStore;
|
|
@@ -428,19 +433,36 @@ export function App({
|
|
|
428
433
|
}) {
|
|
429
434
|
const cwd = process.cwd();
|
|
430
435
|
const [session, setSession] = useState(initialSession);
|
|
431
|
-
|
|
436
|
+
// Load the news companion file exactly once on mount. The transcript
|
|
437
|
+
// initializer reads these items from the ref instead of re-reading the file.
|
|
438
|
+
const newsRef = useRef<NewsItem[]>([]);
|
|
439
|
+
const [news, setNews] = useState<NewsItem[]>(() => {
|
|
440
|
+
const items = loadNewsItems(initialSession.sessionFile);
|
|
441
|
+
newsRef.current = items;
|
|
442
|
+
return items;
|
|
443
|
+
});
|
|
444
|
+
const [tx, setTx] = useState<Transcript>(() => {
|
|
432
445
|
// A resumed session already holds messages; show them instead of a blank pane.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
446
|
+
const replayedLines = replayEntries(
|
|
447
|
+
initialSession.sessionManager.buildContextEntries(),
|
|
448
|
+
cwd,
|
|
449
|
+
initial.showThinking,
|
|
450
|
+
);
|
|
451
|
+
return {
|
|
452
|
+
lines: [
|
|
453
|
+
...tagNewsLines(replayedLines, newsRef.current),
|
|
454
|
+
...startupWarnings.map((text): Line => ({ kind: "text", role: "system", text })),
|
|
455
|
+
],
|
|
456
|
+
stream: null,
|
|
457
|
+
pending: [],
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
const txRef = useRef<Transcript>({ lines: [], stream: null, pending: [] });
|
|
461
|
+
// Layout effect: commit may hand control to a user handler before paint, so
|
|
462
|
+
// the mirror must update synchronously to avoid a stale transcript read.
|
|
463
|
+
useLayoutEffect(() => {
|
|
464
|
+
txRef.current = tx;
|
|
465
|
+
}, [tx]);
|
|
444
466
|
const [busy, setBusy] = useState(false);
|
|
445
467
|
const [quitArmed, setQuitArmed] = useState(false);
|
|
446
468
|
const [cancelArmed, setCancelArmed] = useState(false);
|
|
@@ -494,12 +516,6 @@ export function App({
|
|
|
494
516
|
const [triggerCursor, setTriggerCursor] = useState(0);
|
|
495
517
|
const [, setTriggerRevision] = useState(0);
|
|
496
518
|
|
|
497
|
-
const newsRef = useRef<NewsItem[]>([]);
|
|
498
|
-
const [news, setNews] = useState<NewsItem[]>(() => {
|
|
499
|
-
const items = loadNewsItems(initialSession.sessionFile);
|
|
500
|
-
newsRef.current = items;
|
|
501
|
-
return items;
|
|
502
|
-
});
|
|
503
519
|
const [newsOpen, setNewsOpen] = useState(false);
|
|
504
520
|
const newsOpenRef = useRef(false);
|
|
505
521
|
const [newsCursor, setNewsCursor] = useState(0);
|
|
@@ -604,6 +620,8 @@ export function App({
|
|
|
604
620
|
const userTurnActiveRef = useRef(false);
|
|
605
621
|
/** Text of the final assistant message; reset at each assistant message start. */
|
|
606
622
|
const answerBufRef = useRef("");
|
|
623
|
+
/** User prompt and steer texts of the running main turn, oldest first. */
|
|
624
|
+
const turnPromptsRef = useRef<NewsPrompt[]>([]);
|
|
607
625
|
const resetQuitArm = () => {
|
|
608
626
|
lastQuitPress.current = 0;
|
|
609
627
|
clearTimeout(quitTimer.current);
|
|
@@ -1172,6 +1190,7 @@ export function App({
|
|
|
1172
1190
|
at: Date.now(),
|
|
1173
1191
|
read: false,
|
|
1174
1192
|
answered: false,
|
|
1193
|
+
prompts: turnPromptsRef.current,
|
|
1175
1194
|
};
|
|
1176
1195
|
const nextNews = [item, ...newsRef.current].slice(0, NEWS_CAPACITY);
|
|
1177
1196
|
newsRef.current = nextNews;
|
|
@@ -1193,6 +1212,7 @@ export function App({
|
|
|
1193
1212
|
}
|
|
1194
1213
|
answerBufRef.current = "";
|
|
1195
1214
|
userTurnActiveRef.current = false;
|
|
1215
|
+
turnPromptsRef.current = [];
|
|
1196
1216
|
releasePostTurnPastedTexts("main");
|
|
1197
1217
|
setWorking(false);
|
|
1198
1218
|
break;
|
|
@@ -1481,8 +1501,24 @@ export function App({
|
|
|
1481
1501
|
};
|
|
1482
1502
|
|
|
1483
1503
|
const markNewestNewsAnswered = () => {
|
|
1504
|
+
// When a resumed answer's text no longer matches a replayed line (for
|
|
1505
|
+
// example after compaction or regeneration), the line cannot be tagged
|
|
1506
|
+
// with its news id, so a direct prompt will not mark it read. This is
|
|
1507
|
+
// intentional, not a bug.
|
|
1484
1508
|
const first = newsRef.current[0];
|
|
1485
1509
|
if (!first || (first.read && first.answered)) return;
|
|
1510
|
+
const current = txRef.current;
|
|
1511
|
+
const last = current.lines[current.lines.length - 1];
|
|
1512
|
+
// Mark read only when the new user prompt lands directly after the newest
|
|
1513
|
+
// answer. Any interleaved line (agent message, trigger event, queued
|
|
1514
|
+
// message, or an in-progress stream) means the prompt is not a direct reply.
|
|
1515
|
+
const directReply =
|
|
1516
|
+
current.stream === null &&
|
|
1517
|
+
current.pending.length === 0 &&
|
|
1518
|
+
last?.kind === "text" &&
|
|
1519
|
+
last.role === "assistant" &&
|
|
1520
|
+
last.newsId === first.id;
|
|
1521
|
+
if (!directReply) return;
|
|
1486
1522
|
commitNews(
|
|
1487
1523
|
newsRef.current.map((entry, index) =>
|
|
1488
1524
|
index === 0 ? { ...entry, read: true, answered: true } : entry,
|
|
@@ -1513,6 +1549,16 @@ export function App({
|
|
|
1513
1549
|
});
|
|
1514
1550
|
};
|
|
1515
1551
|
|
|
1552
|
+
const copyNewsAnswer = () => {
|
|
1553
|
+
const item = newsRef.current[newsCursorRef.current];
|
|
1554
|
+
if (!item) return;
|
|
1555
|
+
copyNewsAnswerText(item.text, {
|
|
1556
|
+
osc52: (value) => renderer.copyToClipboardOSC52(value),
|
|
1557
|
+
}).catch((error) =>
|
|
1558
|
+
append({ kind: "text", role: "error", text: `copy failed: ${String(error)}` }),
|
|
1559
|
+
);
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1516
1562
|
const selectHistorySession = (path: string) => {
|
|
1517
1563
|
setHistoryOpen(false);
|
|
1518
1564
|
if (path === session.sessionFile) {
|
|
@@ -1718,6 +1764,7 @@ export function App({
|
|
|
1718
1764
|
text: displayText.trim(),
|
|
1719
1765
|
};
|
|
1720
1766
|
|
|
1767
|
+
turnPromptsRef.current.push({ text: displayText.trim(), steer: busyRef.current });
|
|
1721
1768
|
// Working already: keep the steering message pending at the transcript
|
|
1722
1769
|
// bottom until pi emits message_start for its actual insertion.
|
|
1723
1770
|
if (busyRef.current) {
|
|
@@ -2320,6 +2367,7 @@ export function App({
|
|
|
2320
2367
|
else if (key.name === "left") moveNewsCursor(1);
|
|
2321
2368
|
else if (key.name === "right") moveNewsCursor(-1);
|
|
2322
2369
|
else if (key.name === "space" || key.sequence === " ") toggleCurrentNewsRead();
|
|
2370
|
+
else if (key.name === "c" || key.sequence === "c") void copyNewsAnswer();
|
|
2323
2371
|
else if (key.name === "return" || key.name === "enter" || key.name === "kpenter")
|
|
2324
2372
|
replyToCurrentNews();
|
|
2325
2373
|
return;
|
package/src/clipboard.ts
CHANGED
|
@@ -82,7 +82,23 @@ export async function copySelectionText(
|
|
|
82
82
|
text: string,
|
|
83
83
|
options: ClipboardCopyOptions = {},
|
|
84
84
|
): Promise<ClipboardRoute> {
|
|
85
|
-
|
|
85
|
+
return copyTextCore(text, options, "Cannot copy an empty selection");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Copy arbitrary text (a news answer, for example) through the same routes. */
|
|
89
|
+
export async function copyTextToClipboard(
|
|
90
|
+
text: string,
|
|
91
|
+
options: ClipboardCopyOptions = {},
|
|
92
|
+
): Promise<ClipboardRoute> {
|
|
93
|
+
return copyTextCore(text, options, "Cannot copy empty text");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function copyTextCore(
|
|
97
|
+
text: string,
|
|
98
|
+
options: ClipboardCopyOptions,
|
|
99
|
+
emptyMessage: string,
|
|
100
|
+
): Promise<ClipboardRoute> {
|
|
101
|
+
if (!text) throw new Error(emptyMessage);
|
|
86
102
|
|
|
87
103
|
const platform = options.platform ?? process.platform;
|
|
88
104
|
const env = options.env ?? process.env;
|
package/src/commands.ts
CHANGED
|
@@ -25,6 +25,10 @@ export const COMMANDS: Command[] = [
|
|
|
25
25
|
name: "/login",
|
|
26
26
|
description: "Add or update a provider login",
|
|
27
27
|
},
|
|
28
|
+
{
|
|
29
|
+
name: "/news",
|
|
30
|
+
description: "Open recent answers (News)",
|
|
31
|
+
},
|
|
28
32
|
{
|
|
29
33
|
name: "/check-path",
|
|
30
34
|
description: "Manage additional Check mode directory roots",
|
package/src/help-popup.tsx
CHANGED
|
@@ -26,6 +26,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|
|
26
26
|
["Alt+Enter", "Cache without sending"],
|
|
27
27
|
["Alt+V", "Attach a clipboard image"],
|
|
28
28
|
["Ctrl+Backspace", "Delete the previous word"],
|
|
29
|
+
["Ctrl+W", "Delete the previous word"],
|
|
29
30
|
["Questionnaire", "↑↓/←→ move · Enter select · Esc cancel"],
|
|
30
31
|
],
|
|
31
32
|
},
|
package/src/news-popup.tsx
CHANGED
|
@@ -3,11 +3,12 @@ import type { ScrollBoxRenderable } from "@opentui/core";
|
|
|
3
3
|
import { buildSyntaxStyle } from "./syntax";
|
|
4
4
|
import { PopupFrame } from "./popup-frame";
|
|
5
5
|
import { formatAge, type NewsItem } from "./news";
|
|
6
|
+
import { TextLine } from "./transcript";
|
|
6
7
|
import type { Theme } from "./theme";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Presentational popup for recent final answers. It owns no keyboard logic;
|
|
10
|
-
* `app.tsx` routes arrows, Space, Enter, and
|
|
11
|
+
* `app.tsx` routes arrows, Space, Enter, Esc, and C to the shared handlers.
|
|
11
12
|
*/
|
|
12
13
|
export function NewsPopup({
|
|
13
14
|
theme,
|
|
@@ -28,9 +29,9 @@ export function NewsPopup({
|
|
|
28
29
|
const marginY = terminalHeight >= 16 ? Math.max(1, Math.floor(terminalHeight * 0.07)) : 0;
|
|
29
30
|
const width = Math.max(1, terminalWidth - marginX * 2);
|
|
30
31
|
const height = Math.max(1, terminalHeight - marginY * 2);
|
|
31
|
-
// PopupFrame adds a 1-c wide border and 1-c padding per side,
|
|
32
|
-
//
|
|
33
|
-
const bodyWidth = Math.max(1, width -
|
|
32
|
+
// PopupFrame adds a 1-c wide border and 1-c padding per side, so the
|
|
33
|
+
// markdown body gets a concrete numeric width.
|
|
34
|
+
const bodyWidth = Math.max(1, width - 4);
|
|
34
35
|
const count = items.length;
|
|
35
36
|
const current = items[cursor];
|
|
36
37
|
const seen = current ? current.read : false;
|
|
@@ -61,38 +62,45 @@ export function NewsPopup({
|
|
|
61
62
|
<text content={formatAge(current.at)} fg={theme.dim} bg={theme.popupBg} wrapMode="none" />
|
|
62
63
|
</box>
|
|
63
64
|
<box style={{ height: 1, flexShrink: 0 }} />
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
minHeight: 0,
|
|
69
|
-
flexDirection: "row",
|
|
70
|
-
}}
|
|
65
|
+
<scrollbox
|
|
66
|
+
ref={markdownScrollRef}
|
|
67
|
+
verticalScrollbarOptions={{ visible: true }}
|
|
68
|
+
style={{ width: bodyWidth, flexGrow: 1, flexShrink: 0, minWidth: 0 }}
|
|
71
69
|
>
|
|
72
|
-
<box style={{ width:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
wrapMode="none"
|
|
78
|
-
/>
|
|
79
|
-
</box>
|
|
80
|
-
<scrollbox
|
|
81
|
-
ref={markdownScrollRef}
|
|
82
|
-
verticalScrollbarOptions={{ visible: true }}
|
|
83
|
-
style={{ width: bodyWidth, flexGrow: 1, flexShrink: 0, minWidth: 0 }}
|
|
84
|
-
>
|
|
85
|
-
<box style={{ width: "100%", paddingRight: 1 }}>
|
|
86
|
-
<markdown
|
|
87
|
-
content={current.text}
|
|
88
|
-
streaming={false}
|
|
70
|
+
<box style={{ width: "100%", paddingRight: 1 }}>
|
|
71
|
+
{current.prompts?.map((prompt, index) => (
|
|
72
|
+
<TextLine
|
|
73
|
+
key={`${index}:${prompt.text}:${prompt.steer}`}
|
|
74
|
+
theme={theme}
|
|
89
75
|
syntaxStyle={syntaxStyle}
|
|
90
|
-
|
|
91
|
-
|
|
76
|
+
role="user"
|
|
77
|
+
text={prompt.text}
|
|
92
78
|
/>
|
|
79
|
+
))}
|
|
80
|
+
{current.prompts && current.prompts.length > 0 ? (
|
|
81
|
+
<box style={{ height: 1, flexShrink: 0 }} />
|
|
82
|
+
) : null}
|
|
83
|
+
<box style={{ flexDirection: "row", width: "100%" }}>
|
|
84
|
+
<box style={{ width: 2, flexShrink: 0 }}>
|
|
85
|
+
<text
|
|
86
|
+
content={seen ? "✓ " : "◦ "}
|
|
87
|
+
fg={seen ? theme.success : theme.dim}
|
|
88
|
+
bg={theme.popupBg}
|
|
89
|
+
wrapMode="none"
|
|
90
|
+
/>
|
|
91
|
+
</box>
|
|
92
|
+
<box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
|
|
93
|
+
<markdown
|
|
94
|
+
content={current.text}
|
|
95
|
+
streaming={false}
|
|
96
|
+
syntaxStyle={syntaxStyle}
|
|
97
|
+
fg={seen ? theme.dim : theme.assistant}
|
|
98
|
+
style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
|
|
99
|
+
/>
|
|
100
|
+
</box>
|
|
93
101
|
</box>
|
|
94
|
-
</
|
|
95
|
-
</
|
|
102
|
+
</box>
|
|
103
|
+
</scrollbox>
|
|
96
104
|
</>
|
|
97
105
|
) : (
|
|
98
106
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
@@ -103,7 +111,7 @@ export function NewsPopup({
|
|
|
103
111
|
<text
|
|
104
112
|
content={count === 0
|
|
105
113
|
? "esc close"
|
|
106
|
-
: "← → navigate · space read/unread · enter reply · esc close"}
|
|
114
|
+
: "← → navigate · space read/unread · c copy · enter reply · esc close"}
|
|
107
115
|
fg={theme.dim}
|
|
108
116
|
bg={theme.popupBg}
|
|
109
117
|
wrapMode="none"
|
package/src/news.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { basename, dirname, join } from "node:path";
|
|
2
2
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
|
|
4
|
+
export type NewsPrompt = {
|
|
5
|
+
/** The user prompt or steer text that produced the answer. */
|
|
6
|
+
text: string;
|
|
7
|
+
/** True when the text steered an already-running turn. */
|
|
8
|
+
steer: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
4
11
|
export type NewsItem = {
|
|
5
12
|
/** Stable identifier used to tag the matching transcript line. */
|
|
6
13
|
id: string;
|
|
@@ -12,6 +19,8 @@ export type NewsItem = {
|
|
|
12
19
|
read: boolean;
|
|
13
20
|
/** True when the user replied to it. */
|
|
14
21
|
answered: boolean;
|
|
22
|
+
/** User prompt and steers that produced this answer, oldest first. */
|
|
23
|
+
prompts?: NewsPrompt[];
|
|
15
24
|
};
|
|
16
25
|
|
|
17
26
|
/** The list never holds more than this many answers. */
|
|
@@ -31,7 +40,15 @@ function isNewsItem(value: unknown): value is NewsItem {
|
|
|
31
40
|
typeof item.text === "string" &&
|
|
32
41
|
typeof item.at === "number" &&
|
|
33
42
|
typeof item.read === "boolean" &&
|
|
34
|
-
typeof item.answered === "boolean"
|
|
43
|
+
typeof item.answered === "boolean" &&
|
|
44
|
+
(item.prompts === undefined ||
|
|
45
|
+
(Array.isArray(item.prompts) &&
|
|
46
|
+
item.prompts.every(
|
|
47
|
+
(prompt) =>
|
|
48
|
+
Boolean(prompt) &&
|
|
49
|
+
typeof (prompt as Record<string, unknown>).text === "string" &&
|
|
50
|
+
typeof (prompt as Record<string, unknown>).steer === "boolean",
|
|
51
|
+
)))
|
|
35
52
|
);
|
|
36
53
|
}
|
|
37
54
|
|
package/src/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { AGENT_DIR } from "./config";
|
|
4
4
|
import { DEFAULT_CHECK_MODEL } from "./check-mode";
|
|
@@ -152,5 +152,10 @@ export function loadSettings(): PumSettings {
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
export function saveSettings(settings: PumSettings): void {
|
|
155
|
-
|
|
155
|
+
// Write to a temporary sibling file, then rename into place, so a crash
|
|
156
|
+
// during the write cannot corrupt pum.json. loadSettings keeps its tolerance
|
|
157
|
+
// for a corrupt or missing file, so a leftover temp file never affects it.
|
|
158
|
+
const temporary = `${SETTINGS_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
159
|
+
writeFileSync(temporary, JSON.stringify(settings, null, 2));
|
|
160
|
+
renameSync(temporary, SETTINGS_PATH);
|
|
156
161
|
}
|
package/src/status-bar.tsx
CHANGED
|
@@ -44,6 +44,9 @@ const REMOVAL_ORDER: readonly (StatusMetadataItem["key"] | "title")[] = [
|
|
|
44
44
|
"title",
|
|
45
45
|
];
|
|
46
46
|
|
|
47
|
+
/** The WorkingPulse spinner occupies exactly one terminal column. */
|
|
48
|
+
const PULSE_GLYPH_WIDTH = 1;
|
|
49
|
+
|
|
47
50
|
type WorkingMode = "full" | "compact" | "pulse" | null;
|
|
48
51
|
type LeftPart = "model" | "thinking" | "agents" | "activeAgent";
|
|
49
52
|
|
|
@@ -105,8 +108,14 @@ function agentTextWidth(input: StatusBarLayoutInput, layout: StatusBarLayout): n
|
|
|
105
108
|
let width = 0;
|
|
106
109
|
if (layout.showIdleAgents && idle > 0) width += statusTextWidth(`◇ ${idle}`);
|
|
107
110
|
if (layout.showRunningAgents && input.runningAgentCount > 0) {
|
|
111
|
+
// One space separates the idle block, not the pulse glyph.
|
|
108
112
|
if (width) width += 1;
|
|
109
|
-
|
|
113
|
+
// The running block renders as the pulse glyph (PULSE_GLYPH_WIDTH columns)
|
|
114
|
+
// followed by the " N/M" counter. That leading space is inside the text
|
|
115
|
+
// that statusTextWidth measures.
|
|
116
|
+
width += PULSE_GLYPH_WIDTH + statusTextWidth(
|
|
117
|
+
` ${input.runningAgentCount}/${input.maxActiveAgentCount}`,
|
|
118
|
+
);
|
|
110
119
|
}
|
|
111
120
|
return width;
|
|
112
121
|
}
|
|
@@ -122,7 +131,7 @@ function leftParts(input: StatusBarLayoutInput, layout: StatusBarLayout): LeftPa
|
|
|
122
131
|
|
|
123
132
|
function workingWidth(input: StatusBarLayoutInput, mode: WorkingMode): number {
|
|
124
133
|
if (!mode) return 0;
|
|
125
|
-
if (mode === "pulse") return
|
|
134
|
+
if (mode === "pulse") return PULSE_GLYPH_WIDTH;
|
|
126
135
|
const elapsedWidth = statusTextWidth(fmtElapsed(input.elapsedSec));
|
|
127
136
|
return mode === "full" ? 12 + elapsedWidth : 4 + elapsedWidth;
|
|
128
137
|
}
|