pum-agent 0.2.23-beta.1 → 0.2.25-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.23-beta.1",
3
+ "version": "0.2.25-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
@@ -52,10 +52,12 @@ import {
52
52
  needsTranscriptGap,
53
53
  topAnchorScrollTop,
54
54
  PendingMessageLine,
55
+ flushStream,
55
56
  rawToolText,
56
57
  resolveGoalReview,
57
58
  resolvePendingDelivery,
58
59
  settleTranscriptMessage,
60
+ streamedDelta,
59
61
  StreamLine,
60
62
  TextLine,
61
63
  ToolLine,
@@ -310,8 +312,14 @@ const QUIT_WINDOW_MS = 2000;
310
312
  const MAX_INPUT_ROWS = 8;
311
313
  /** How long a scroll to a row waits between tries for React to draw it. */
312
314
  const ROW_DRAW_RETRY_MS = 30;
313
- /** How many of those tries it makes before it gives up. */
314
- const ROW_DRAW_TRIES = 12;
315
+ /**
316
+ * How many of those tries it makes before it gives up.
317
+ *
318
+ * A busy machine with a long session can take most of a second to draw a row
319
+ * that had to be mounted first, and a jump that gives up before then looks to
320
+ * the reader exactly like a dead key.
321
+ */
322
+ const ROW_DRAW_TRIES = 30;
315
323
  /** Frames a scroll correction waits for its rows before it gives up. */
316
324
  const ANCHOR_FRAME_BUDGET = 30;
317
325
  /** Keys that move around without changing the text. */
@@ -731,11 +739,7 @@ const DEFAULT_PROMPT_STASH_STORE: PromptStashStore = {
731
739
 
732
740
  /** Move any buffered stream into the transcript so later lines land in order. */
733
741
  function flushed(t: Transcript): Transcript {
734
- if (t.stream && t.stream.text.trim()) {
735
- const line: Line = { kind: "text", role: t.stream.kind, text: t.stream.text.trim() };
736
- return { lines: [...t.lines, line], stream: null, pending: t.pending };
737
- }
738
- return { ...t, stream: null };
742
+ return flushStream(t);
739
743
  }
740
744
 
741
745
  export function App({
@@ -1882,10 +1886,7 @@ export function App({
1882
1886
  }, [activeAgentId, agents.map((agent) => agent.id).join(":")]);
1883
1887
 
1884
1888
  const delta = (kind: "assistant" | "thinking", text: string) =>
1885
- setTx((t) => {
1886
- if (t.stream?.kind === kind) return { ...t, stream: { kind, text: t.stream.text + text } };
1887
- return { ...flushed(t), stream: { kind, text } };
1888
- });
1889
+ setTx((t) => streamedDelta(t, kind, text));
1889
1890
 
1890
1891
  const patchTool = (id: string, patch: Partial<ToolCall>) =>
1891
1892
  setTx((t) => ({
package/src/cli.ts CHANGED
@@ -229,8 +229,8 @@ session history, Ctrl+T for triggers, and Ctrl+C twice on an empty prompt to qui
229
229
  Configuration:
230
230
  PUM stores credentials, settings, and sessions in its own configuration
231
231
  directory. Set PUM_DIR to override the complete directory. Defaults are
232
- $XDG_CONFIG_HOME/pum or ~/.config/pum on Linux, ~/Library/Application Support/pum
233
- on macOS, and %LOCALAPPDATA%\\pum on Windows.
232
+ $XDG_CONFIG_HOME/pum or ~/.config/pum on Linux and Windows, and
233
+ ~/Library/Application Support/pum on macOS.
234
234
 
235
235
  Package: ${metadata.name}
236
236
  Executable: pum
package/src/platform.ts CHANGED
@@ -19,11 +19,9 @@ export function defaultAgentDir(
19
19
  ): string {
20
20
  const paths = pathApi(platform);
21
21
  if (env.PUM_DIR) return paths.resolve(env.PUM_DIR);
22
- if (platform === "win32") {
23
- const base = env.LOCALAPPDATA ?? env.APPDATA ?? paths.join(home, "AppData", "Local");
24
- return paths.join(base, "pum");
25
- }
26
22
  if (platform === "darwin") return paths.join(home, "Library", "Application Support", "pum");
23
+ // Windows shares the Linux layout on purpose: one path to document, and a
24
+ // config directory the user can find and back up next to their other tools.
27
25
  return paths.join(env.XDG_CONFIG_HOME ?? paths.join(home, ".config"), "pum");
28
26
  }
29
27
 
@@ -68,8 +68,10 @@ import type { SandboxMode } from "../sandbox/types";
68
68
  import { readBranch } from "../git-branch";
69
69
  import { GOAL_VERDICT_TOOL_NAME, goalVerdictParameters } from "../goal-judge";
70
70
  import {
71
+ flushStream,
71
72
  resolvePendingDelivery,
72
73
  settleTranscriptMessage,
74
+ streamedDelta,
73
75
  type Line,
74
76
  type PendingLine,
75
77
  } from "../transcript";
@@ -314,15 +316,7 @@ type ManagerOptions = {
314
316
  const emptyTranscript = (): AgentTranscript => ({ lines: [], stream: null, pending: [] });
315
317
 
316
318
  function flushTranscript(transcript: AgentTranscript): AgentTranscript {
317
- if (!transcript.stream?.text.trim()) return { ...transcript, stream: null };
318
- return {
319
- lines: [
320
- ...transcript.lines,
321
- { kind: "text", role: transcript.stream.kind, text: transcript.stream.text.trim() },
322
- ],
323
- stream: null,
324
- pending: transcript.pending,
325
- };
319
+ return flushStream(transcript);
326
320
  }
327
321
 
328
322
  function snapshotMetadata(snapshot: SubagentSnapshot): Omit<SubagentSnapshot, "transcript"> {
@@ -981,13 +975,7 @@ export class SubagentManager {
981
975
  if (kind === "assistant" && record.completionMessageIds?.size) {
982
976
  record.completionResponse = (record.completionResponse ?? "") + update.delta;
983
977
  }
984
- this.updateTranscript(record, (value) => {
985
- if (value.stream?.kind === kind) {
986
- return { ...value, stream: { kind, text: value.stream.text + update.delta } };
987
- }
988
- const flushed = flushTranscript(value);
989
- return { ...flushed, stream: { kind, text: update.delta } };
990
- });
978
+ this.updateTranscript(record, (value) => streamedDelta(value, kind, update.delta));
991
979
  break;
992
980
  }
993
981
  case "tool_execution_start":
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Wrapping that breaks at spaces only.
3
+ *
4
+ * OpenTUI's `wrapMode="word"` also breaks after `.`, `,`, `:`, `-`, `/` and
5
+ * `(`. In reasoning text, which is dense with file names, flags and paths,
6
+ * that puts `auth.` and `json` on two rows, and reads as a linebreak in the
7
+ * middle of a word. Pre-wrapped text leaves the renderer nothing to break.
8
+ */
9
+
10
+ /** OpenTUI paints one tab as two columns, not as a jump to a tab stop. */
11
+ const TAB = " ";
12
+
13
+ /** Columns a run of text occupies on screen. */
14
+ export function textColumns(text: string): number {
15
+ return Bun.stringWidth(text.replaceAll("\t", TAB));
16
+ }
17
+
18
+ /**
19
+ * Break `text` into rows of at most `width` columns, at spaces only.
20
+ *
21
+ * A word wider than the whole row is split, because no break point exists.
22
+ * Existing line breaks and leading indentation are kept.
23
+ */
24
+ export function wrapAtSpaces(text: string, width: number): string {
25
+ if (!Number.isFinite(width) || width < 1) return text;
26
+ const rows: string[] = [];
27
+ for (const line of text.split("\n")) rows.push(...wrapLine(line, width));
28
+ return rows.join("\n");
29
+ }
30
+
31
+ function wrapLine(line: string, width: number): string[] {
32
+ if (textColumns(line) <= width) return [line];
33
+ const rows: string[] = [];
34
+ // Whitespace runs are kept as their own segments, so the indentation of a
35
+ // code line survives and two words never lose the space between them.
36
+ const segments = line.split(/(\s+)/);
37
+ let row = "";
38
+ let gap = "";
39
+ const flush = () => {
40
+ if (row.length > 0) rows.push(row);
41
+ row = "";
42
+ gap = "";
43
+ };
44
+ for (const segment of segments) {
45
+ if (segment.length === 0) continue;
46
+ if (/^\s+$/.test(segment)) {
47
+ // Indentation belongs to the row it opens; a gap between two words is
48
+ // only paid for when the following word fits on the same row.
49
+ if (row.length === 0) row = segment;
50
+ else gap = segment;
51
+ continue;
52
+ }
53
+ if (textColumns(row + gap + segment) <= width) {
54
+ row += gap + segment;
55
+ gap = "";
56
+ continue;
57
+ }
58
+ flush();
59
+ if (textColumns(segment) <= width) {
60
+ row = segment;
61
+ continue;
62
+ }
63
+ const pieces = splitWord(segment, width);
64
+ rows.push(...pieces.slice(0, -1));
65
+ row = pieces[pieces.length - 1]!;
66
+ }
67
+ flush();
68
+ return rows.length > 0 ? rows : [line];
69
+ }
70
+
71
+ /** Cut a word that cannot fit any row into row-sized pieces. */
72
+ function splitWord(word: string, width: number): string[] {
73
+ const pieces: string[] = [];
74
+ let piece = "";
75
+ for (const char of word) {
76
+ if (textColumns(piece + char) > width && piece.length > 0) {
77
+ pieces.push(piece);
78
+ piece = "";
79
+ }
80
+ piece += char;
81
+ }
82
+ if (piece.length > 0) pieces.push(piece);
83
+ return pieces;
84
+ }
@@ -2,13 +2,14 @@ import {
2
2
  StyledText,
3
3
  TextAttributes,
4
4
  fg,
5
+ type BoxRenderable,
5
6
  type MarkdownRenderable,
6
7
  type MouseEvent as OpenTuiMouseEvent,
7
8
  type SyntaxStyle,
8
9
  type TextChunk,
9
10
  type TextRenderable,
10
11
  } from "@opentui/core";
11
- import type { MarkdownProps } from "@opentui/react";
12
+ import { useRenderer, type MarkdownProps } from "@opentui/react";
12
13
  import { useEffect, useRef, useState, type RefObject } from "react";
13
14
  import {
14
15
  useBlinkingText,
@@ -22,6 +23,7 @@ import {
22
23
  goalReviewHeadline,
23
24
  type GoalReviewStatus,
24
25
  } from "./goal-review";
26
+ import { wrapAtSpaces } from "./text-wrap";
25
27
  import type { Theme } from "./theme";
26
28
  import { bashOutputWindow, type BashOutputWindow, type ToolCall } from "./tool-line";
27
29
  import type { TranscriptOutputMode } from "./transcript-output";
@@ -158,10 +160,65 @@ export function resolveGoalReview<T extends PendingTranscriptState>(
158
160
  }
159
161
 
160
162
  /** Finish the stream, then insert messages that arrived while it was active. */
163
+ /**
164
+ * The line a buffered stream becomes, or nothing when it holds no text.
165
+ *
166
+ * Reasoning keeps its text as it arrived. The rest of it can still turn up
167
+ * after the answer has started, and it is appended to this line, so a space at
168
+ * the join has to survive; the row normalizes what it shows anyway. An answer
169
+ * is markdown, where leading whitespace would open a code block, so it is
170
+ * trimmed.
171
+ */
172
+ export function streamedLine(stream: PendingTranscriptState["stream"]): Line | null {
173
+ if (!stream?.text.trim()) return null;
174
+ const text = stream.kind === "thinking" ? stream.text : stream.text.trim();
175
+ return { kind: "text", role: stream.kind, text };
176
+ }
177
+
178
+ /** Move any buffered stream into the transcript, and close the stream. */
179
+ export function flushStream<T extends PendingTranscriptState>(value: T): T {
180
+ const line = streamedLine(value.stream);
181
+ if (!line) return { ...value, stream: null };
182
+ return { ...value, lines: [...value.lines, line], stream: null };
183
+ }
184
+
185
+ /**
186
+ * Take one streamed delta of `kind` into the transcript.
187
+ *
188
+ * A reasoning provider does not always finish one stream before it starts the
189
+ * other: the last of the reasoning can arrive after the first words of the
190
+ * answer. Committing the answer to make room for it would cut the answer in
191
+ * two, which reads as a line break in the middle of a sentence. So the answer
192
+ * keeps the stream, and the late reasoning rejoins the row it belongs to.
193
+ */
194
+ export function streamedDelta<T extends PendingTranscriptState>(
195
+ value: T,
196
+ kind: "assistant" | "thinking",
197
+ text: string,
198
+ ): T {
199
+ if (value.stream?.kind === kind) {
200
+ return { ...value, stream: { kind, text: value.stream.text + text } };
201
+ }
202
+ if (kind === "thinking" && value.stream?.kind === "assistant") {
203
+ return { ...value, lines: withThinkingAppended(value.lines, text) };
204
+ }
205
+ return { ...flushStream(value), stream: { kind, text } };
206
+ }
207
+
208
+ /** Add reasoning to the row it came from, or start one if there is none. */
209
+ function withThinkingAppended(lines: readonly Line[], text: string): Line[] {
210
+ const last = lines[lines.length - 1];
211
+ if (last?.kind === "text" && last.role === "thinking") {
212
+ return [...lines.slice(0, -1), { ...last, text: last.text + text }];
213
+ }
214
+ return [...lines, { kind: "text", role: "thinking", text }];
215
+ }
216
+
161
217
  export function settleTranscriptMessage<T extends PendingTranscriptState>(value: T): T {
162
218
  const lines = [...value.lines];
163
- if (value.stream?.text.trim()) {
164
- lines.push({ kind: "text", role: value.stream.kind, text: value.stream.text.trim() });
219
+ const streamed = streamedLine(value.stream);
220
+ if (streamed) {
221
+ lines.push(streamed);
165
222
  }
166
223
  const delivered = value.pending.filter((item) => item.delivered);
167
224
  return {
@@ -237,6 +294,40 @@ export function normalizeThinkingText(text: string): string {
237
294
  .replace(/\n[ \t]*\n+/g, "\n");
238
295
  }
239
296
 
297
+ /**
298
+ * Columns the message body of a row occupies, or 0 before it is measured.
299
+ *
300
+ * The row is pre-wrapped at this width, so the renderer's own word wrap finds
301
+ * nothing to break. Layout runs before effects, so the first measurement is
302
+ * already the real one; the terminal's resize event covers every later one,
303
+ * because a resized row re-lays out without React rendering it again.
304
+ */
305
+ function useBodyColumns(ref: RefObject<BoxRenderable | null>, active: boolean): number {
306
+ const renderer = useRenderer();
307
+ const [columns, setColumns] = useState(0);
308
+ useEffect(() => {
309
+ if (!active) return;
310
+ const measure = () => {
311
+ const width = ref.current?.width ?? 0;
312
+ if (width > 0) setColumns((current) => (current === width ? current : width));
313
+ };
314
+ measure();
315
+ renderer.on("resize", measure);
316
+ return () => { renderer.off("resize", measure); };
317
+ }, [active, ref, renderer]);
318
+ return active ? columns : 0;
319
+ }
320
+
321
+ /**
322
+ * Reasoning text as the row shows it: wrapped at spaces only.
323
+ *
324
+ * A caret rides the last character, so it needs a column of its own. Without
325
+ * one, the last word jumps to the next row the moment the caret appears.
326
+ */
327
+ function thinkingDisplayText(text: string, columns: number, caret: boolean): string {
328
+ return wrapAtSpaces(normalizeThinkingText(text), caret ? columns - 1 : columns);
329
+ }
330
+
240
331
  export function roleColor(theme: Theme, role: Role): string {
241
332
  switch (role) {
242
333
  case "user":
@@ -262,6 +353,7 @@ function Row({
262
353
  glyph,
263
354
  glyphColor,
264
355
  glyphRef,
356
+ bodyRef,
265
357
  background,
266
358
  onGlyphClick,
267
359
  children,
@@ -270,6 +362,8 @@ function Row({
270
362
  glyphColor: string;
271
363
  /** An animated gutter glyph writes its own content through this ref. */
272
364
  glyphRef?: RefObject<TextRenderable | null>;
365
+ /** A row that wraps its own text measures the message column through this. */
366
+ bodyRef?: RefObject<BoxRenderable | null>;
273
367
  background?: string;
274
368
  onGlyphClick?: (event: OpenTuiMouseEvent) => void;
275
369
  children: React.ReactNode;
@@ -299,7 +393,7 @@ function Row({
299
393
  </box>
300
394
  {/* The nested flex item gives every transcript type the same measured
301
395
  remaining-width column as the tool-row body. */}
302
- <box style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
396
+ <box ref={bodyRef} style={{ flexDirection: "row", flexGrow: 1, flexShrink: 1, minWidth: 0 }}>
303
397
  {children}
304
398
  </box>
305
399
  </box>
@@ -353,7 +447,12 @@ export function TextLine({
353
447
  const color = roleColor(theme, role);
354
448
  const isUser = role === "user";
355
449
  const isAssistant = role === "assistant";
356
- const displayText = role === "thinking" ? normalizeThinkingText(text) : text;
450
+ const isThinking = role === "thinking";
451
+ const bodyRef = useRef<BoxRenderable>(null);
452
+ const columns = useBodyColumns(bodyRef, isThinking);
453
+ const displayText = isThinking
454
+ ? thinkingDisplayText(text, columns, workingCaret)
455
+ : text;
357
456
  const textCaret = useBlinkingText({
358
457
  chunks: [fg(color)(displayText)],
359
458
  contentKey: `${role}:${displayText}`,
@@ -395,7 +494,7 @@ export function TextLine({
395
494
  }
396
495
 
397
496
  return (
398
- <Row glyph={GUTTER} glyphColor={color}>
497
+ <Row glyph={GUTTER} glyphColor={color} bodyRef={isThinking ? bodyRef : undefined}>
399
498
  <text
400
499
  ref={workingCaret ? textCaret : undefined}
401
500
  // The caret hook repaints this imperatively on the frame clock. Passing
@@ -424,7 +523,11 @@ export function StreamLine({
424
523
  text: string;
425
524
  }) {
426
525
  const color = roleColor(theme, role);
427
- const displayText = role === "thinking" ? normalizeThinkingText(text) : text;
526
+ const isThinking = role === "thinking";
527
+ const bodyRef = useRef<BoxRenderable>(null);
528
+ const columns = useBodyColumns(bodyRef, isThinking);
529
+ // The shimmer always carries a caret, so the row always reserves its column.
530
+ const displayText = isThinking ? thinkingDisplayText(text, columns, true) : text;
428
531
  const shimmer = useShimmerText({
429
532
  text: displayText,
430
533
  color,
@@ -436,7 +539,7 @@ export function StreamLine({
436
539
  const markdown = useMarkdownCaret(text, role === "assistant");
437
540
 
438
541
  return (
439
- <Row glyph={GUTTER} glyphColor={color}>
542
+ <Row glyph={GUTTER} glyphColor={color} bodyRef={isThinking ? bodyRef : undefined}>
440
543
  {role === "assistant" ? (
441
544
  <SelectableMarkdown
442
545
  ref={markdown.ref}