praxis-agent 0.21.2 → 0.21.4
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 +59 -15
- package/dist/cli/tui/claude-style.d.ts +6 -0
- package/dist/cli/tui/claude-style.js +39 -1
- package/dist/cli/tui/streaming-frame-buffer.d.ts +76 -0
- package/dist/cli/tui/streaming-frame-buffer.js +148 -0
- package/dist/cli/tui/transcript-viewport.d.ts +34 -0
- package/dist/cli/tui/transcript-viewport.js +156 -0
- package/package.json +1 -1
package/dist/cli/interactive.js
CHANGED
|
@@ -14,7 +14,9 @@ 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';
|
|
19
|
+
import { FULLSCREEN_TRANSCRIPT_RESERVED_ROWS, projectTranscriptTail, } from './tui/transcript-viewport.js';
|
|
18
20
|
import { loadGitDiff, visiblePatchLines, } from './tui/git-diff.js';
|
|
19
21
|
import { addTuiPermissionRule, loadTuiPermissionRules, removeTuiPermissionRule, } from './tui/permission-settings.js';
|
|
20
22
|
import { createRecentlyDeniedStore, } from './tui/recently-denied.js';
|
|
@@ -409,6 +411,20 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
409
411
|
const [status, setStatus] = useState('ready');
|
|
410
412
|
const [activeText, setActiveText] = useState('');
|
|
411
413
|
const [activeThinking, setActiveThinking] = useState('');
|
|
414
|
+
// One provider-neutral streaming frame buffer per mounted app. RuntimeEvent
|
|
415
|
+
// text/thinking deltas accumulate here and are coalesced into bounded frames
|
|
416
|
+
// instead of causing a React state update per delta. The React state is only
|
|
417
|
+
// ever written through the buffer's publish callback so the buffer's committed
|
|
418
|
+
// prefix and the displayed text stay in sync. Disposed on unmount.
|
|
419
|
+
const streamingFrameRef = useRef(null);
|
|
420
|
+
if (streamingFrameRef.current === null) {
|
|
421
|
+
streamingFrameRef.current = new StreamingFrameBuffer({
|
|
422
|
+
publish: (frame) => {
|
|
423
|
+
setActiveText(frame.text);
|
|
424
|
+
setActiveThinking(frame.thinking);
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
}
|
|
412
428
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
|
413
429
|
const [turnDuration, setTurnDuration] = useState();
|
|
414
430
|
const [usage, setUsage] = useState();
|
|
@@ -432,6 +448,13 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
432
448
|
// conversation content appears.
|
|
433
449
|
const resumed = resume !== undefined && resumedWithTranscript;
|
|
434
450
|
const freshSession = !resumed && !hasConversationHistory;
|
|
451
|
+
// Fullscreen projects only the newest transcript tail that fits the fixed
|
|
452
|
+
// viewport, leaving the composer/status chrome intact and keeping the active
|
|
453
|
+
// stream visible. Classic and screen-reader modes always render the full
|
|
454
|
+
// history exactly as before.
|
|
455
|
+
const projectedHistory = fixedViewport && !axScreenReader
|
|
456
|
+
? projectTranscriptTail(history, Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), width)
|
|
457
|
+
: history;
|
|
435
458
|
const sessionLoadRef = useRef(0);
|
|
436
459
|
const [turnDiffs, setTurnDiffs] = useState([]);
|
|
437
460
|
const turnNumberRef = useRef(0);
|
|
@@ -675,6 +698,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
675
698
|
}, [exit, signal]);
|
|
676
699
|
useEffect(() => () => {
|
|
677
700
|
componentMountedRef.current = false;
|
|
701
|
+
streamingFrameRef.current?.dispose();
|
|
678
702
|
permissionRef.current?.resolve(false);
|
|
679
703
|
elicitationRef.current?.resolve({ action: 'cancel' });
|
|
680
704
|
questionRef.current?.resolve(null);
|
|
@@ -692,7 +716,13 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
692
716
|
else
|
|
693
717
|
void closing.catch(() => undefined);
|
|
694
718
|
}, []);
|
|
695
|
-
const append = (line) =>
|
|
719
|
+
const append = (line) => {
|
|
720
|
+
// Any unflushed streaming deltas must be published before the boundary
|
|
721
|
+
// transcript state so the active stream never renders below a tool,
|
|
722
|
+
// thinking, or completion entry that it textually precedes.
|
|
723
|
+
streamingFrameRef.current?.flush();
|
|
724
|
+
setHistory((current) => [...current, line]);
|
|
725
|
+
};
|
|
696
726
|
useEffect(() => {
|
|
697
727
|
if (initialThemeSettings !== undefined) {
|
|
698
728
|
if (initialThemeLoadError)
|
|
@@ -1111,28 +1141,33 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
1111
1141
|
const handleEvent = (event) => {
|
|
1112
1142
|
switch (event.type) {
|
|
1113
1143
|
case 'text-delta':
|
|
1114
|
-
|
|
1144
|
+
streamingFrameRef.current?.appendText(event.delta);
|
|
1115
1145
|
break;
|
|
1116
1146
|
case 'thinking-start':
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1147
|
+
streamingFrameRef.current?.resetThinking();
|
|
1148
|
+
if (event.block.type === 'thinking') {
|
|
1149
|
+
streamingFrameRef.current?.appendThinking(redactSensitiveText(event.block.thinking, sensitiveValues));
|
|
1150
|
+
}
|
|
1120
1151
|
break;
|
|
1121
1152
|
case 'thinking-delta':
|
|
1122
|
-
|
|
1153
|
+
streamingFrameRef.current?.appendThinking(redactSensitiveText(event.delta, sensitiveValues));
|
|
1123
1154
|
break;
|
|
1124
1155
|
case 'thinking-signature-delta':
|
|
1125
1156
|
// Signatures authenticate a thinking block for provider replay; they are
|
|
1126
1157
|
// intentionally not part of the user-visible reasoning summary.
|
|
1127
1158
|
break;
|
|
1128
1159
|
case 'thinking-stop':
|
|
1160
|
+
// append flushes pending thinking deltas before the retained boundary
|
|
1161
|
+
// item, keeping streaming order correct; the effective thinking getter
|
|
1162
|
+
// already includes any deltas not yet published.
|
|
1129
1163
|
append({
|
|
1130
1164
|
kind: 'thinking',
|
|
1131
1165
|
text: event.block.type === 'thinking'
|
|
1132
1166
|
? redactSensitiveText(event.block.thinking, sensitiveValues)
|
|
1133
|
-
:
|
|
1167
|
+
: (streamingFrameRef.current?.thinking ?? ''),
|
|
1134
1168
|
});
|
|
1135
|
-
|
|
1169
|
+
streamingFrameRef.current?.resetThinking();
|
|
1170
|
+
streamingFrameRef.current?.flush();
|
|
1136
1171
|
break;
|
|
1137
1172
|
case 'user-message':
|
|
1138
1173
|
append({ kind: 'assistant', text: event.message });
|
|
@@ -2575,8 +2610,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
2575
2610
|
? allSlashCommands.find((command) => command.name.toLowerCase() === submittedCommandName)?.progressMessage
|
|
2576
2611
|
: undefined;
|
|
2577
2612
|
setStatus(commandProgressMessage ?? 'assembling-context');
|
|
2578
|
-
|
|
2579
|
-
|
|
2613
|
+
streamingFrameRef.current?.resetText();
|
|
2614
|
+
streamingFrameRef.current?.resetThinking();
|
|
2615
|
+
streamingFrameRef.current?.flush();
|
|
2580
2616
|
if (runtimeSettingsRef.current.tips && !commandProgressMessage) {
|
|
2581
2617
|
setStatus(spinnerTip(runtimeSettingsRef.current) ?? 'assembling-context');
|
|
2582
2618
|
}
|
|
@@ -2641,8 +2677,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
2641
2677
|
// Diff snapshots are a local presentation aid and must not fail a turn.
|
|
2642
2678
|
}
|
|
2643
2679
|
}
|
|
2644
|
-
|
|
2645
|
-
|
|
2680
|
+
streamingFrameRef.current?.resetText();
|
|
2681
|
+
streamingFrameRef.current?.resetThinking();
|
|
2682
|
+
streamingFrameRef.current?.flush();
|
|
2646
2683
|
setStatus('ready');
|
|
2647
2684
|
setTurnDuration(Date.now() - turnStartedAt);
|
|
2648
2685
|
if (runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
|
|
@@ -5131,8 +5168,9 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
5131
5168
|
setHistory([]);
|
|
5132
5169
|
setUsage(undefined);
|
|
5133
5170
|
setCostUsd(undefined);
|
|
5134
|
-
|
|
5135
|
-
|
|
5171
|
+
streamingFrameRef.current?.resetText();
|
|
5172
|
+
streamingFrameRef.current?.resetThinking();
|
|
5173
|
+
streamingFrameRef.current?.flush();
|
|
5136
5174
|
setThinkingExpanded(false);
|
|
5137
5175
|
setStatus('ready');
|
|
5138
5176
|
inputHistoryRef.current = [];
|
|
@@ -5647,7 +5685,13 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
|
|
|
5647
5685
|
});
|
|
5648
5686
|
return (_jsx(TuiThemeProvider, { settings: themeSettings, children: _jsx(Box, { flexDirection: "column", ...(!fixedViewport
|
|
5649
5687
|
? {}
|
|
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(
|
|
5688
|
+
: { 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
|
|
5689
|
+
? {
|
|
5690
|
+
flexShrink: 1,
|
|
5691
|
+
minHeight: 0,
|
|
5692
|
+
overflowY: 'hidden',
|
|
5693
|
+
}
|
|
5694
|
+
: {}), children: _jsx(Transcript, { items: projectedHistory, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }) }), externalEditorRequest !== null ||
|
|
5651
5695
|
keybindingsEditing ||
|
|
5652
5696
|
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
5697
|
(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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TranscriptItem } from './claude-style.js';
|
|
2
|
+
/**
|
|
3
|
+
* Rows reserved outside the shrinkable fullscreen transcript region: the top
|
|
4
|
+
* identity chrome, the composer separator/prompt/footer, and the status line,
|
|
5
|
+
* plus a small headroom so the active streaming tail stays visible below the
|
|
6
|
+
* projected history suffix. Fullscreen computes the transcript budget as
|
|
7
|
+
* `rows - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const FULLSCREEN_TRANSCRIPT_RESERVED_ROWS = 12;
|
|
10
|
+
/**
|
|
11
|
+
* Compact marker prepended to the projected tail of an oversized newest item so
|
|
12
|
+
* the truncation is visually explicit while the newest lines remain visible.
|
|
13
|
+
*/
|
|
14
|
+
export declare const TRANSCRIPT_TRUNCATION_MARKER = "\u2026";
|
|
15
|
+
/**
|
|
16
|
+
* Deterministic estimate of the terminal rows one transcript item occupies in
|
|
17
|
+
* the TUI presentation. It is intentionally conservative (leans toward
|
|
18
|
+
* overestimation) so the suffix projector keeps the newest content visible
|
|
19
|
+
* rather than dropping items that would actually render.
|
|
20
|
+
*/
|
|
21
|
+
export declare function estimateTranscriptLines(item: TranscriptItem, width: number): number;
|
|
22
|
+
/**
|
|
23
|
+
* Pure, deterministic suffix projector for the fullscreen transcript region.
|
|
24
|
+
*
|
|
25
|
+
* Given transcript items, a terminal row budget, and the usable width, it
|
|
26
|
+
* returns the longest suffix of items whose conservative line estimate fits
|
|
27
|
+
* the budget. The newest user/assistant content is always retained; if even
|
|
28
|
+
* the newest item exceeds the budget, that item is projected as a bounded tail
|
|
29
|
+
* clone rather than rendering an empty transcript (or clipping the tail below
|
|
30
|
+
* the viewport). Ordering and item object identity are preserved for items
|
|
31
|
+
* that fit and the input is never mutated.
|
|
32
|
+
*/
|
|
33
|
+
export declare function projectTranscriptTail(items: readonly TranscriptItem[], budget: number, width: number): readonly TranscriptItem[];
|
|
34
|
+
//# sourceMappingURL=transcript-viewport.d.ts.map
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows reserved outside the shrinkable fullscreen transcript region: the top
|
|
3
|
+
* identity chrome, the composer separator/prompt/footer, and the status line,
|
|
4
|
+
* plus a small headroom so the active streaming tail stays visible below the
|
|
5
|
+
* projected history suffix. Fullscreen computes the transcript budget as
|
|
6
|
+
* `rows - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS`.
|
|
7
|
+
*/
|
|
8
|
+
export const FULLSCREEN_TRANSCRIPT_RESERVED_ROWS = 12;
|
|
9
|
+
/**
|
|
10
|
+
* Conservative width-aware row estimate for a block of text. Blank lines still
|
|
11
|
+
* occupy a row, and every logical line wraps across `Math.ceil(length / width)`
|
|
12
|
+
* rows so the estimate leans high and never drops content that would actually
|
|
13
|
+
* fit.
|
|
14
|
+
*/
|
|
15
|
+
function wrappedLineCount(text, width) {
|
|
16
|
+
if (text === '')
|
|
17
|
+
return 1;
|
|
18
|
+
const usable = Math.max(1, width);
|
|
19
|
+
let rows = 0;
|
|
20
|
+
for (const line of text.split('\n')) {
|
|
21
|
+
rows += Math.max(1, Math.ceil(line.length / usable));
|
|
22
|
+
}
|
|
23
|
+
return rows;
|
|
24
|
+
}
|
|
25
|
+
function bounded(text, max) {
|
|
26
|
+
return text.length > max ? text.slice(0, max) : text;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compact marker prepended to the projected tail of an oversized newest item so
|
|
30
|
+
* the truncation is visually explicit while the newest lines remain visible.
|
|
31
|
+
*/
|
|
32
|
+
export const TRANSCRIPT_TRUNCATION_MARKER = '…';
|
|
33
|
+
/**
|
|
34
|
+
* Projects a text block to the longest trailing suffix (newest content) that
|
|
35
|
+
* fits `budget` terminal rows, prefixed with the truncation marker. The marker
|
|
36
|
+
* merges into the first kept line, so the result is exactly
|
|
37
|
+
* `${TRANSCRIPT_TRUNCATION_MARKER}${suffix}`. A text that already fits is
|
|
38
|
+
* returned unchanged.
|
|
39
|
+
*/
|
|
40
|
+
function projectTextTail(text, budget, width) {
|
|
41
|
+
if (wrappedLineCount(text, width) <= budget)
|
|
42
|
+
return text;
|
|
43
|
+
const lines = text.split('\n');
|
|
44
|
+
let start = lines.length;
|
|
45
|
+
// Wrapped rows of every kept line after the current front line (marker-free).
|
|
46
|
+
let rowsAfter = 0;
|
|
47
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
48
|
+
const line = lines[index];
|
|
49
|
+
if (line === undefined)
|
|
50
|
+
break;
|
|
51
|
+
// This line becomes the front of the tail and carries the marker.
|
|
52
|
+
if (rowsAfter +
|
|
53
|
+
wrappedLineCount(`${TRANSCRIPT_TRUNCATION_MARKER}${line}`, width) >
|
|
54
|
+
budget) {
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
start = index;
|
|
58
|
+
rowsAfter += wrappedLineCount(line, width);
|
|
59
|
+
}
|
|
60
|
+
return `${TRANSCRIPT_TRUNCATION_MARKER}${lines.slice(start).join('\n')}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Returns a cloned oversized text-bearing item whose text/summary is a bounded
|
|
64
|
+
* trailing suffix that fits `budget` rows, preserving the newest content and
|
|
65
|
+
* the item kind without mutating the input. Non-text display items (context,
|
|
66
|
+
* tool, tool-result, shell, shell-result) are returned unchanged.
|
|
67
|
+
*/
|
|
68
|
+
function projectOversizedItem(item, budget, width) {
|
|
69
|
+
switch (item.kind) {
|
|
70
|
+
case 'user':
|
|
71
|
+
case 'assistant':
|
|
72
|
+
case 'notice':
|
|
73
|
+
case 'warning':
|
|
74
|
+
case 'local-result':
|
|
75
|
+
return { ...item, text: projectTextTail(item.text, budget, width) };
|
|
76
|
+
case 'thinking':
|
|
77
|
+
return {
|
|
78
|
+
...item,
|
|
79
|
+
text: projectTextTail(item.text, Math.max(1, budget - 1), width),
|
|
80
|
+
};
|
|
81
|
+
case 'compact':
|
|
82
|
+
return {
|
|
83
|
+
...item,
|
|
84
|
+
summary: projectTextTail(item.summary, Math.max(1, budget - 1), width),
|
|
85
|
+
};
|
|
86
|
+
default:
|
|
87
|
+
return item;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Deterministic estimate of the terminal rows one transcript item occupies in
|
|
92
|
+
* the TUI presentation. It is intentionally conservative (leans toward
|
|
93
|
+
* overestimation) so the suffix projector keeps the newest content visible
|
|
94
|
+
* rather than dropping items that would actually render.
|
|
95
|
+
*/
|
|
96
|
+
export function estimateTranscriptLines(item, width) {
|
|
97
|
+
switch (item.kind) {
|
|
98
|
+
case 'user':
|
|
99
|
+
case 'assistant':
|
|
100
|
+
case 'notice':
|
|
101
|
+
case 'warning':
|
|
102
|
+
case 'local-result':
|
|
103
|
+
return wrappedLineCount(item.text, width);
|
|
104
|
+
case 'thinking':
|
|
105
|
+
return 1 + wrappedLineCount(item.text, width);
|
|
106
|
+
case 'compact':
|
|
107
|
+
return 1 + wrappedLineCount(item.summary, width);
|
|
108
|
+
case 'context':
|
|
109
|
+
return 12 + item.skills.length + item.memoryFiles.length;
|
|
110
|
+
case 'tool':
|
|
111
|
+
return 1 + (item.detail ? 1 : 0);
|
|
112
|
+
case 'tool-result':
|
|
113
|
+
return 1 + wrappedLineCount(bounded(item.text, 500), width);
|
|
114
|
+
case 'shell':
|
|
115
|
+
return 2 + wrappedLineCount(item.command, width);
|
|
116
|
+
case 'shell-result':
|
|
117
|
+
return wrappedLineCount(bounded(`${item.stdout}\n${item.stderr}`, 500), width);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Pure, deterministic suffix projector for the fullscreen transcript region.
|
|
122
|
+
*
|
|
123
|
+
* Given transcript items, a terminal row budget, and the usable width, it
|
|
124
|
+
* returns the longest suffix of items whose conservative line estimate fits
|
|
125
|
+
* the budget. The newest user/assistant content is always retained; if even
|
|
126
|
+
* the newest item exceeds the budget, that item is projected as a bounded tail
|
|
127
|
+
* clone rather than rendering an empty transcript (or clipping the tail below
|
|
128
|
+
* the viewport). Ordering and item object identity are preserved for items
|
|
129
|
+
* that fit and the input is never mutated.
|
|
130
|
+
*/
|
|
131
|
+
export function projectTranscriptTail(items, budget, width) {
|
|
132
|
+
if (items.length === 0)
|
|
133
|
+
return [];
|
|
134
|
+
const usableBudget = Math.max(1, budget);
|
|
135
|
+
let rows = 0;
|
|
136
|
+
let start = items.length;
|
|
137
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
138
|
+
const item = items[index];
|
|
139
|
+
if (item === undefined)
|
|
140
|
+
break;
|
|
141
|
+
const estimate = estimateTranscriptLines(item, width);
|
|
142
|
+
if (rows + estimate > usableBudget) {
|
|
143
|
+
// Stop at the first item that no longer fits. When no item has been
|
|
144
|
+
// added yet, even the newest item exceeds the budget: returning it whole
|
|
145
|
+
// would let Ink clip the oversized tail below the viewport, so project a
|
|
146
|
+
// bounded suffix clone that keeps the newest content visible instead.
|
|
147
|
+
return start === items.length
|
|
148
|
+
? [projectOversizedItem(item, usableBudget, width)]
|
|
149
|
+
: items.slice(start);
|
|
150
|
+
}
|
|
151
|
+
rows += estimate;
|
|
152
|
+
start = index;
|
|
153
|
+
}
|
|
154
|
+
return items.slice(start);
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=transcript-viewport.js.map
|