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.
@@ -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) => setHistory((current) => [...current, 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
- setActiveText((current) => current + event.delta);
1144
+ streamingFrameRef.current?.appendText(event.delta);
1115
1145
  break;
1116
1146
  case 'thinking-start':
1117
- setActiveThinking(event.block.type === 'thinking'
1118
- ? redactSensitiveText(event.block.thinking, sensitiveValues)
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
- setActiveThinking((current) => current + redactSensitiveText(event.delta, sensitiveValues));
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
- : activeThinking,
1167
+ : (streamingFrameRef.current?.thinking ?? ''),
1134
1168
  });
1135
- setActiveThinking('');
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
- setActiveText('');
2579
- setActiveThinking('');
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
- setActiveText('');
2645
- setActiveThinking('');
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
- setActiveText('');
5135
- setActiveThinking('');
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(Transcript, { items: history, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }), externalEditorRequest !== null ||
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.21.2",
3
+ "version": "0.21.4",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",