praxis-agent 0.21.3 → 0.21.5

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.
@@ -16,6 +16,7 @@ import { loadTuiMemoryFiles, openTuiMemoryFolder, } from './tui/memory-files.js'
16
16
  import { loadClaudeReleaseNotes } from './tui/release-notes.js';
17
17
  import { StreamingFrameBuffer } from './tui/streaming-frame-buffer.js';
18
18
  import { createClaudeStatusLineInput, StatusLine } from './tui/status-line.js';
19
+ import { FULLSCREEN_TRANSCRIPT_RESERVED_ROWS, projectTranscriptTail, projectTranscriptWindow, transcriptLineCount, } from './tui/transcript-viewport.js';
19
20
  import { loadGitDiff, visiblePatchLines, } from './tui/git-diff.js';
20
21
  import { addTuiPermissionRule, loadTuiPermissionRules, removeTuiPermissionRule, } from './tui/permission-settings.js';
21
22
  import { createRecentlyDeniedStore, } from './tui/recently-denied.js';
@@ -430,6 +431,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
430
431
  const [costUsd, setCostUsd] = useState();
431
432
  const [contextWindowTokens, setContextWindowTokens] = useState(display.contextWindowTokens);
432
433
  const [history, setHistory] = useState([...initialHistory]);
434
+ const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
433
435
  // Startup diagnostics are useful before the first prompt, but they are not
434
436
  // conversation history and must not suppress the new-session welcome panel.
435
437
  // Only real user/assistant transcript entries start a conversation; every
@@ -447,6 +449,15 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
447
449
  // conversation content appears.
448
450
  const resumed = resume !== undefined && resumedWithTranscript;
449
451
  const freshSession = !resumed && !hasConversationHistory;
452
+ // Fullscreen projects only the newest transcript tail that fits the fixed
453
+ // viewport, leaving the composer/status chrome intact and keeping the active
454
+ // stream visible. Classic and screen-reader modes always render the full
455
+ // history exactly as before.
456
+ const projectedHistory = fixedViewport && !axScreenReader
457
+ ? transcriptScrollOffset > 0
458
+ ? projectTranscriptWindow(history, Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), width, transcriptScrollOffset)
459
+ : projectTranscriptTail(history, Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), width)
460
+ : history;
450
461
  const sessionLoadRef = useRef(0);
451
462
  const [turnDiffs, setTurnDiffs] = useState([]);
452
463
  const turnNumberRef = useRef(0);
@@ -713,6 +724,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
713
724
  // transcript state so the active stream never renders below a tool,
714
725
  // thinking, or completion entry that it textually precedes.
715
726
  streamingFrameRef.current?.flush();
727
+ setTranscriptScrollOffset(0);
716
728
  setHistory((current) => [...current, line]);
717
729
  };
718
730
  useEffect(() => {
@@ -2582,6 +2594,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2582
2594
  void restoring.finally(() => onTurnChange?.(null));
2583
2595
  };
2584
2596
  const submit = async (prompt, shellCommand, images = []) => {
2597
+ setTranscriptScrollOffset(0);
2585
2598
  const turnNumber = turnNumberRef.current + 1;
2586
2599
  const turnStartedAt = Date.now();
2587
2600
  turnNumberRef.current = turnNumber;
@@ -2890,6 +2903,33 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2890
2903
  return;
2891
2904
  }
2892
2905
  const isKeybinding = (action) => keybindingAction === action;
2906
+ if (fixedViewport &&
2907
+ menuRef.current === null &&
2908
+ !permission &&
2909
+ !planApproval &&
2910
+ !question &&
2911
+ !elicitation &&
2912
+ !selectingSession) {
2913
+ const page = Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS);
2914
+ const scrollDelta = key.pageUp
2915
+ ? page
2916
+ : key.pageDown
2917
+ ? -page
2918
+ : controlKey('u') || controlKey('b')
2919
+ ? page / 2
2920
+ : controlKey('f') || controlKey('n')
2921
+ ? -page / 2
2922
+ : inputRef.current.length === 0 && key.upArrow
2923
+ ? 1
2924
+ : inputRef.current.length === 0 && key.downArrow
2925
+ ? -1
2926
+ : 0;
2927
+ if (scrollDelta !== 0) {
2928
+ setTranscriptScrollOffset((current) => Math.min(Math.max(0, transcriptLineCount(history, width) -
2929
+ Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS)), Math.max(0, current + Math.trunc(scrollDelta))));
2930
+ return;
2931
+ }
2932
+ }
2893
2933
  if (runtimeSettingsRef.current.editor === 'vim' &&
2894
2934
  !permission &&
2895
2935
  !planApproval &&
@@ -5683,7 +5723,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
5683
5723
  minHeight: 0,
5684
5724
  overflowY: 'hidden',
5685
5725
  }
5686
- : {}), children: _jsx(Transcript, { items: history, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }) }), externalEditorRequest !== null ||
5726
+ : {}), children: _jsx(Transcript, { items: projectedHistory, activeText: activeText, activeThinking: activeThinking, thinkingExpanded: thinkingExpanded, detailedTranscript: thinkingExpanded || runtimeSettings.verbose, screenReader: axScreenReader }) }), externalEditorRequest !== null ||
5687
5727
  keybindingsEditing ||
5688
5728
  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 ||
5689
5729
  (permissionSelection === 0
@@ -0,0 +1,37 @@
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
+ export declare function transcriptLineCount(items: readonly TranscriptItem[], width: number): number;
23
+ /**
24
+ * Pure, deterministic suffix projector for the fullscreen transcript region.
25
+ *
26
+ * Given transcript items, a terminal row budget, and the usable width, it
27
+ * returns the longest suffix of items whose conservative line estimate fits
28
+ * the budget. The newest user/assistant content is always retained; if even
29
+ * the newest item exceeds the budget, that item is projected as a bounded tail
30
+ * clone rather than rendering an empty transcript (or clipping the tail below
31
+ * the viewport). Ordering and item object identity are preserved for items
32
+ * that fit and the input is never mutated.
33
+ */
34
+ export declare function projectTranscriptTail(items: readonly TranscriptItem[], budget: number, width: number): readonly TranscriptItem[];
35
+ /** Projects a fixed-size transcript window, measured upward from the newest row. */
36
+ export declare function projectTranscriptWindow(items: readonly TranscriptItem[], budget: number, width: number, scrollOffset: number): readonly TranscriptItem[];
37
+ //# sourceMappingURL=transcript-viewport.d.ts.map
@@ -0,0 +1,184 @@
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
+ export function transcriptLineCount(items, width) {
121
+ return items.reduce((total, item) => total + estimateTranscriptLines(item, width), 0);
122
+ }
123
+ /**
124
+ * Pure, deterministic suffix projector for the fullscreen transcript region.
125
+ *
126
+ * Given transcript items, a terminal row budget, and the usable width, it
127
+ * returns the longest suffix of items whose conservative line estimate fits
128
+ * the budget. The newest user/assistant content is always retained; if even
129
+ * the newest item exceeds the budget, that item is projected as a bounded tail
130
+ * clone rather than rendering an empty transcript (or clipping the tail below
131
+ * the viewport). Ordering and item object identity are preserved for items
132
+ * that fit and the input is never mutated.
133
+ */
134
+ export function projectTranscriptTail(items, budget, width) {
135
+ if (items.length === 0)
136
+ return [];
137
+ const usableBudget = Math.max(1, budget);
138
+ let rows = 0;
139
+ let start = items.length;
140
+ for (let index = items.length - 1; index >= 0; index -= 1) {
141
+ const item = items[index];
142
+ if (item === undefined)
143
+ break;
144
+ const estimate = estimateTranscriptLines(item, width);
145
+ if (rows + estimate > usableBudget) {
146
+ // Stop at the first item that no longer fits. When no item has been
147
+ // added yet, even the newest item exceeds the budget: returning it whole
148
+ // would let Ink clip the oversized tail below the viewport, so project a
149
+ // bounded suffix clone that keeps the newest content visible instead.
150
+ return start === items.length
151
+ ? [projectOversizedItem(item, usableBudget, width)]
152
+ : items.slice(start);
153
+ }
154
+ rows += estimate;
155
+ start = index;
156
+ }
157
+ return items.slice(start);
158
+ }
159
+ /** Projects a fixed-size transcript window, measured upward from the newest row. */
160
+ export function projectTranscriptWindow(items, budget, width, scrollOffset) {
161
+ if (scrollOffset <= 0)
162
+ return projectTranscriptTail(items, budget, width);
163
+ const endRows = Math.max(0, transcriptLineCount(items, width) - scrollOffset);
164
+ const startRows = Math.max(0, endRows - budget);
165
+ let rows = 0;
166
+ let start = 0;
167
+ let end = 0;
168
+ for (let index = 0; index < items.length; index += 1) {
169
+ const item = items[index];
170
+ if (!item)
171
+ break;
172
+ const nextRows = rows + estimateTranscriptLines(item, width);
173
+ if (nextRows <= startRows) {
174
+ start = index + 1;
175
+ }
176
+ if (rows < endRows)
177
+ end = index + 1;
178
+ rows = nextRows;
179
+ if (rows >= endRows)
180
+ break;
181
+ }
182
+ return items.slice(start, end);
183
+ }
184
+ //# sourceMappingURL=transcript-viewport.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",