praxis-agent 0.21.1 → 0.21.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.
@@ -14,6 +14,7 @@ import { redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/se
14
14
  import { CommandPalette, BtwPanel, Composer, DiffDashboard, DialogFrame, ExternalEditorWait, HelpMenu, HookDashboard, ListDashboard, MemoryDashboard, MentionPicker, ModelMenu, PermissionDashboard, SelectionMenu, SessionPicker, ThemePicker, CustomThemeEditor, SessionIdentity, Transcript, WelcomePanel, useTerminalRows, useTerminalWidth, } from './tui/claude-style.js';
15
15
  import { loadTuiMemoryFiles, openTuiMemoryFolder, } from './tui/memory-files.js';
16
16
  import { loadClaudeReleaseNotes } from './tui/release-notes.js';
17
+ import { StreamingFrameBuffer } from './tui/streaming-frame-buffer.js';
17
18
  import { createClaudeStatusLineInput, StatusLine } from './tui/status-line.js';
18
19
  import { loadGitDiff, visiblePatchLines, } from './tui/git-diff.js';
19
20
  import { addTuiPermissionRule, loadTuiPermissionRules, removeTuiPermissionRule, } from './tui/permission-settings.js';
@@ -409,6 +410,20 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
409
410
  const [status, setStatus] = useState('ready');
410
411
  const [activeText, setActiveText] = useState('');
411
412
  const [activeThinking, setActiveThinking] = useState('');
413
+ // One provider-neutral streaming frame buffer per mounted app. RuntimeEvent
414
+ // text/thinking deltas accumulate here and are coalesced into bounded frames
415
+ // instead of causing a React state update per delta. The React state is only
416
+ // ever written through the buffer's publish callback so the buffer's committed
417
+ // prefix and the displayed text stay in sync. Disposed on unmount.
418
+ const streamingFrameRef = useRef(null);
419
+ if (streamingFrameRef.current === null) {
420
+ streamingFrameRef.current = new StreamingFrameBuffer({
421
+ publish: (frame) => {
422
+ setActiveText(frame.text);
423
+ setActiveThinking(frame.thinking);
424
+ },
425
+ });
426
+ }
412
427
  const [thinkingExpanded, setThinkingExpanded] = useState(false);
413
428
  const [turnDuration, setTurnDuration] = useState();
414
429
  const [usage, setUsage] = useState();
@@ -675,6 +690,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
675
690
  }, [exit, signal]);
676
691
  useEffect(() => () => {
677
692
  componentMountedRef.current = false;
693
+ streamingFrameRef.current?.dispose();
678
694
  permissionRef.current?.resolve(false);
679
695
  elicitationRef.current?.resolve({ action: 'cancel' });
680
696
  questionRef.current?.resolve(null);
@@ -692,7 +708,13 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
692
708
  else
693
709
  void closing.catch(() => undefined);
694
710
  }, []);
695
- const append = (line) => setHistory((current) => [...current, line]);
711
+ const append = (line) => {
712
+ // Any unflushed streaming deltas must be published before the boundary
713
+ // transcript state so the active stream never renders below a tool,
714
+ // thinking, or completion entry that it textually precedes.
715
+ streamingFrameRef.current?.flush();
716
+ setHistory((current) => [...current, line]);
717
+ };
696
718
  useEffect(() => {
697
719
  if (initialThemeSettings !== undefined) {
698
720
  if (initialThemeLoadError)
@@ -1111,28 +1133,33 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
1111
1133
  const handleEvent = (event) => {
1112
1134
  switch (event.type) {
1113
1135
  case 'text-delta':
1114
- setActiveText((current) => current + event.delta);
1136
+ streamingFrameRef.current?.appendText(event.delta);
1115
1137
  break;
1116
1138
  case 'thinking-start':
1117
- setActiveThinking(event.block.type === 'thinking'
1118
- ? redactSensitiveText(event.block.thinking, sensitiveValues)
1119
- : '');
1139
+ streamingFrameRef.current?.resetThinking();
1140
+ if (event.block.type === 'thinking') {
1141
+ streamingFrameRef.current?.appendThinking(redactSensitiveText(event.block.thinking, sensitiveValues));
1142
+ }
1120
1143
  break;
1121
1144
  case 'thinking-delta':
1122
- setActiveThinking((current) => current + redactSensitiveText(event.delta, sensitiveValues));
1145
+ streamingFrameRef.current?.appendThinking(redactSensitiveText(event.delta, sensitiveValues));
1123
1146
  break;
1124
1147
  case 'thinking-signature-delta':
1125
1148
  // Signatures authenticate a thinking block for provider replay; they are
1126
1149
  // intentionally not part of the user-visible reasoning summary.
1127
1150
  break;
1128
1151
  case 'thinking-stop':
1152
+ // append flushes pending thinking deltas before the retained boundary
1153
+ // item, keeping streaming order correct; the effective thinking getter
1154
+ // already includes any deltas not yet published.
1129
1155
  append({
1130
1156
  kind: 'thinking',
1131
1157
  text: event.block.type === 'thinking'
1132
1158
  ? redactSensitiveText(event.block.thinking, sensitiveValues)
1133
- : activeThinking,
1159
+ : (streamingFrameRef.current?.thinking ?? ''),
1134
1160
  });
1135
- setActiveThinking('');
1161
+ streamingFrameRef.current?.resetThinking();
1162
+ streamingFrameRef.current?.flush();
1136
1163
  break;
1137
1164
  case 'user-message':
1138
1165
  append({ kind: 'assistant', text: event.message });
@@ -2575,8 +2602,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2575
2602
  ? allSlashCommands.find((command) => command.name.toLowerCase() === submittedCommandName)?.progressMessage
2576
2603
  : undefined;
2577
2604
  setStatus(commandProgressMessage ?? 'assembling-context');
2578
- setActiveText('');
2579
- setActiveThinking('');
2605
+ streamingFrameRef.current?.resetText();
2606
+ streamingFrameRef.current?.resetThinking();
2607
+ streamingFrameRef.current?.flush();
2580
2608
  if (runtimeSettingsRef.current.tips && !commandProgressMessage) {
2581
2609
  setStatus(spinnerTip(runtimeSettingsRef.current) ?? 'assembling-context');
2582
2610
  }
@@ -2641,8 +2669,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2641
2669
  // Diff snapshots are a local presentation aid and must not fail a turn.
2642
2670
  }
2643
2671
  }
2644
- setActiveText('');
2645
- setActiveThinking('');
2672
+ streamingFrameRef.current?.resetText();
2673
+ streamingFrameRef.current?.resetThinking();
2674
+ streamingFrameRef.current?.flush();
2646
2675
  setStatus('ready');
2647
2676
  setTurnDuration(Date.now() - turnStartedAt);
2648
2677
  if (runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
@@ -5131,8 +5160,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
5131
5160
  setHistory([]);
5132
5161
  setUsage(undefined);
5133
5162
  setCostUsd(undefined);
5134
- setActiveText('');
5135
- setActiveThinking('');
5163
+ streamingFrameRef.current?.resetText();
5164
+ streamingFrameRef.current?.resetThinking();
5165
+ streamingFrameRef.current?.flush();
5136
5166
  setThinkingExpanded(false);
5137
5167
  setStatus('ready');
5138
5168
  inputHistoryRef.current = [];
@@ -5647,7 +5677,13 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
5647
5677
  });
5648
5678
  return (_jsx(TuiThemeProvider, { settings: themeSettings, children: _jsx(Box, { flexDirection: "column", ...(!fixedViewport
5649
5679
  ? {}
5650
- : { height: rows, overflowY: 'hidden' }), children: selectingSession ? (_jsx(SessionPicker, { sessions: filteredPickerChoices, selectedIndex: selectedIndex, screenReader: axScreenReader, query: sessionSearch })) : (_jsxs(_Fragment, { children: [!axScreenReader && freshSession ? (_jsx(WelcomePanel, { display: runtimeDisplay, width: width, showTips: runtimeSettings.tips })) : null, sessionId ? (_jsxs(Text, { dimColor: true, children: ["Session ", sessionId.slice(0, 8)] })) : null, !axScreenReader && !resumed && hasConversationHistory ? (_jsx(SessionIdentity, { display: runtimeDisplay, width: width })) : null, _jsx(Transcript, { items: history, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }), externalEditorRequest !== null ||
5680
+ : { height: rows, overflowY: 'hidden' }), children: selectingSession ? (_jsx(SessionPicker, { sessions: filteredPickerChoices, selectedIndex: selectedIndex, screenReader: axScreenReader, query: sessionSearch })) : (_jsxs(_Fragment, { children: [!axScreenReader && freshSession ? (_jsx(WelcomePanel, { display: runtimeDisplay, width: width, showTips: runtimeSettings.tips })) : null, sessionId ? (_jsxs(Text, { dimColor: true, children: ["Session ", sessionId.slice(0, 8)] })) : null, !axScreenReader && !resumed && hasConversationHistory ? (_jsx(SessionIdentity, { display: runtimeDisplay, width: width })) : null, _jsx(Box, { ...(fixedViewport
5681
+ ? {
5682
+ flexShrink: 1,
5683
+ minHeight: 0,
5684
+ overflowY: 'hidden',
5685
+ }
5686
+ : {}), children: _jsx(Transcript, { items: history, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }) }), externalEditorRequest !== null ||
5651
5687
  keybindingsEditing ||
5652
5688
  memoryEditorRequest !== null ? (_jsx(ExternalEditorWait, { screenReader: axScreenReader })) : permission ? (permission.kind === 'tool' && toolPermissionModel ? (_jsx(ToolPermissionDialog, { model: toolPermissionModel, selection: permissionSelection, feedbackMode: permissionFeedbackMode, feedback: input, ruleEditor: permissionRuleEditor, screenReader: axScreenReader })) : (_jsxs(DialogFrame, { title: `Retry interrupted ${permission.call.name}?`, screenReader: axScreenReader, children: [_jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: _jsx(Text, { bold: true, children: describeTool(permission.call, sensitiveValues) }) }), _jsx(Text, { children: "Do you want to proceed?" }), _jsxs(Text, { inverse: !axScreenReader && permissionSelection === 0, children: [selectionPrefix(permissionSelection === 0, axScreenReader), "1. Yes"] }), _jsxs(Text, { inverse: !axScreenReader && permissionSelection === 1, children: [selectionPrefix(permissionSelection === 1, axScreenReader), "2. No"] }), permissionFeedbackMode ? (_jsxs(Text, { children: ["\u203A", ' ', input ||
5653
5689
  (permissionSelection === 0
@@ -5737,7 +5773,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
5737
5773
  label: 'Save to file',
5738
5774
  description: 'Save the conversation to a file in the current directory',
5739
5775
  },
5740
- ], selectedIndex: menu.selectedIndex, footer: "Esc to cancel", width: width, screenReader: axScreenReader })) : menu.kind === 'copy' ? (_jsx(SelectionMenu, { title: "Copy", description: "Select content to copy:", options: menu.candidates, selectedIndex: menu.selectedIndex, footer: "Enter to copy \u00B7 w to write to /tmp/claude \u00B7 Esc to cancel", width: width, screenReader: axScreenReader })) : null) : (_jsxs(_Fragment, { children: [commandPaletteVisible ? (_jsx(CommandPalette, { commands: matchingSlashCommands, selectedIndex: selectedSlashCommandIndex, width: width, screenReader: axScreenReader })) : null, filePickerVisible ? (_jsx(MentionPicker, { entries: matchingMentionEntries, selectedIndex: selectedFileIndex, width: width, screenReader: axScreenReader })) : null, exitConfirmation ? (_jsx(Text, { color: activePalette.warning, children: "Press Ctrl-C again to exit" })) : null, _jsx(Composer, { input: shellMode ? input.slice(1) : input, cursor: shellMode ? Math.max(0, inputCursor - 1) : inputCursor, shellMode: shellMode, ...(sessionColor === undefined ? {} : { sessionColor }), ...(commandArgumentHint === undefined
5776
+ ], selectedIndex: menu.selectedIndex, footer: "Esc to cancel", width: width, screenReader: axScreenReader })) : menu.kind === 'copy' ? (_jsx(SelectionMenu, { title: "Copy", description: "Select content to copy:", options: menu.candidates, selectedIndex: menu.selectedIndex, footer: "Enter to copy \u00B7 w to write to /tmp/claude \u00B7 Esc to cancel", width: width, screenReader: axScreenReader })) : null) : (_jsxs(_Fragment, { children: [commandPaletteVisible ? (_jsx(CommandPalette, { commands: matchingSlashCommands, selectedIndex: selectedSlashCommandIndex, width: width, screenReader: axScreenReader })) : null, filePickerVisible ? (_jsx(MentionPicker, { entries: matchingMentionEntries, selectedIndex: selectedFileIndex, width: width, screenReader: axScreenReader })) : null, exitConfirmation ? (_jsx(Text, { color: activePalette.warning, children: "Press Ctrl-C again to exit" })) : null, fixedViewport ? _jsx(Box, { flexGrow: 1 }) : null, _jsx(Composer, { input: shellMode ? input.slice(1) : input, cursor: shellMode ? Math.max(0, inputCursor - 1) : inputCursor, shellMode: shellMode, ...(sessionColor === undefined ? {} : { sessionColor }), ...(commandArgumentHint === undefined
5741
5777
  ? {}
5742
5778
  : { commandArgumentHint }), busy: busy, clipboardBusy: clipboardBusy, status: status, display: runtimeDisplay, ...(usage === undefined ? {} : { usage }), ...(costUsd === undefined ? {} : { costUsd }), width: width, screenReader: axScreenReader, hasThinking: hasDetailedTranscript, thinkingExpanded: thinkingExpanded, reduceMotion: runtimeSettings.reduceMotion, progressBar: runtimeSettings.progressBar, ...(runtimeSettings.turnDuration
5743
5779
  ? (() => {
@@ -84,6 +84,12 @@ export declare function SessionIdentity({ display, width, }: {
84
84
  export declare function MarkdownText({ text }: {
85
85
  text: string;
86
86
  }): ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
87
+ export declare const ACTIVE_STREAM_MAX_LINES = 40;
88
+ export declare function activeStreamWindow(text: string): {
89
+ stableText: string;
90
+ pendingText: string;
91
+ truncated: boolean;
92
+ };
87
93
  export declare function Transcript({ items, activeText, activeThinking, thinkingExpanded, detailedTranscript, screenReader, }: {
88
94
  items: readonly TranscriptItem[];
89
95
  activeText: string;
@@ -473,6 +473,44 @@ export function MarkdownText({ text }) {
473
473
  const palette = useTuiPalette();
474
474
  return cachedMarkdownTextElement(text, palette);
475
475
  }
476
+ // Bounded streaming-text presentation. While a turn is in progress the active
477
+ // assistant text grows on every frame; re-parsing the entire Markdown document
478
+ // each frame would reflow the whole growing body and could let an unterminated
479
+ // fence or heading corrupt the terminal frame. Instead only a bounded window of
480
+ // the most recent complete lines is rendered through MarkdownText, and the
481
+ // trailing partial line is rendered as plain text so incomplete Markdown stays
482
+ // inert. Completed assistant turns still render the full document through the
483
+ // regular history Markdown path, so observable final text is unchanged.
484
+ export const ACTIVE_STREAM_MAX_LINES = 40;
485
+ export function activeStreamWindow(text) {
486
+ const lastBreak = text.lastIndexOf('\n');
487
+ if (lastBreak === -1) {
488
+ return { stableText: '', pendingText: text, truncated: false };
489
+ }
490
+ const pendingText = text.slice(lastBreak + 1);
491
+ let cursor = lastBreak;
492
+ let lines = 0;
493
+ // Walk backwards to the newline that starts the bounded window's first line.
494
+ while (lines < ACTIVE_STREAM_MAX_LINES) {
495
+ const previous = cursor > 0 ? text.lastIndexOf('\n', cursor - 1) : -1;
496
+ if (previous === -1) {
497
+ cursor = 0;
498
+ break;
499
+ }
500
+ cursor = previous;
501
+ lines += 1;
502
+ }
503
+ const windowStart = cursor === 0 ? 0 : cursor + 1;
504
+ return {
505
+ stableText: text.slice(windowStart, lastBreak + 1),
506
+ pendingText,
507
+ truncated: windowStart > 0,
508
+ };
509
+ }
510
+ function ActiveStreamText({ text }) {
511
+ const { stableText, pendingText, truncated } = activeStreamWindow(text);
512
+ return (_jsxs(Box, { flexDirection: "column", children: [truncated ? _jsx(Text, { dimColor: true, children: "\u2026 earlier streaming content \u2026" }) : null, stableText ? _jsx(MarkdownText, { text: stableText }) : null, pendingText ? _jsx(Text, { children: pendingText }) : null] }));
513
+ }
476
514
  export function Transcript({ items, activeText, activeThinking = '', thinkingExpanded = false, detailedTranscript = false, screenReader, }) {
477
515
  const palette = useTuiPalette();
478
516
  const detailed = thinkingExpanded || detailedTranscript;
@@ -585,7 +623,7 @@ export function Transcript({ items, activeText, activeThinking = '', thinkingExp
585
623
  return (_jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["\u23BF ", item.text] }) }, index));
586
624
  }
587
625
  return (_jsxs(Text, { ...(item.kind === 'warning' ? { color: palette.error } : {}), dimColor: item.kind === 'notice', children: [item.kind === 'warning' ? '⚠ ' : '· ', item.text] }, index));
588
- }), activeThinking ? (_jsx(ThinkingBlock, { text: activeThinking, active: true, expanded: detailed, screenReader: screenReader })) : activeText ? (_jsxs(Box, { marginTop: 1, children: [screenReader ? (_jsx(Text, { children: "Praxis: " })) : (_jsx(Text, { color: palette.accent, children: "\u2733 " })), _jsx(MarkdownText, { text: activeText })] })) : null] }));
626
+ }), activeThinking ? (_jsx(ThinkingBlock, { text: activeThinking, active: true, expanded: detailed, screenReader: screenReader })) : activeText ? (_jsxs(Box, { marginTop: 1, children: [screenReader ? (_jsx(Text, { children: "Praxis: " })) : (_jsx(Text, { color: palette.accent, children: "\u2733 " })), screenReader ? (_jsx(MarkdownText, { text: activeText })) : (_jsx(ActiveStreamText, { text: activeText }))] })) : null] }));
589
627
  }
590
628
  export function DiffDashboard({ snapshots, sourceIndex, selectedIndex, viewingFile, scrollOffset, width, screenReader, }) {
591
629
  const palette = useTuiPalette();
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Provider-neutral streaming frame buffer for the interactive CLI.
3
+ *
4
+ * High-frequency RuntimeEvent text/thinking deltas are appended here and
5
+ * coalesced into bounded presentation frames published at a fixed cadence
6
+ * (default ~30 FPS). Deltas are never lost or reordered: each stream keeps a
7
+ * committed prefix (the last published value) plus an accumulating pending
8
+ * tail, and every published frame is the exact concatenation of every delta
9
+ * received since the previous frame.
10
+ *
11
+ * `flush()` publishes any pending deltas immediately and is used at lifecycle
12
+ * boundaries (thinking-stop, tool-call/result, permission/dialog transitions,
13
+ * turn completion/cancellation) so the transcript boundary state is always
14
+ * published after the streaming text that preceded it. `dispose()` cancels
15
+ * pending schedules and ignores later appends after the mounted app is torn
16
+ * down.
17
+ *
18
+ * Scheduling is injected so tests can drive frames deterministically without
19
+ * fixed sleeps.
20
+ */
21
+ export interface StreamingFrame {
22
+ /** Full accumulated assistant text for the active stream. */
23
+ readonly text: string;
24
+ /** Full accumulated thinking text for the active stream. */
25
+ readonly thinking: string;
26
+ }
27
+ export interface StreamingFrameScheduler {
28
+ /** Schedule `callback` after `delayMs`; returns an opaque cancel handle. */
29
+ schedule(callback: () => void, delayMs: number): unknown;
30
+ /** Cancel a previously scheduled callback. */
31
+ cancel(handle: unknown): void;
32
+ }
33
+ export declare const DEFAULT_FRAME_INTERVAL_MS = 33;
34
+ export interface StreamingFrameBufferOptions {
35
+ /** Called with every published frame. */
36
+ publish: (frame: StreamingFrame) => void;
37
+ /** Bounded cadence between frames; defaults to ~30 FPS. */
38
+ frameIntervalMs?: number;
39
+ /** Injected timer hook for deterministic tests. */
40
+ scheduler?: StreamingFrameScheduler;
41
+ }
42
+ export declare class StreamingFrameBuffer {
43
+ private readonly publishFrame;
44
+ private readonly frameIntervalMs;
45
+ private readonly scheduler;
46
+ private committedText;
47
+ private committedThinking;
48
+ private pendingText;
49
+ private pendingThinking;
50
+ private scheduled;
51
+ private scheduledHandle;
52
+ private disposed;
53
+ constructor(options: StreamingFrameBufferOptions);
54
+ /** Full effective text, including any deltas not yet published. */
55
+ get text(): string;
56
+ /** Full effective thinking, including any deltas not yet published. */
57
+ get thinking(): string;
58
+ get hasPending(): boolean;
59
+ get isDisposed(): boolean;
60
+ /** Append an assistant text delta and schedule a frame publish. */
61
+ appendText(delta: string): void;
62
+ /** Append a thinking delta and schedule a frame publish. */
63
+ appendThinking(delta: string): void;
64
+ /** Discard pending text and clear the active text on the next frame. */
65
+ resetText(): void;
66
+ /** Discard pending thinking and clear the active thinking on the next frame. */
67
+ resetThinking(): void;
68
+ /** Publish every pending delta immediately, canceling any scheduled frame. */
69
+ flush(): void;
70
+ /** Cancel pending schedules and ignore all later appends. */
71
+ dispose(): void;
72
+ private scheduleFrame;
73
+ private cancelScheduledFrame;
74
+ private publishPending;
75
+ }
76
+ //# sourceMappingURL=streaming-frame-buffer.d.ts.map
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Provider-neutral streaming frame buffer for the interactive CLI.
3
+ *
4
+ * High-frequency RuntimeEvent text/thinking deltas are appended here and
5
+ * coalesced into bounded presentation frames published at a fixed cadence
6
+ * (default ~30 FPS). Deltas are never lost or reordered: each stream keeps a
7
+ * committed prefix (the last published value) plus an accumulating pending
8
+ * tail, and every published frame is the exact concatenation of every delta
9
+ * received since the previous frame.
10
+ *
11
+ * `flush()` publishes any pending deltas immediately and is used at lifecycle
12
+ * boundaries (thinking-stop, tool-call/result, permission/dialog transitions,
13
+ * turn completion/cancellation) so the transcript boundary state is always
14
+ * published after the streaming text that preceded it. `dispose()` cancels
15
+ * pending schedules and ignores later appends after the mounted app is torn
16
+ * down.
17
+ *
18
+ * Scheduling is injected so tests can drive frames deterministically without
19
+ * fixed sleeps.
20
+ */
21
+ export const DEFAULT_FRAME_INTERVAL_MS = 33;
22
+ const timeoutScheduler = {
23
+ schedule(callback, delayMs) {
24
+ return setTimeout(callback, delayMs);
25
+ },
26
+ cancel(handle) {
27
+ clearTimeout(handle);
28
+ },
29
+ };
30
+ export class StreamingFrameBuffer {
31
+ publishFrame;
32
+ frameIntervalMs;
33
+ scheduler;
34
+ committedText = '';
35
+ committedThinking = '';
36
+ pendingText = null;
37
+ pendingThinking = null;
38
+ scheduled = false;
39
+ scheduledHandle = undefined;
40
+ disposed = false;
41
+ constructor(options) {
42
+ this.publishFrame = options.publish;
43
+ this.frameIntervalMs = options.frameIntervalMs ?? DEFAULT_FRAME_INTERVAL_MS;
44
+ this.scheduler = options.scheduler ?? timeoutScheduler;
45
+ }
46
+ /** Full effective text, including any deltas not yet published. */
47
+ get text() {
48
+ return this.pendingText ?? this.committedText;
49
+ }
50
+ /** Full effective thinking, including any deltas not yet published. */
51
+ get thinking() {
52
+ return this.pendingThinking ?? this.committedThinking;
53
+ }
54
+ get hasPending() {
55
+ return this.pendingText !== null || this.pendingThinking !== null;
56
+ }
57
+ get isDisposed() {
58
+ return this.disposed;
59
+ }
60
+ /** Append an assistant text delta and schedule a frame publish. */
61
+ appendText(delta) {
62
+ if (this.disposed)
63
+ return;
64
+ this.pendingText = (this.pendingText ?? this.committedText) + delta;
65
+ this.scheduleFrame();
66
+ }
67
+ /** Append a thinking delta and schedule a frame publish. */
68
+ appendThinking(delta) {
69
+ if (this.disposed)
70
+ return;
71
+ this.pendingThinking =
72
+ (this.pendingThinking ?? this.committedThinking) + delta;
73
+ this.scheduleFrame();
74
+ }
75
+ /** Discard pending text and clear the active text on the next frame. */
76
+ resetText() {
77
+ if (this.disposed)
78
+ return;
79
+ if (this.pendingText === '' ||
80
+ (this.pendingText === null && this.committedText === '')) {
81
+ return;
82
+ }
83
+ this.pendingText = '';
84
+ this.scheduleFrame();
85
+ }
86
+ /** Discard pending thinking and clear the active thinking on the next frame. */
87
+ resetThinking() {
88
+ if (this.disposed)
89
+ return;
90
+ if (this.pendingThinking === '' ||
91
+ (this.pendingThinking === null && this.committedThinking === '')) {
92
+ return;
93
+ }
94
+ this.pendingThinking = '';
95
+ this.scheduleFrame();
96
+ }
97
+ /** Publish every pending delta immediately, canceling any scheduled frame. */
98
+ flush() {
99
+ if (this.disposed)
100
+ return;
101
+ this.cancelScheduledFrame();
102
+ this.publishPending();
103
+ }
104
+ /** Cancel pending schedules and ignore all later appends. */
105
+ dispose() {
106
+ if (this.disposed)
107
+ return;
108
+ this.disposed = true;
109
+ this.cancelScheduledFrame();
110
+ this.pendingText = null;
111
+ this.pendingThinking = null;
112
+ }
113
+ scheduleFrame() {
114
+ if (this.scheduled || this.disposed)
115
+ return;
116
+ this.scheduled = true;
117
+ this.scheduledHandle = this.scheduler.schedule(() => {
118
+ this.scheduled = false;
119
+ this.scheduledHandle = undefined;
120
+ this.publishPending();
121
+ }, this.frameIntervalMs);
122
+ }
123
+ cancelScheduledFrame() {
124
+ if (this.scheduledHandle !== undefined) {
125
+ this.scheduler.cancel(this.scheduledHandle);
126
+ this.scheduledHandle = undefined;
127
+ this.scheduled = false;
128
+ }
129
+ }
130
+ publishPending() {
131
+ if (this.disposed)
132
+ return;
133
+ if (this.pendingText === null && this.pendingThinking === null)
134
+ return;
135
+ if (this.pendingText !== null)
136
+ this.committedText = this.pendingText;
137
+ if (this.pendingThinking !== null) {
138
+ this.committedThinking = this.pendingThinking;
139
+ }
140
+ this.pendingText = null;
141
+ this.pendingThinking = null;
142
+ this.publishFrame({
143
+ text: this.committedText,
144
+ thinking: this.committedThinking,
145
+ });
146
+ }
147
+ }
148
+ //# sourceMappingURL=streaming-frame-buffer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.21.1",
3
+ "version": "0.21.3",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",