praxis-agent 0.22.0 → 0.23.1

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.
@@ -25,8 +25,11 @@ export interface SessionMemoryStoreOptions {
25
25
  sidecarRoot?: string;
26
26
  }
27
27
  /**
28
- * Durable sidecar for one session's extracted memory under
29
- * `<configRoot>/praxis/session-memory/<sessionId>/`. All writes are atomic
28
+ * Durable sidecar for one session's extracted memory. Claude compatibility
29
+ * defaults to `<configRoot>/praxis/session-memory/<sessionId>/`; callers may
30
+ * select another data plane with
31
+ * `<sidecarRoot>/session-memory/<sessionId>/` (for example, native
32
+ * `<configRoot>/state/session-memory/<sessionId>/`). All writes are atomic
30
33
  * (same-directory temp file, fsync, then rename) and version-checked. Never
31
34
  * touches shared Claude transcript entries.
32
35
  */
@@ -77,8 +77,11 @@ function parseSessionMemoryState(source) {
77
77
  return value;
78
78
  }
79
79
  /**
80
- * Durable sidecar for one session's extracted memory under
81
- * `<configRoot>/praxis/session-memory/<sessionId>/`. All writes are atomic
80
+ * Durable sidecar for one session's extracted memory. Claude compatibility
81
+ * defaults to `<configRoot>/praxis/session-memory/<sessionId>/`; callers may
82
+ * select another data plane with
83
+ * `<sidecarRoot>/session-memory/<sessionId>/` (for example, native
84
+ * `<configRoot>/state/session-memory/<sessionId>/`). All writes are atomic
82
85
  * (same-directory temp file, fsync, then rename) and version-checked. Never
83
86
  * touches shared Claude transcript entries.
84
87
  */
@@ -15,9 +15,11 @@ import { redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/se
15
15
  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';
16
16
  import { loadTuiMemoryFiles, openTuiMemoryFolder, } from './tui/memory-files.js';
17
17
  import { loadClaudeReleaseNotes } from './tui/release-notes.js';
18
+ import { fullscreenInkRenderOptions } from './tui/fullscreen-renderer.js';
18
19
  import { StreamingFrameBuffer } from './tui/streaming-frame-buffer.js';
19
20
  import { createClaudeStatusLineInput, StatusLine } from './tui/status-line.js';
20
- import { FULLSCREEN_TRANSCRIPT_RESERVED_ROWS, projectTranscriptTail, projectTranscriptWindow, transcriptLineCount, } from './tui/transcript-viewport.js';
21
+ import { FULLSCREEN_TRANSCRIPT_RESERVED_ROWS, transcriptLineCount, } from './tui/transcript-viewport.js';
22
+ import { projectTuiView, resolveTuiRenderer } from './tui/tui-view-model.js';
21
23
  import { loadGitDiff, visiblePatchLines, } from './tui/git-diff.js';
22
24
  import { addTuiPermissionRule, loadTuiPermissionRules, removeTuiPermissionRule, } from './tui/permission-settings.js';
23
25
  import { createRecentlyDeniedStore, } from './tui/recently-denied.js';
@@ -26,6 +28,7 @@ import { filterTuiSlashCommands, mergeTuiSlashCommands, slashCommandQuery, } fro
26
28
  import { runDoctor, } from '../maintenance/doctor.js';
27
29
  import { canonicalClaudeCostModelName, formatCostSummary, } from './tui/cost-summary.js';
28
30
  import { createComposerEditor, deleteComposerBackward, deleteComposerForward, deleteComposerToEnd, deleteComposerToStart, deleteComposerWordBackward, insertComposerText, moveComposerCursor, moveComposerCursorByWord, } from './tui/composer-editor.js';
31
+ import { routeComposerKey, } from './tui/composer-key-router.js';
29
32
  import { applyMentionReference, fileReferenceAtCursor, filterTuiMentionEntries, loadTuiFileEntries, } from './tui/file-picker.js';
30
33
  import { editTuiPrompt, openTuiEditorFile, } from './tui/external-editor.js';
31
34
  import { suspendTuiProcess } from './tui/terminal-suspend.js';
@@ -447,32 +450,31 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
447
450
  const [contextWindowTokens, setContextWindowTokens] = useState(display.contextWindowTokens);
448
451
  const [history, setHistory] = useState([...initialHistory]);
449
452
  const [transcriptScrollOffset, setTranscriptScrollOffset] = useState(0);
450
- // Startup diagnostics are useful before the first prompt, but they are not
451
- // conversation history and must not suppress the new-session welcome panel.
452
- // Only real user/assistant transcript entries start a conversation; every
453
- // other kind (thinking, context, tool, shell, notices, results, and so on)
454
- // is operational bookkeeping that must not hide the fresh-session welcome.
455
- const isRealConversation = (item) => item.kind === 'user' || item.kind === 'assistant';
456
- // The original loaded transcript decides whether the session was resumed,
457
- // separately from the live history that grows while the session runs.
458
- const resumedWithTranscript = initialHistory.some(isRealConversation);
459
- const hasConversationHistory = history.some(isRealConversation);
460
- // A session is resumed only when it was opened through `resume` and the
461
- // original transcript already contained real conversation content. Supplying
462
- // a session ID alone with an empty transcript keeps the session fresh, so the
463
- // full welcome panel renders and the compact identity stays hidden until real
464
- // conversation content appears.
465
- const resumed = resume !== undefined && resumedWithTranscript;
466
- const freshSession = !resumed && !hasConversationHistory;
467
- // Fullscreen projects only the newest transcript tail that fits the fixed
468
- // viewport, leaving the composer/status chrome intact and keeping the active
469
- // stream visible. Classic and screen-reader modes always render the full
470
- // history exactly as before.
471
- const projectedHistory = fixedViewport && !axScreenReader
472
- ? transcriptScrollOffset > 0
473
- ? projectTranscriptWindow(history, Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), width, transcriptScrollOffset)
474
- : projectTranscriptTail(history, Math.max(1, (rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), width)
475
- : history;
453
+ // The pure TUI view model classifies the session identity (fresh/resumed/
454
+ // started) and projects the rendered transcript. Startup diagnostics are
455
+ // useful before the first prompt, but they are not conversation history and
456
+ // must not suppress the new-session welcome panel. Only real user/assistant
457
+ // transcript entries start a conversation; every other kind (thinking,
458
+ // context, tool, shell, notices, results, and so on) is operational
459
+ // bookkeeping that must not hide the fresh-session welcome. The original
460
+ // loaded transcript decides whether the session was resumed, separately from
461
+ // the live history that grows while the session runs. A session is resumed
462
+ // only when it was opened through `resume` and the original transcript
463
+ // already contained real conversation content. Fullscreen projects only the
464
+ // newest transcript tail that fits the fixed viewport, leaving the
465
+ // composer/status chrome intact and keeping the active stream visible.
466
+ // Classic and screen-reader modes always render the full history exactly as
467
+ // before.
468
+ const { projectedHistory, resumed, freshSession, hasConversationHistory } = projectTuiView({
469
+ initialHistory,
470
+ history,
471
+ resume: resume !== undefined,
472
+ fixedViewport,
473
+ screenReader: axScreenReader,
474
+ rows,
475
+ width,
476
+ scrollOffset: transcriptScrollOffset,
477
+ });
476
478
  const sessionLoadRef = useRef(0);
477
479
  const [turnDiffs, setTurnDiffs] = useState([]);
478
480
  const turnNumberRef = useRef(0);
@@ -2691,6 +2693,14 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
2691
2693
  }
2692
2694
  setUsage(result.usage);
2693
2695
  setCostUsd(result.costUsd);
2696
+ // The active stream and its committed final item must never render in the
2697
+ // same frame. Publish the cleared stream before appending the final
2698
+ // assistant entry; there is no await between these operations, and the
2699
+ // renderer's frame buffer prevents the identical text from being shown
2700
+ // twice during the transition.
2701
+ streamingFrameRef.current?.resetText();
2702
+ streamingFrameRef.current?.resetThinking();
2703
+ streamingFrameRef.current?.flush();
2694
2704
  append({ kind: 'assistant', text: result.text });
2695
2705
  if (turnMutatedFilesRef.current) {
2696
2706
  try {
@@ -5735,7 +5745,22 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5735
5745
  clearComposerInput();
5736
5746
  return;
5737
5747
  }
5738
- editComposer();
5748
+ const transition = routeComposerKey(editor(), {
5749
+ value,
5750
+ left: key.leftArrow,
5751
+ right: key.rightArrow,
5752
+ backspace: key.backspace,
5753
+ delete: key.delete,
5754
+ ctrl: key.ctrl,
5755
+ meta: key.meta,
5756
+ escape: key.escape || value === '\u001B',
5757
+ });
5758
+ if (transition.kind === 'cancel') {
5759
+ clearComposerInput();
5760
+ }
5761
+ else if (transition.kind === 'edit') {
5762
+ updateComposerEditor(transition.editor);
5763
+ }
5739
5764
  });
5740
5765
  return (_jsx(TuiThemeProvider, { settings: themeSettings, children: _jsx(Box, { flexDirection: "column", ...(!fixedViewport
5741
5766
  ? {}
@@ -5884,6 +5909,22 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5884
5909
  usage?.outputTokens,
5885
5910
  ].join(':'), width: width, ...(settingSources === undefined ? {} : { settingSources }) })] }))] })) }) }));
5886
5911
  }
5912
+ /**
5913
+ * Whether the user explicitly saved a `tui` renderer value in configuration.
5914
+ * A fresh install leaves the setting unset, so the interactive TTY session can
5915
+ * default to the fullscreen renderer; once the user runs `/tui default` or
5916
+ * `/tui fullscreen`, the saved value is honored. If configuration cannot be
5917
+ * read, the loaded runtime setting is honored instead of guessing.
5918
+ */
5919
+ async function tuiRendererExplicitlyConfigured(target) {
5920
+ try {
5921
+ const snapshot = await loadConfigSettings(target);
5922
+ return snapshot.settings.tui !== undefined;
5923
+ }
5924
+ catch {
5925
+ return true;
5926
+ }
5927
+ }
5887
5928
  export async function runInteractive(options) {
5888
5929
  const controller = new AbortController();
5889
5930
  const signal = options.signal
@@ -5903,7 +5944,19 @@ export async function runInteractive(options) {
5903
5944
  const runtimeSettingsTarget = { configRoot, statePath };
5904
5945
  let currentRuntimeSettings = options.runtimeSettings ??
5905
5946
  (await loadRuntimeSettings(runtimeSettingsTarget));
5906
- let currentRenderer = currentRuntimeSettings.tui;
5947
+ // Fullscreen is the default interactive TTY renderer. Classic remains the
5948
+ // fallback for screen-reader and non-interactive execution, and an explicit
5949
+ // renderer saved in configuration is honored over the default.
5950
+ const rendererExplicitlyConfigured = await tuiRendererExplicitlyConfigured(runtimeSettingsTarget);
5951
+ let currentRenderer = resolveTuiRenderer({
5952
+ configured: currentRuntimeSettings.tui,
5953
+ explicitlyConfigured: rendererExplicitlyConfigured,
5954
+ interactiveTty: process.stdin.isTTY === true,
5955
+ screenReader: options.axScreenReader ?? false,
5956
+ });
5957
+ if (currentRenderer !== currentRuntimeSettings.tui) {
5958
+ currentRuntimeSettings = { ...currentRuntimeSettings, tui: currentRenderer };
5959
+ }
5907
5960
  let rendererChange = null;
5908
5961
  let rendererNotice;
5909
5962
  const readRendererChange = () => rendererChange;
@@ -6026,9 +6079,8 @@ export async function runInteractive(options) {
6026
6079
  ? { allowDangerouslySkipPermissions: true }
6027
6080
  : {}) }), {
6028
6081
  exitOnCtrlC: false,
6029
- incrementalRendering: !options.axScreenReader,
6082
+ ...fullscreenInkRenderOptions(currentRenderer, options.axScreenReader),
6030
6083
  interactive: true,
6031
- alternateScreen: currentRenderer === 'fullscreen',
6032
6084
  });
6033
6085
  await instance.waitUntilExit();
6034
6086
  if (activeTurn)
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { cloneElement, useEffect, useState } from 'react';
3
3
  import { Box, Text, useStdout } from 'ink';
4
4
  import { composerEditorSegments } from './composer-editor.js';
5
+ import { composerLayoutForWidth } from './composer-layout.js';
5
6
  import { visiblePatchLines } from './git-diff.js';
6
7
  import { TUI_HOOK_MENU } from './hook-settings.js';
7
8
  import { tuiPalette, tuiSyntaxStyle, useTuiPalette, } from './theme.js';
@@ -379,7 +380,9 @@ function ToolTranscriptEntry({ call, detail, result, detailed, }) {
379
380
  : displayResult;
380
381
  const oldLines = contentLines(inputString(call, 'old_string'));
381
382
  const newLines = contentLines(inputString(call, 'new_string'));
382
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: palette.accent, children: "\u23FA" }), ' ', _jsx(Text, { bold: true, children: toolHeading(call) })] }), !['Bash', 'Read', 'Edit'].includes(call.name) && detail ? (_jsxs(Text, { dimColor: true, children: [" ", detail] })) : null, result ? (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: result.isError ? (_jsxs(Text, { color: palette.error, children: ["\u23BF Error: ", errorText] })) : call.name === 'Edit' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["\u23BF Added ", newLines.length, " line", newLines.length === 1 ? '' : 's', ", removed ", oldLines.length, " line", oldLines.length === 1 ? '' : 's'] }), oldLines.map((line, index) => (_jsx(SyntaxCodeLine, { prefix: ` ${index + 1} -`, text: line, change: "removed" }, `old-${index}`))), newLines.map((line, index) => (_jsx(SyntaxCodeLine, { prefix: ` ${index + 1} +`, text: line, change: "added" }, `new-${index}`)))] })) : resultIsDiff ? (_jsx(ToolResultText, { text: visible.join('\n'), prefix: "\u23BF " })) : (_jsxs(_Fragment, { children: [visible.map((line, index) => (_jsxs(Text, { dimColor: true, children: [index === 0 ? '⎿ ' : ' ', line || ' '] }, index))), hidden > 0 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u2026 +", hidden, " lines (ctrl+o to expand)"] })) : null] })) })) : null] }));
383
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: palette.accent, children: "\u23FA" }), ' ', _jsx(Text, { bold: true, children: toolHeading(call) })] }), !['Bash', 'Read', 'Edit'].includes(call.name) && detail ? (_jsxs(Text, { dimColor: true, children: [" ", detail] })) : null, result ? (_jsx(Box, { marginLeft: 2, flexDirection: "column", children: result.isError ? (_jsxs(Text, { color: palette.error, children: ["\u23BF Error: ", errorText] })) : call.name === 'Edit' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["\u23BF Added ", newLines.length, " line", newLines.length === 1 ? '' : 's', ", removed ", oldLines.length, " line", oldLines.length === 1 ? '' : 's'] }), detailed &&
384
+ oldLines.map((line, index) => (_jsx(SyntaxCodeLine, { prefix: ` ${index + 1} -`, text: line, change: "removed" }, `old-${index}`))), detailed &&
385
+ newLines.map((line, index) => (_jsx(SyntaxCodeLine, { prefix: ` ${index + 1} +`, text: line, change: "added" }, `new-${index}`)))] })) : resultIsDiff ? (_jsx(ToolResultText, { text: visible.join('\n'), prefix: "\u23BF " })) : (_jsxs(_Fragment, { children: [visible.map((line, index) => (_jsxs(Text, { dimColor: true, children: [index === 0 ? '⎿ ' : ' ', line || ' '] }, index))), hidden > 0 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u2026 +", hidden, " lines (ctrl+o to expand)"] })) : null] })) })) : null] }));
383
386
  }
384
387
  function ThinkingBlock({ text, active, expanded, screenReader, }) {
385
388
  const palette = useTuiPalette();
@@ -976,9 +979,9 @@ export function Composer({ input, cursor, busy, clipboardBusy = false, status, d
976
979
  : shellMode
977
980
  ? `Shell command: ${input}`
978
981
  : `Prompt: ${input}` }));
979
- const line = '─'.repeat(Math.max(12, Math.min(100, width)));
982
+ const { lineWidth, footerWidth, showEditorHint } = composerLayoutForWidth(width);
983
+ const line = '─'.repeat(lineWidth);
980
984
  const separatorColor = sessionColor === undefined ? undefined : palette.sessionColors[sessionColor];
981
- const footerWidth = Math.min(100, width);
982
985
  const footerMode = `⏵⏵ ${permissionLabel(display.permissionMode)}`;
983
986
  const footerCompactMode = `⏵⏵ ${compactPermissionLabel(display.permissionMode)}`;
984
987
  const footerLeft = composerFooterLeft(footerWidth, busy, hasThinking, thinkingExpanded, footerMode, footerCompactMode);
@@ -995,7 +998,7 @@ export function Composer({ input, cursor, busy, clipboardBusy = false, status, d
995
998
  ? 'Enter a shell command'
996
999
  : 'Try "review this project"' }))] })), _jsx(Text, { ...(separatorColor === undefined
997
1000
  ? { dimColor: true }
998
- : { color: separatorColor }), children: line }), shortcutsVisible ? (_jsx(ShortcutHelp, { width: width })) : (_jsx(Box, { width: footerWidth, children: _jsx(Text, { wrap: "truncate", children: shellMode ? (_jsx(Text, { dimColor: true, children: "! for bash mode" })) : footerMessage ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerMessageLeft, " \u00B7 "] }), footerMessage.isError ? (_jsx(Text, { color: palette.error, children: footerMessage.text })) : (_jsx(Text, { dimColor: true, children: footerMessage.text }))] })) : prStatus ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerLeft, " \u00B7 "] }), _jsx(Text, { dimColor: true, children: prStatus })] })) : turnDuration ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerLeft, " \u00B7 "] }), _jsxs(Text, { dimColor: true, children: ["Cooked for ", turnDuration] })] })) : display.effort ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: footerLeft }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: palette.accent, children: ["\u25CF ", display.effort] }), footerWidth >= 100 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", editorMode === 'vim' ? 'vim' : '/effort'] })) : null] })) : (_jsx(Text, { dimColor: true, children: footerLeft })) }) }))] }));
1001
+ : { color: separatorColor }), children: line }), shortcutsVisible ? (_jsx(ShortcutHelp, { width: width })) : (_jsx(Box, { width: footerWidth, children: _jsx(Text, { wrap: "truncate", children: shellMode ? (_jsx(Text, { dimColor: true, children: "! for bash mode" })) : footerMessage ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerMessageLeft, " \u00B7 "] }), footerMessage.isError ? (_jsx(Text, { color: palette.error, children: footerMessage.text })) : (_jsx(Text, { dimColor: true, children: footerMessage.text }))] })) : prStatus ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerLeft, " \u00B7 "] }), _jsx(Text, { dimColor: true, children: prStatus })] })) : turnDuration ? (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [footerLeft, " \u00B7 "] }), _jsxs(Text, { dimColor: true, children: ["Cooked for ", turnDuration] })] })) : display.effort ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: footerLeft }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: palette.accent, children: ["\u25CF ", display.effort] }), showEditorHint ? (_jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", editorMode === 'vim' ? 'vim' : '/effort'] })) : null] })) : (_jsx(Text, { dimColor: true, children: footerLeft })) }) }))] }));
999
1002
  }
1000
1003
  export function DialogFrame({ title, children, screenReader, }) {
1001
1004
  const palette = useTuiPalette();
@@ -0,0 +1,31 @@
1
+ import { type ComposerEditorState } from './composer-editor.js';
2
+ /**
3
+ * Projection of an ink useInput key event that the pure composer router
4
+ * consumes. It deliberately omits terminal/resolution concerns so the
5
+ * router can never submit, exit, or resolve a modal.
6
+ */
7
+ export interface ComposerKeyProjection {
8
+ value: string;
9
+ left: boolean;
10
+ right: boolean;
11
+ backspace: boolean;
12
+ delete: boolean;
13
+ ctrl: boolean;
14
+ meta: boolean;
15
+ escape: boolean;
16
+ }
17
+ export type ComposerKeyTransition = {
18
+ kind: 'edit';
19
+ editor: ComposerEditorState;
20
+ } | {
21
+ kind: 'cancel';
22
+ } | {
23
+ kind: 'noop';
24
+ };
25
+ /**
26
+ * Pure normal-composer key transition router. Recognized editing input maps
27
+ * onto the existing composer-editor/composer-images primitives; Escape maps to
28
+ * a cancel result without mutating state; everything else is a noop.
29
+ */
30
+ export declare function routeComposerKey(editor: ComposerEditorState, key: ComposerKeyProjection): ComposerKeyTransition;
31
+ //# sourceMappingURL=composer-key-router.d.ts.map
@@ -0,0 +1,68 @@
1
+ import { createComposerEditor, deleteComposerToEnd, deleteComposerToStart, deleteComposerWordBackward, insertComposerText, moveComposerCursorByWord, } from './composer-editor.js';
2
+ import { deleteComposerImageBackward, deleteComposerImageForward, moveComposerCursorAcrossImages, } from './composer-images.js';
3
+ /**
4
+ * Pure normal-composer key transition router. Recognized editing input maps
5
+ * onto the existing composer-editor/composer-images primitives; Escape maps to
6
+ * a cancel result without mutating state; everything else is a noop.
7
+ */
8
+ export function routeComposerKey(editor, key) {
9
+ const state = createComposerEditor(editor.text, editor.cursor);
10
+ const lower = key.value.toLowerCase();
11
+ const controlKey = (letter) => (key.ctrl && lower === letter) ||
12
+ key.value === String.fromCharCode(letter.charCodeAt(0) - 96);
13
+ const printable = key.value.length > 0 &&
14
+ [...key.value].every((character) => {
15
+ const codePoint = character.codePointAt(0) ?? 0;
16
+ return codePoint >= 32 && codePoint !== 127;
17
+ });
18
+ if (key.escape)
19
+ return { kind: 'cancel' };
20
+ if (key.left) {
21
+ return {
22
+ kind: 'edit',
23
+ editor: key.meta
24
+ ? moveComposerCursorByWord(state, 'backward')
25
+ : moveComposerCursorAcrossImages(state, -1),
26
+ };
27
+ }
28
+ if (key.right) {
29
+ return {
30
+ kind: 'edit',
31
+ editor: key.meta
32
+ ? moveComposerCursorByWord(state, 'forward')
33
+ : moveComposerCursorAcrossImages(state, 1),
34
+ };
35
+ }
36
+ if (controlKey('a')) {
37
+ return { kind: 'edit', editor: createComposerEditor(state.text, 0) };
38
+ }
39
+ if (controlKey('e')) {
40
+ return { kind: 'edit', editor: createComposerEditor(state.text) };
41
+ }
42
+ if (controlKey('b')) {
43
+ return { kind: 'edit', editor: moveComposerCursorAcrossImages(state, -1) };
44
+ }
45
+ if (controlKey('f')) {
46
+ return { kind: 'edit', editor: moveComposerCursorAcrossImages(state, 1) };
47
+ }
48
+ if (controlKey('w')) {
49
+ return { kind: 'edit', editor: deleteComposerWordBackward(state) };
50
+ }
51
+ if (controlKey('u')) {
52
+ return { kind: 'edit', editor: deleteComposerToStart(state) };
53
+ }
54
+ if (controlKey('k')) {
55
+ return { kind: 'edit', editor: deleteComposerToEnd(state) };
56
+ }
57
+ if (key.backspace) {
58
+ return { kind: 'edit', editor: deleteComposerImageBackward(state) };
59
+ }
60
+ if (key.delete) {
61
+ return { kind: 'edit', editor: deleteComposerImageForward(state) };
62
+ }
63
+ if (!key.ctrl && !key.meta && key.value && printable) {
64
+ return { kind: 'edit', editor: insertComposerText(state, key.value) };
65
+ }
66
+ return { kind: 'noop' };
67
+ }
68
+ //# sourceMappingURL=composer-key-router.js.map
@@ -0,0 +1,7 @@
1
+ export interface ComposerLayout {
2
+ lineWidth: number;
3
+ footerWidth: number;
4
+ showEditorHint: boolean;
5
+ }
6
+ export declare function composerLayoutForWidth(width: number): ComposerLayout;
7
+ //# sourceMappingURL=composer-layout.d.ts.map
@@ -0,0 +1,13 @@
1
+ const MIN_COMPOSER_WIDTH = 12;
2
+ const MAX_COMPOSER_WIDTH = 100;
3
+ export function composerLayoutForWidth(width) {
4
+ const usableWidth = Number.isFinite(width)
5
+ ? Math.min(Math.max(width, MIN_COMPOSER_WIDTH), MAX_COMPOSER_WIDTH)
6
+ : MIN_COMPOSER_WIDTH;
7
+ return {
8
+ lineWidth: usableWidth,
9
+ footerWidth: usableWidth,
10
+ showEditorHint: usableWidth >= MAX_COMPOSER_WIDTH,
11
+ };
12
+ }
13
+ //# sourceMappingURL=composer-layout.js.map
@@ -0,0 +1,14 @@
1
+ import type { TuiRendererMode } from './tui-view-model.js';
2
+ /** Stable terminal-frame policy for the fullscreen renderer. */
3
+ export interface FullscreenInkRenderOptions {
4
+ incrementalRendering: boolean;
5
+ alternateScreen: boolean;
6
+ }
7
+ /**
8
+ * The Ink render options for an interactive session. Fullscreen uses a stable
9
+ * terminal-frame policy: incremental rendering is disabled (every frame is a
10
+ * full redraw that erases the previous viewport) while classic and screen-reader
11
+ * modes keep their current behavior.
12
+ */
13
+ export declare function fullscreenInkRenderOptions(currentRenderer: TuiRendererMode, axScreenReader: boolean | undefined): FullscreenInkRenderOptions;
14
+ //# sourceMappingURL=fullscreen-renderer.d.ts.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The Ink render options for an interactive session. Fullscreen uses a stable
3
+ * terminal-frame policy: incremental rendering is disabled (every frame is a
4
+ * full redraw that erases the previous viewport) while classic and screen-reader
5
+ * modes keep their current behavior.
6
+ */
7
+ export function fullscreenInkRenderOptions(currentRenderer, axScreenReader) {
8
+ return {
9
+ incrementalRendering: currentRenderer !== 'fullscreen' && !axScreenReader,
10
+ alternateScreen: currentRenderer === 'fullscreen',
11
+ };
12
+ }
13
+ //# sourceMappingURL=fullscreen-renderer.js.map
@@ -8,6 +8,10 @@
8
8
  * tail, and every published frame is the exact concatenation of every delta
9
9
  * received since the previous frame.
10
10
  *
11
+ * Pending deltas are buffered as chunks and concatenated exactly once per
12
+ * published frame, so appending a delta stores the delta alone (amortized
13
+ * O(1)) instead of re-concatenating the full accumulated string.
14
+ *
11
15
  * `flush()` publishes any pending deltas immediately and is used at lifecycle
12
16
  * boundaries (thinking-stop, tool-call/result, permission/dialog transitions,
13
17
  * turn completion/cancellation) so the transcript boundary state is always
@@ -71,6 +75,8 @@ export declare class StreamingFrameBuffer {
71
75
  dispose(): void;
72
76
  private scheduleFrame;
73
77
  private cancelScheduledFrame;
78
+ private isTextResetRedundant;
79
+ private isThinkingResetRedundant;
74
80
  private publishPending;
75
81
  }
76
82
  //# sourceMappingURL=streaming-frame-buffer.d.ts.map
@@ -8,6 +8,10 @@
8
8
  * tail, and every published frame is the exact concatenation of every delta
9
9
  * received since the previous frame.
10
10
  *
11
+ * Pending deltas are buffered as chunks and concatenated exactly once per
12
+ * published frame, so appending a delta stores the delta alone (amortized
13
+ * O(1)) instead of re-concatenating the full accumulated string.
14
+ *
11
15
  * `flush()` publishes any pending deltas immediately and is used at lifecycle
12
16
  * boundaries (thinking-stop, tool-call/result, permission/dialog transitions,
13
17
  * turn completion/cancellation) so the transcript boundary state is always
@@ -45,11 +49,15 @@ export class StreamingFrameBuffer {
45
49
  }
46
50
  /** Full effective text, including any deltas not yet published. */
47
51
  get text() {
48
- return this.pendingText ?? this.committedText;
52
+ return this.pendingText === null
53
+ ? this.committedText
54
+ : this.pendingText.base + this.pendingText.chunks.join('');
49
55
  }
50
56
  /** Full effective thinking, including any deltas not yet published. */
51
57
  get thinking() {
52
- return this.pendingThinking ?? this.committedThinking;
58
+ return this.pendingThinking === null
59
+ ? this.committedThinking
60
+ : this.pendingThinking.base + this.pendingThinking.chunks.join('');
53
61
  }
54
62
  get hasPending() {
55
63
  return this.pendingText !== null || this.pendingThinking !== null;
@@ -61,37 +69,38 @@ export class StreamingFrameBuffer {
61
69
  appendText(delta) {
62
70
  if (this.disposed)
63
71
  return;
64
- this.pendingText = (this.pendingText ?? this.committedText) + delta;
72
+ if (this.pendingText === null) {
73
+ this.pendingText = { base: this.committedText, chunks: [] };
74
+ }
75
+ this.pendingText.chunks.push(delta);
65
76
  this.scheduleFrame();
66
77
  }
67
78
  /** Append a thinking delta and schedule a frame publish. */
68
79
  appendThinking(delta) {
69
80
  if (this.disposed)
70
81
  return;
71
- this.pendingThinking =
72
- (this.pendingThinking ?? this.committedThinking) + delta;
82
+ if (this.pendingThinking === null) {
83
+ this.pendingThinking = { base: this.committedThinking, chunks: [] };
84
+ }
85
+ this.pendingThinking.chunks.push(delta);
73
86
  this.scheduleFrame();
74
87
  }
75
88
  /** Discard pending text and clear the active text on the next frame. */
76
89
  resetText() {
77
90
  if (this.disposed)
78
91
  return;
79
- if (this.pendingText === '' ||
80
- (this.pendingText === null && this.committedText === '')) {
92
+ if (this.isTextResetRedundant())
81
93
  return;
82
- }
83
- this.pendingText = '';
94
+ this.pendingText = { base: '', chunks: [] };
84
95
  this.scheduleFrame();
85
96
  }
86
97
  /** Discard pending thinking and clear the active thinking on the next frame. */
87
98
  resetThinking() {
88
99
  if (this.disposed)
89
100
  return;
90
- if (this.pendingThinking === '' ||
91
- (this.pendingThinking === null && this.committedThinking === '')) {
101
+ if (this.isThinkingResetRedundant())
92
102
  return;
93
- }
94
- this.pendingThinking = '';
103
+ this.pendingThinking = { base: '', chunks: [] };
95
104
  this.scheduleFrame();
96
105
  }
97
106
  /** Publish every pending delta immediately, canceling any scheduled frame. */
@@ -127,15 +136,29 @@ export class StreamingFrameBuffer {
127
136
  this.scheduled = false;
128
137
  }
129
138
  }
139
+ isTextResetRedundant() {
140
+ if (this.pendingText === null)
141
+ return this.committedText === '';
142
+ return (this.pendingText.base === '' && this.pendingText.chunks.join('') === '');
143
+ }
144
+ isThinkingResetRedundant() {
145
+ if (this.pendingThinking === null)
146
+ return this.committedThinking === '';
147
+ return (this.pendingThinking.base === '' &&
148
+ this.pendingThinking.chunks.join('') === '');
149
+ }
130
150
  publishPending() {
131
151
  if (this.disposed)
132
152
  return;
133
153
  if (this.pendingText === null && this.pendingThinking === null)
134
154
  return;
135
- if (this.pendingText !== null)
136
- this.committedText = this.pendingText;
155
+ if (this.pendingText !== null) {
156
+ this.committedText =
157
+ this.pendingText.base + this.pendingText.chunks.join('');
158
+ }
137
159
  if (this.pendingThinking !== null) {
138
- this.committedThinking = this.pendingThinking;
160
+ this.committedThinking =
161
+ this.pendingThinking.base + this.pendingThinking.chunks.join('');
139
162
  }
140
163
  this.pendingText = null;
141
164
  this.pendingThinking = null;
@@ -8,6 +8,7 @@ import { extractPermissionRules, permissionRuleValueToString, shellPermissionSug
8
8
  import { redactSensitiveText } from '../../platform/sensitive-data.js';
9
9
  import { resolveDataPlaneRoot, } from '../../persistence/data-plane.js';
10
10
  import { composerEditorSegments, } from './composer-editor.js';
11
+ import { useTuiPalette } from './theme.js';
11
12
  function stringInput(call, name) {
12
13
  const value = call.input[name];
13
14
  return typeof value === 'string' ? value : undefined;
@@ -369,9 +370,10 @@ export function projectTuiToolPermission(call, cwd, sensitiveValues, decision, d
369
370
  });
370
371
  }
371
372
  export function ToolPermissionDialog({ model, selection, feedbackMode, feedback, ruleEditor, screenReader, }) {
372
- return (_jsxs(Box, { flexDirection: "column", borderStyle: screenReader ? undefined : 'round', borderColor: "yellow", paddingX: screenReader ? 0 : 1, marginTop: 1, children: [_jsx(Text, { bold: true, children: model.title }), model.subtitle ? _jsx(Text, { dimColor: true, children: model.subtitle }) : null, _jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [model.detail.map((line, index) => {
373
+ const palette = useTuiPalette();
374
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: screenReader ? undefined : 'round', borderColor: palette.warning, paddingX: screenReader ? 0 : 1, marginTop: 1, children: [_jsx(Text, { bold: true, color: palette.warning, children: model.title }), model.subtitle ? _jsx(Text, { dimColor: true, children: model.subtitle }) : null, _jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [model.detail.map((line, index) => {
373
375
  const content = `${line.prefix ? `${line.prefix} ` : ''}${line.text}`;
374
- return line.prefix ? (_jsx(Text, { color: line.prefix === '+' ? 'green' : 'red', children: content }, `${index}-${line.prefix}`)) : (_jsx(Text, { children: content }, `${index}-`));
376
+ return line.prefix ? (_jsx(Text, { color: line.prefix === '+' ? palette.success : palette.error, children: content }, `${index}-${line.prefix}`)) : (_jsx(Text, { children: content }, `${index}-`));
375
377
  }), model.description ? _jsx(Text, { dimColor: true, children: model.description }) : null] }), model.explanation ? _jsx(Text, { dimColor: true, children: model.explanation }) : null, _jsx(Text, { children: model.question }), model.options.map((option, index) => {
376
378
  const selected = selection === index;
377
379
  const editor = option.editableRule
@@ -381,7 +383,7 @@ export function ToolPermissionDialog({ model, selection, feedbackMode, feedback,
381
383
  })
382
384
  : null;
383
385
  const segments = editor ? composerEditorSegments(editor) : null;
384
- return (_jsxs(Text, { bold: selected, children: [selected ? (screenReader ? 'Selected: ' : '❯ ') : ' ', index + 1, ". ", option.label, segments ? (screenReader || !selected ? (`: ${editor?.text ?? ''}`) : (_jsxs(Text, { children: [": ", segments.before, _jsx(Text, { inverse: true, children: segments.current ?? ' ' }), segments.after] }))) : null] }, `${option.action}-${index}`));
386
+ return (_jsxs(Text, { bold: selected, ...(selected ? { color: palette.brand } : {}), children: [selected ? (screenReader ? 'Selected: ' : '❯ ') : ' ', index + 1, ". ", option.label, segments ? (screenReader || !selected ? (`: ${editor?.text ?? ''}`) : (_jsxs(Text, { children: [": ", segments.before, _jsx(Text, { inverse: true, children: segments.current ?? ' ' }), segments.after] }))) : null] }, `${option.action}-${index}`));
385
387
  }), feedbackMode ? (_jsxs(Text, { children: ["\u203A", ' ', feedback ||
386
388
  (model.options[selection]?.action === 'deny'
387
389
  ? 'tell Praxis what to do differently'
@@ -0,0 +1,46 @@
1
+ import type { TranscriptItem } from './claude-style.js';
2
+ export type TuiRendererMode = 'default' | 'fullscreen';
3
+ /**
4
+ * Inputs for the pure TUI view projection. `history` is the live transcript
5
+ * that grows while the session runs; `initialHistory` is the transcript loaded
6
+ * before rendering and decides the resumed/fresh identity. `resume` is true
7
+ * only when the session was opened through a resume option.
8
+ */
9
+ export interface TuiViewInput {
10
+ initialHistory: readonly TranscriptItem[];
11
+ history: readonly TranscriptItem[];
12
+ resume: boolean;
13
+ fixedViewport: boolean;
14
+ screenReader: boolean;
15
+ rows: number | undefined;
16
+ width: number;
17
+ scrollOffset: number;
18
+ }
19
+ export interface TuiViewModel {
20
+ projectedHistory: readonly TranscriptItem[];
21
+ resumed: boolean;
22
+ freshSession: boolean;
23
+ hasConversationHistory: boolean;
24
+ }
25
+ /**
26
+ * Pure, deterministic TUI projection. It classifies the session identity
27
+ * (fresh/resumed/started) and projects the rendered transcript exactly as the
28
+ * fullscreen renderer needs it, preserving TranscriptItem identity and order
29
+ * and the fullscreen suffix/window behavior. It never writes to transcripts or
30
+ * alters runtime events.
31
+ */
32
+ export declare function projectTuiView(input: TuiViewInput): TuiViewModel;
33
+ export interface TuiRendererInput {
34
+ configured: TuiRendererMode;
35
+ explicitlyConfigured: boolean;
36
+ interactiveTty: boolean;
37
+ screenReader: boolean;
38
+ }
39
+ /**
40
+ * Resolves the renderer for an interactive session. Fullscreen is the default
41
+ * for interactive TTY execution; classic remains the fallback for screen-reader
42
+ * and non-interactive paths, and an explicit renderer configuration is always
43
+ * honored over the default.
44
+ */
45
+ export declare function resolveTuiRenderer(input: TuiRendererInput): TuiRendererMode;
46
+ //# sourceMappingURL=tui-view-model.d.ts.map
@@ -0,0 +1,52 @@
1
+ import { FULLSCREEN_TRANSCRIPT_RESERVED_ROWS, projectTranscriptTail, projectTranscriptWindow, } from './transcript-viewport.js';
2
+ /**
3
+ * Pure, deterministic TUI projection. It classifies the session identity
4
+ * (fresh/resumed/started) and projects the rendered transcript exactly as the
5
+ * fullscreen renderer needs it, preserving TranscriptItem identity and order
6
+ * and the fullscreen suffix/window behavior. It never writes to transcripts or
7
+ * alters runtime events.
8
+ */
9
+ export function projectTuiView(input) {
10
+ // Startup diagnostics are useful before the first prompt, but they are not
11
+ // conversation history and must not suppress the new-session welcome panel.
12
+ // Only real user/assistant transcript entries start a conversation; every
13
+ // other kind (thinking, context, tool, shell, notices, results, and so on)
14
+ // is operational bookkeeping that must not hide the fresh-session welcome.
15
+ const isRealConversation = (item) => item.kind === 'user' || item.kind === 'assistant';
16
+ // The original loaded transcript decides whether the session was resumed,
17
+ // separately from the live history that grows while the session runs.
18
+ const resumed = input.resume && input.initialHistory.some(isRealConversation);
19
+ const hasConversationHistory = input.history.some(isRealConversation);
20
+ // A session is resumed only when it was opened through `resume` and the
21
+ // original transcript already contained real conversation content. Supplying
22
+ // a session ID alone with an empty transcript keeps the session fresh, so the
23
+ // full welcome panel renders and the compact identity stays hidden until real
24
+ // conversation content appears.
25
+ const freshSession = !resumed && !hasConversationHistory;
26
+ // Fullscreen projects only the newest transcript tail that fits the fixed
27
+ // viewport, leaving the composer/status chrome intact and keeping the active
28
+ // stream visible. Classic and screen-reader modes always render the full
29
+ // history exactly as before.
30
+ const projectedHistory = input.fixedViewport && !input.screenReader
31
+ ? input.scrollOffset > 0
32
+ ? projectTranscriptWindow(input.history, Math.max(1, (input.rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), input.width, input.scrollOffset)
33
+ : projectTranscriptTail(input.history, Math.max(1, (input.rows ?? 0) - FULLSCREEN_TRANSCRIPT_RESERVED_ROWS), input.width)
34
+ : input.history;
35
+ return { projectedHistory, resumed, freshSession, hasConversationHistory };
36
+ }
37
+ /**
38
+ * Resolves the renderer for an interactive session. Fullscreen is the default
39
+ * for interactive TTY execution; classic remains the fallback for screen-reader
40
+ * and non-interactive paths, and an explicit renderer configuration is always
41
+ * honored over the default.
42
+ */
43
+ export function resolveTuiRenderer(input) {
44
+ if (input.screenReader)
45
+ return 'default';
46
+ if (!input.interactiveTty)
47
+ return 'default';
48
+ if (input.explicitlyConfigured)
49
+ return input.configured;
50
+ return 'fullscreen';
51
+ }
52
+ //# sourceMappingURL=tui-view-model.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "praxis-agent",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "Local-first, single-user general agent for the command line.",
5
5
  "license": "MIT",
6
6
  "author": "wuqisen",
@@ -44,6 +44,7 @@
44
44
  "test:docs": "node scripts/verify-docs.mjs",
45
45
  "test:compat": "npm run build && node scripts/verify-claude-compatibility.mjs && node scripts/verify-claude-advanced-fixtures.mjs",
46
46
  "test:compat:all": "npm run build && node scripts/verify-compatibility-matrix.mjs",
47
+ "test:tui:pty": "npm run build && node scripts/verify-fullscreen-rendering.mjs",
47
48
  "test:cli-surface-compat": "npm run build && node scripts/verify-cli-surface-parity.mjs",
48
49
  "test:cli-controls-compat": "npm run build && node scripts/verify-cli-controls.mjs",
49
50
  "test:conditional-compat": "npm run build && node scripts/verify-conditional-rules.mjs",