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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.8-beta.1",
3
+ "version": "0.2.9-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.tsx CHANGED
@@ -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
- const [tx, setTx] = useState<Transcript>(() => ({
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
- lines: [
434
- ...replayEntries(
435
- initialSession.sessionManager.buildContextEntries(),
436
- cwd,
437
- initial.showThinking,
438
- ),
439
- ...startupWarnings.map((text): Line => ({ kind: "text", role: "system", text })),
440
- ],
441
- stream: null,
442
- pending: [],
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
- if (!text) throw new Error("Cannot copy an empty selection");
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",
@@ -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
  },
@@ -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 Esc to the shared handlers.
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, and the gutter
32
- // takes 2 columns, so the markdown body gets a concrete numeric width.
33
- const bodyWidth = Math.max(1, width - 6);
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
- <box
65
- style={{
66
- flexGrow: 1,
67
- flexShrink: 1,
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: 2, flexShrink: 0 }}>
73
- <text
74
- content={seen ? "✓" : "◦"}
75
- fg={seen ? theme.success : theme.dim}
76
- bg={theme.popupBg}
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
- fg={seen ? theme.dim : theme.assistant}
91
- style={{ flexGrow: 1, flexShrink: 1, minWidth: 0, width: "100%" }}
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
- </scrollbox>
95
- </box>
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
- writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
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
  }
@@ -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
- width += 1 + statusTextWidth(` ${input.runningAgentCount}/${input.maxActiveAgentCount}`);
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 1;
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
  }