praxis-agent 0.21.2 → 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.
package/dist/cli/interactive.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
1136
|
+
streamingFrameRef.current?.appendText(event.delta);
|
|
1115
1137
|
break;
|
|
1116
1138
|
case 'thinking-start':
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
1159
|
+
: (streamingFrameRef.current?.thinking ?? ''),
|
|
1134
1160
|
});
|
|
1135
|
-
|
|
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
|
-
|
|
2579
|
-
|
|
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
|
-
|
|
2645
|
-
|
|
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
|
-
|
|
5135
|
-
|
|
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(
|
|
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
|
|
@@ -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
|