praxis-agent 0.45.3 → 0.46.0

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.
@@ -28,6 +28,7 @@ import { createTuiAppendHistoryChange } from './tui/transcript-window-model.js';
28
28
  import { createTuiHistoryChange, resolveTuiRenderer, } from './tui/tui-view-model.js';
29
29
  import { projectTuiScreen, } from './tui/tui-screen-model.js';
30
30
  import { TuiAnsiSurface } from './tui/ansi-surface.js';
31
+ import { clampTranscriptScrollOffset, createTerminalSelectionContext, createTerminalSelectionState, parseTerminalMouseReport, projectTerminalSelection, refreshTerminalSelection, releaseTerminalSelection, startTerminalSelection, updateTerminalSelection, } from './tui/terminal-selection.js';
31
32
  import { QuietInkFrame } from './tui/quiet-frame-adapter.js';
32
33
  import { projectQuietScreenFrame, } from './tui/quiet-screen-projector.js';
33
34
  import { projectTuiHelpSurface } from './tui/help-surface-model.js';
@@ -374,6 +375,15 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
374
375
  const inputHistoryRef = useRef([]);
375
376
  const inputHistoryIndexRef = useRef(null);
376
377
  const inputHistoryDraftRef = useRef('');
378
+ const [pendingInputs, setPendingInputs] = useState([]);
379
+ const pendingInputsRef = useRef(pendingInputs);
380
+ pendingInputsRef.current = pendingInputs;
381
+ const followUpQueueRef = useRef([]);
382
+ const updatePendingInputs = (update) => {
383
+ const next = typeof update === 'function' ? update(pendingInputsRef.current) : update;
384
+ pendingInputsRef.current = next;
385
+ setPendingInputs(next);
386
+ };
377
387
  const undoStackRef = useRef([]);
378
388
  const composerImagesRef = useRef(new Map());
379
389
  const nextImageIdRef = useRef(1);
@@ -487,6 +497,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
487
497
  transcriptScrollOffsetRef.current = offset;
488
498
  setTranscriptScrollOffsetState(offset);
489
499
  };
500
+ const [terminalSelection, setTerminalSelection] = useState(createTerminalSelectionState);
501
+ const terminalSelectionRef = useRef(createTerminalSelectionState());
502
+ const terminalSelectionContextRef = useRef(null);
503
+ const selectionEdgeTimerRef = useRef(null);
504
+ const [clearRevision, setClearRevision] = useState(0);
490
505
  const sessionLoadRef = useRef(0);
491
506
  const [turnDiffs, setTurnDiffs] = useState([]);
492
507
  const turnNumberRef = useRef(0);
@@ -1191,6 +1206,56 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1191
1206
  const conversationScreen = screen.body.kind === 'conversation' ? screen.body : undefined;
1192
1207
  const transcriptPageRows = conversationScreen?.transcript.pageRows ?? 2;
1193
1208
  const maxTranscriptScrollOffset = conversationScreen?.transcript.maxScrollOffset ?? 0;
1209
+ const stopSelectionEdgeScroll = () => {
1210
+ if (selectionEdgeTimerRef.current !== null) {
1211
+ clearInterval(selectionEdgeTimerRef.current);
1212
+ selectionEdgeTimerRef.current = null;
1213
+ }
1214
+ };
1215
+ const selectionContextRef = terminalSelectionContextRef;
1216
+ const copyTerminalSelection = (text) => {
1217
+ if (!text)
1218
+ return;
1219
+ void sideQuestionClipboardWriter(text).catch((error) => {
1220
+ append({
1221
+ kind: 'warning',
1222
+ text: `Clipboard unavailable: ${error instanceof Error ? error.message : String(error)}`,
1223
+ });
1224
+ });
1225
+ };
1226
+ const beginSelectionEdgeScroll = (direction) => {
1227
+ if (selectionEdgeTimerRef.current !== null)
1228
+ return;
1229
+ selectionEdgeTimerRef.current = setInterval(() => {
1230
+ const context = selectionContextRef.current;
1231
+ const current = terminalSelectionRef.current;
1232
+ if (!context ||
1233
+ current.phase !== 'dragging' ||
1234
+ current.edge !== direction) {
1235
+ stopSelectionEdgeScroll();
1236
+ return;
1237
+ }
1238
+ const offset = transcriptScrollOffsetRef.current;
1239
+ const next = clampTranscriptScrollOffset(offset + (direction === 'older' ? 1 : -1), context.maxTranscriptScrollOffset);
1240
+ if (next === offset) {
1241
+ stopSelectionEdgeScroll();
1242
+ setTerminalSelection((state) => ({ ...state, edge: 'none' }));
1243
+ terminalSelectionRef.current = { ...current, edge: 'none' };
1244
+ return;
1245
+ }
1246
+ setTranscriptScrollOffset(next);
1247
+ }, 80);
1248
+ };
1249
+ useEffect(() => {
1250
+ if (!ansiActive) {
1251
+ stopSelectionEdgeScroll();
1252
+ terminalSelectionRef.current = createTerminalSelectionState();
1253
+ setTerminalSelection(terminalSelectionRef.current);
1254
+ }
1255
+ return () => {
1256
+ stopSelectionEdgeScroll();
1257
+ };
1258
+ }, [ansiActive]);
1194
1259
  const permissionOptions = useMemo(() => [
1195
1260
  ...PERMISSION_OPTIONS,
1196
1261
  ...(allowDangerouslySkipPermissions
@@ -1672,6 +1737,30 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1672
1737
  inputHistoryIndexRef.current = nextIndex;
1673
1738
  updateComposerInput(history[nextIndex] ?? '');
1674
1739
  };
1740
+ const withdrawLatestPending = () => {
1741
+ if (inputRef.current.trim().length !== 0)
1742
+ return false;
1743
+ const pending = pendingInputsRef.current.at(-1);
1744
+ if (!pending)
1745
+ return false;
1746
+ if (pending.kind === 'follow-up') {
1747
+ followUpQueueRef.current = followUpQueueRef.current.filter((item) => item.id !== pending.id);
1748
+ updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
1749
+ updateComposerInput(pending.text);
1750
+ return true;
1751
+ }
1752
+ const activeSessionId = sessionIdRef.current;
1753
+ const result = activeSessionId
1754
+ ? serviceRef.current?.withdrawSteering?.(activeSessionId, pending.id)
1755
+ : undefined;
1756
+ if (result?.kind === 'withdrawn') {
1757
+ updatePendingInputs((current) => current.filter((item) => item.id !== pending.id));
1758
+ updateComposerInput(result.item.content);
1759
+ return true;
1760
+ }
1761
+ append({ kind: 'warning', text: 'Input is already delivered.' });
1762
+ return true;
1763
+ };
1675
1764
  const dismissExitConfirmation = () => {
1676
1765
  if (!exitConfirmation)
1677
1766
  return;
@@ -1725,6 +1814,33 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
1725
1814
  case 'user-message':
1726
1815
  append({ kind: 'assistant', text: event.message });
1727
1816
  break;
1817
+ case 'user-input-delivered':
1818
+ // Steering is delivered after the preceding assistant batch has been
1819
+ // persisted. Commit that batch to the visible transcript before the
1820
+ // user row, then start the next provider continuation with a fresh
1821
+ // active buffer. Otherwise the active row would appear after the
1822
+ // steering message and be discarded when the turn finally completes.
1823
+ if ((streamingFrameRef.current?.text.trim().length ?? 0) > 0) {
1824
+ append({
1825
+ kind: 'assistant',
1826
+ text: streamingFrameRef.current?.text ?? '',
1827
+ });
1828
+ }
1829
+ streamingFrameRef.current?.resetText();
1830
+ streamingFrameRef.current?.resetThinking();
1831
+ streamingFrameRef.current?.flush();
1832
+ updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
1833
+ append({ kind: 'user', text: event.content });
1834
+ break;
1835
+ case 'user-input-rejected':
1836
+ updatePendingInputs((current) => current.filter((item) => item.id !== event.id));
1837
+ if (inputRef.current.trim().length === 0)
1838
+ updateComposerInput(event.content);
1839
+ append({
1840
+ kind: 'warning',
1841
+ text: `Input not delivered · ${redactSensitiveText(event.content, sensitiveValues)}`,
1842
+ });
1843
+ break;
1728
1844
  case 'state':
1729
1845
  if (event.state === 'awaiting-model') {
1730
1846
  activeAttemptThinkingItemsRef.current = [];
@@ -3156,7 +3272,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3156
3272
  onTurnChange?.(restoring);
3157
3273
  void restoring.finally(() => onTurnChange?.(null));
3158
3274
  };
3159
- const submit = async (prompt, shellCommand, images = []) => {
3275
+ const submitTurn = async (prompt, shellCommand, images = [], followUpId, internal = false) => {
3160
3276
  setTranscriptScrollOffset(0);
3161
3277
  const turnNumber = turnNumberRef.current + 1;
3162
3278
  const turnStartedAt = Date.now();
@@ -3171,6 +3287,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3171
3287
  ? AbortSignal.any([signal, turnController.signal])
3172
3288
  : turnController.signal;
3173
3289
  setBusy(true);
3290
+ if (followUpId !== undefined) {
3291
+ updatePendingInputs((current) => current.filter((item) => item.id !== followUpId));
3292
+ }
3174
3293
  setTurnDuration(undefined);
3175
3294
  setCommandPaletteOpen(false);
3176
3295
  const submittedCommandName = /^\/([^\s]+)/u.exec(prompt)?.[1]?.toLowerCase();
@@ -3189,9 +3308,10 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3189
3308
  else
3190
3309
  turnMutatedFilesRef.current = true;
3191
3310
  let commands;
3311
+ let turnSucceeded = false;
3192
3312
  try {
3193
3313
  commands = await service();
3194
- let activeSessionId = sessionId;
3314
+ let activeSessionId = sessionIdRef.current;
3195
3315
  const startedNewSession = activeSessionId === null;
3196
3316
  if (activeSessionId === null) {
3197
3317
  activeSessionId = randomUUID();
@@ -3260,7 +3380,9 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3260
3380
  streamingFrameRef.current?.flush();
3261
3381
  setStatus('ready');
3262
3382
  setTurnDuration(Date.now() - turnStartedAt);
3263
- if (runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3383
+ if (!internal &&
3384
+ followUpQueueRef.current.length === 0 &&
3385
+ runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3264
3386
  notifyTerminal({
3265
3387
  channel: runtimeSettingsRef.current.notifChannel,
3266
3388
  title: 'Praxis',
@@ -3270,6 +3392,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3270
3392
  : { write: notificationWriter }),
3271
3393
  });
3272
3394
  }
3395
+ turnSucceeded = true;
3273
3396
  }
3274
3397
  catch (error) {
3275
3398
  if (turnController.signal.aborted && !signal?.aborted) {
@@ -3287,7 +3410,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3287
3410
  }
3288
3411
  }
3289
3412
  finally {
3290
- if (!factory.scheduledPrompts && commands) {
3413
+ if (!internal && !factory.scheduledPrompts && commands) {
3291
3414
  try {
3292
3415
  await commands.close?.();
3293
3416
  }
@@ -3304,6 +3427,62 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3304
3427
  }
3305
3428
  if (turnControllerRef.current === turnController)
3306
3429
  turnControllerRef.current = null;
3430
+ if (!internal) {
3431
+ setBusy(false);
3432
+ }
3433
+ }
3434
+ return turnSucceeded;
3435
+ };
3436
+ const submit = async (prompt, shellCommand, images = [], followUpId) => {
3437
+ let currentPrompt = prompt;
3438
+ let currentShellCommand = shellCommand;
3439
+ let currentImages = images;
3440
+ let currentFollowUpId = followUpId;
3441
+ let completed = false;
3442
+ try {
3443
+ while (true) {
3444
+ completed = await submitTurn(currentPrompt, currentShellCommand, currentImages, currentFollowUpId, true);
3445
+ if (!completed)
3446
+ break;
3447
+ const next = followUpQueueRef.current.shift();
3448
+ if (!next)
3449
+ break;
3450
+ currentPrompt = next.text;
3451
+ currentShellCommand = undefined;
3452
+ currentImages = [];
3453
+ currentFollowUpId = next.id;
3454
+ }
3455
+ if (completed &&
3456
+ runtimeSettingsRef.current.notifChannel !== 'notifications_disabled') {
3457
+ notifyTerminal({
3458
+ channel: runtimeSettingsRef.current.notifChannel,
3459
+ title: 'Praxis',
3460
+ message: 'Turn complete',
3461
+ ...(notificationWriter === undefined
3462
+ ? {}
3463
+ : { write: notificationWriter }),
3464
+ });
3465
+ }
3466
+ }
3467
+ finally {
3468
+ if (!factory.scheduledPrompts) {
3469
+ const commands = serviceRef.current;
3470
+ if (commands) {
3471
+ try {
3472
+ await commands.close?.();
3473
+ }
3474
+ catch (error) {
3475
+ append({
3476
+ kind: 'warning',
3477
+ text: redactSensitiveText(error instanceof Error ? error.message : String(error), sensitiveValues),
3478
+ });
3479
+ }
3480
+ finally {
3481
+ if (serviceRef.current === commands)
3482
+ serviceRef.current = null;
3483
+ }
3484
+ }
3485
+ }
3307
3486
  setBusy(false);
3308
3487
  }
3309
3488
  };
@@ -3360,6 +3539,53 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3360
3539
  return () => controller.abort();
3361
3540
  }, [busy, initialPromptPending, permission, selectingSession, sessionId]);
3362
3541
  useInput((value, key) => {
3542
+ if (ansiActive) {
3543
+ const mouse = parseTerminalMouseReport(value);
3544
+ if (mouse !== null) {
3545
+ const context = terminalSelectionContextRef.current;
3546
+ if (context === null)
3547
+ return;
3548
+ if (mouse.kind === 'wheel') {
3549
+ const offset = transcriptScrollOffsetRef.current;
3550
+ setTranscriptScrollOffset(clampTranscriptScrollOffset(offset + (mouse.direction === 'older' ? 1 : -1), context.maxTranscriptScrollOffset));
3551
+ return;
3552
+ }
3553
+ if (mouse.kind === 'press') {
3554
+ stopSelectionEdgeScroll();
3555
+ const next = startTerminalSelection(context, mouse);
3556
+ terminalSelectionRef.current = next;
3557
+ setTerminalSelection(next);
3558
+ return;
3559
+ }
3560
+ if (mouse.kind === 'drag') {
3561
+ const current = terminalSelectionRef.current;
3562
+ if (current.phase !== 'dragging')
3563
+ return;
3564
+ const previousEdge = current.edge;
3565
+ const next = updateTerminalSelection(current, context, mouse);
3566
+ terminalSelectionRef.current = next;
3567
+ setTerminalSelection(next);
3568
+ if (next.edge !== 'none') {
3569
+ if (next.edge !== previousEdge) {
3570
+ const offset = transcriptScrollOffsetRef.current;
3571
+ const moved = clampTranscriptScrollOffset(offset + (next.edge === 'older' ? 1 : -1), context.maxTranscriptScrollOffset);
3572
+ if (moved !== offset)
3573
+ setTranscriptScrollOffset(moved);
3574
+ }
3575
+ beginSelectionEdgeScroll(next.edge);
3576
+ }
3577
+ else
3578
+ stopSelectionEdgeScroll();
3579
+ return;
3580
+ }
3581
+ const released = releaseTerminalSelection(terminalSelectionRef.current, context, mouse);
3582
+ stopSelectionEdgeScroll();
3583
+ terminalSelectionRef.current = released.state;
3584
+ setTerminalSelection(released.state);
3585
+ copyTerminalSelection(released.text);
3586
+ return;
3587
+ }
3588
+ }
3363
3589
  const lower = value.toLowerCase();
3364
3590
  const controlKey = (letter) => (key.ctrl && lower === letter) ||
3365
3591
  value === String.fromCharCode(letter.charCodeAt(0) - 96);
@@ -3547,6 +3773,32 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
3547
3773
  if (!keybindingAction) {
3548
3774
  keybindingAction = resolveTuiKeybinding(keybindings, keybindingContexts, inputChord);
3549
3775
  }
3776
+ const clearScreenBinding = ansiActive &&
3777
+ (keybindingAction === 'chat:clearScreen' ||
3778
+ (inputChord === 'ctrl+l' && keybindingAction === 'chat:clearInput'));
3779
+ if (clearScreenBinding) {
3780
+ stopSelectionEdgeScroll();
3781
+ const cleared = createTerminalSelectionState();
3782
+ terminalSelectionRef.current = cleared;
3783
+ setTerminalSelection(cleared);
3784
+ setClearRevision((revision) => revision + 1);
3785
+ return;
3786
+ }
3787
+ if (ansiActive && terminalSelectionRef.current.phase !== 'idle') {
3788
+ const selectionAction = resolveTuiKeybinding(keybindings, ['Scroll'], inputChord);
3789
+ if (selectionAction === 'selection:copy') {
3790
+ const context = terminalSelectionContextRef.current;
3791
+ if (context === null)
3792
+ return;
3793
+ const copied = releaseTerminalSelection(terminalSelectionRef.current, context);
3794
+ copyTerminalSelection(copied.text);
3795
+ return;
3796
+ }
3797
+ stopSelectionEdgeScroll();
3798
+ const cleared = createTerminalSelectionState();
3799
+ terminalSelectionRef.current = cleared;
3800
+ setTerminalSelection(cleared);
3801
+ }
3550
3802
  const scrollIntent = key.pageUp
3551
3803
  ? 'page-older'
3552
3804
  : key.pageDown
@@ -5761,6 +6013,94 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
5761
6013
  void backgrounding;
5762
6014
  return;
5763
6015
  }
6016
+ const pendingText = inputRef.current.trim();
6017
+ const addPending = (item) => updatePendingInputs((current) => [...current, item]);
6018
+ if (key.upArrow && pendingText.length === 0) {
6019
+ withdrawLatestPending();
6020
+ return;
6021
+ }
6022
+ if (key.return && key.shift) {
6023
+ updateComposerEditor(insertComposerText(editor(), '\n'));
6024
+ return;
6025
+ }
6026
+ if (isKeybinding('chat:submit') && !key.shift && !key.meta && !key.ctrl) {
6027
+ if (!pendingText)
6028
+ return;
6029
+ const controller = turnControllerRef.current;
6030
+ if (!controller) {
6031
+ append({
6032
+ kind: 'warning',
6033
+ text: 'Turn is completing; input was retained.',
6034
+ });
6035
+ return;
6036
+ }
6037
+ const restoreSteeringText = () => {
6038
+ const current = inputRef.current;
6039
+ updateComposerInput(current.trim().length === 0
6040
+ ? pendingText
6041
+ : `${pendingText}\n${current}`);
6042
+ };
6043
+ // Service construction may still be in flight while the busy composer
6044
+ // is already visible. Clear now so newly typed text becomes a distinct
6045
+ // draft; restore both drafts if the active-turn command loses a race.
6046
+ clearComposerInput();
6047
+ const steer = async () => {
6048
+ const commands = serviceRef.current ?? (await service());
6049
+ if (turnControllerRef.current !== controller ||
6050
+ controller.signal.aborted) {
6051
+ restoreSteeringText();
6052
+ append({
6053
+ kind: 'warning',
6054
+ text: 'Active turn changed; input was retained.',
6055
+ });
6056
+ return;
6057
+ }
6058
+ const activeSessionId = sessionIdRef.current;
6059
+ const result = activeSessionId
6060
+ ? commands.steer?.(activeSessionId, pendingText)
6061
+ : undefined;
6062
+ if (result?.kind === 'accepted') {
6063
+ addPending({
6064
+ id: result.item.id,
6065
+ kind: 'steering',
6066
+ text: result.item.content,
6067
+ });
6068
+ }
6069
+ else {
6070
+ restoreSteeringText();
6071
+ append({
6072
+ kind: 'warning',
6073
+ text: result?.kind === 'turn-completing'
6074
+ ? 'Turn is completing; input was retained.'
6075
+ : result?.kind === 'not-steerable'
6076
+ ? 'This turn cannot be steered; input was retained.'
6077
+ : 'Steering is unavailable; input was retained.',
6078
+ });
6079
+ }
6080
+ };
6081
+ void steer().catch((error) => {
6082
+ restoreSteeringText();
6083
+ warn(error);
6084
+ });
6085
+ return;
6086
+ }
6087
+ if (key.tab || (key.return && key.meta)) {
6088
+ if (!pendingText)
6089
+ return;
6090
+ if (!turnControllerRef.current) {
6091
+ append({
6092
+ kind: 'warning',
6093
+ text: 'Turn is completing; follow-up input was retained.',
6094
+ });
6095
+ return;
6096
+ }
6097
+ const item = { id: randomUUID(), text: pendingText };
6098
+ followUpQueueRef.current.push(item);
6099
+ addPending({ id: item.id, kind: 'follow-up', text: item.text });
6100
+ clearComposerInput();
6101
+ return;
6102
+ }
6103
+ editComposer();
5764
6104
  return;
5765
6105
  }
5766
6106
  if (isKeybinding('chat:imagePaste')) {
@@ -6442,6 +6782,11 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6442
6782
  }
6443
6783
  return;
6444
6784
  }
6785
+ if (isKeybinding('history:previous') &&
6786
+ inputRef.current.length === 0 &&
6787
+ withdrawLatestPending()) {
6788
+ return;
6789
+ }
6445
6790
  if (isKeybinding('history:previous')) {
6446
6791
  restorePromptHistory('previous');
6447
6792
  return;
@@ -6535,6 +6880,7 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6535
6880
  shellMode,
6536
6881
  busy,
6537
6882
  status: conciseStatus,
6883
+ pendingItems: pendingInputs,
6538
6884
  display: runtimeDisplay,
6539
6885
  }), [
6540
6886
  screen,
@@ -6543,9 +6889,28 @@ export function InteractiveApp({ dataPlane = resolveDataPlane(), configRoot: sup
6543
6889
  inputCursor,
6544
6890
  busy,
6545
6891
  conciseStatus,
6892
+ pendingInputs,
6546
6893
  runtimeDisplay,
6547
6894
  ]);
6548
- return (_jsx(TuiThemeProvider, { settings: themeSettings, screenReader: axScreenReader, children: ansiActive ? (_jsx(TuiAnsiSurface, { frame: quietFrame, onError: () => setAnsiRendererFailed(true) })) : (_jsx(QuietInkFrame, { frame: quietFrame, screenReader: axScreenReader })) }));
6895
+ const terminalSelectionContext = useMemo(() => createTerminalSelectionContext(quietFrame, conversationScreen?.transcript.rows.length ?? 0, transcriptScrollOffset, maxTranscriptScrollOffset), [
6896
+ quietFrame,
6897
+ conversationScreen?.transcript.rows.length,
6898
+ transcriptScrollOffset,
6899
+ maxTranscriptScrollOffset,
6900
+ ]);
6901
+ terminalSelectionContextRef.current = terminalSelectionContext;
6902
+ terminalSelectionRef.current = terminalSelection;
6903
+ useEffect(() => {
6904
+ if (!ansiActive || terminalSelection.phase !== 'dragging')
6905
+ return;
6906
+ const refreshed = refreshTerminalSelection(terminalSelection, terminalSelectionContext);
6907
+ if (refreshed !== terminalSelection) {
6908
+ terminalSelectionRef.current = refreshed;
6909
+ setTerminalSelection(refreshed);
6910
+ }
6911
+ }, [ansiActive, terminalSelection, terminalSelectionContext]);
6912
+ const selectedQuietFrame = useMemo(() => projectTerminalSelection(quietFrame, terminalSelection, terminalSelectionContext), [quietFrame, terminalSelection, terminalSelectionContext]);
6913
+ return (_jsx(TuiThemeProvider, { settings: themeSettings, screenReader: axScreenReader, children: ansiActive ? (_jsx(TuiAnsiSurface, { frame: selectedQuietFrame, clearRevision: clearRevision, onError: () => setAnsiRendererFailed(true) })) : (_jsx(QuietInkFrame, { frame: quietFrame, screenReader: axScreenReader })) }));
6549
6914
  }
6550
6915
  /**
6551
6916
  * Whether the user explicitly saved a `tui` renderer value in configuration.
@@ -20,6 +20,7 @@ export declare class AnsiFullscreenRenderer {
20
20
  setStyles(styles?: Partial<Record<TuiTextRole, string>>): void;
21
21
  get mounted(): boolean;
22
22
  mount(): void;
23
+ clear(): void;
23
24
  draw(frame: AnsiFrame): void;
24
25
  dispose(): void;
25
26
  }
@@ -4,6 +4,18 @@ const ALTERNATE_SCREEN_ENTER = '\u001b[?1049h';
4
4
  const ALTERNATE_SCREEN_LEAVE = '\u001b[?1049l';
5
5
  const HIDE_CURSOR = '\u001b[?25l';
6
6
  const SHOW_CURSOR = '\u001b[?25h';
7
+ const MOUSE_MODE_ENABLE = [
8
+ '\u001b[?1000h',
9
+ '\u001b[?1002h',
10
+ '\u001b[?1003h',
11
+ '\u001b[?1006h',
12
+ ];
13
+ const MOUSE_MODE_DISABLE = [
14
+ '\u001b[?1006l',
15
+ '\u001b[?1003l',
16
+ '\u001b[?1002l',
17
+ '\u001b[?1000l',
18
+ ];
7
19
  const SYNCHRONIZED_BEGIN = '\u001b[?2026h';
8
20
  const SYNCHRONIZED_END = '\u001b[?2026l';
9
21
  const RESET = '\u001b[0m';
@@ -91,11 +103,16 @@ export class AnsiFullscreenRenderer {
91
103
  let alternateScreenEntered = false;
92
104
  let cursorHidden = false;
93
105
  let synchronizedOutputBegun = false;
106
+ const mouseModesEnabled = [];
94
107
  try {
95
108
  alternateScreenEntered = true;
96
109
  this.#writer.write(ALTERNATE_SCREEN_ENTER);
97
110
  cursorHidden = true;
98
111
  this.#writer.write(HIDE_CURSOR);
112
+ for (const [index, mode] of MOUSE_MODE_ENABLE.entries()) {
113
+ mouseModesEnabled[index] = true;
114
+ this.#writer.write(mode);
115
+ }
99
116
  if (this.#synchronizedOutput) {
100
117
  synchronizedOutputBegun = true;
101
118
  this.#writer.write(SYNCHRONIZED_BEGIN);
@@ -106,6 +123,16 @@ export class AnsiFullscreenRenderer {
106
123
  this.#mounted = false;
107
124
  this.#previousLines = [];
108
125
  this.#previousCursor = undefined;
126
+ for (let index = MOUSE_MODE_ENABLE.length - 1; index >= 0; index -= 1) {
127
+ if (!mouseModesEnabled[index])
128
+ continue;
129
+ try {
130
+ this.#writer.write(MOUSE_MODE_DISABLE[MOUSE_MODE_ENABLE.length - 1 - index]);
131
+ }
132
+ catch {
133
+ // Rollback must continue if one restoration write fails.
134
+ }
135
+ }
109
136
  if (synchronizedOutputBegun) {
110
137
  try {
111
138
  this.#writer.write(SYNCHRONIZED_END);
@@ -133,6 +160,40 @@ export class AnsiFullscreenRenderer {
133
160
  throw error;
134
161
  }
135
162
  }
163
+ clear() {
164
+ if (!this.#mounted)
165
+ throw new Error('ANSI fullscreen renderer is not mounted');
166
+ let firstError;
167
+ if (this.#synchronizedOutput) {
168
+ try {
169
+ this.#writer.write(SYNCHRONIZED_BEGIN);
170
+ }
171
+ catch (error) {
172
+ firstError = error;
173
+ }
174
+ }
175
+ if (firstError === undefined) {
176
+ try {
177
+ this.#writer.write('\u001b[2J\u001b[H');
178
+ }
179
+ catch (error) {
180
+ firstError = error;
181
+ }
182
+ }
183
+ if (this.#synchronizedOutput) {
184
+ try {
185
+ this.#writer.write(SYNCHRONIZED_END);
186
+ }
187
+ catch (error) {
188
+ if (firstError === undefined)
189
+ firstError = error;
190
+ }
191
+ }
192
+ if (firstError !== undefined)
193
+ throw firstError;
194
+ this.#previousLines = [];
195
+ this.#previousCursor = undefined;
196
+ }
136
197
  draw(frame) {
137
198
  if (!this.#mounted)
138
199
  throw new Error('ANSI fullscreen renderer is not mounted');
@@ -192,17 +253,31 @@ export class AnsiFullscreenRenderer {
192
253
  dispose() {
193
254
  if (!this.#mounted)
194
255
  return;
256
+ let firstError;
257
+ const attempt = (write) => {
258
+ try {
259
+ this.#writer.write(write);
260
+ }
261
+ catch (error) {
262
+ if (firstError === undefined)
263
+ firstError = error;
264
+ }
265
+ };
195
266
  try {
267
+ for (const mode of MOUSE_MODE_DISABLE)
268
+ attempt(mode);
196
269
  if (this.#synchronizedOutput)
197
- this.#writer.write(SYNCHRONIZED_END);
198
- this.#writer.write(SHOW_CURSOR);
199
- this.#writer.write(ALTERNATE_SCREEN_LEAVE);
270
+ attempt(SYNCHRONIZED_END);
271
+ attempt(SHOW_CURSOR);
272
+ attempt(ALTERNATE_SCREEN_LEAVE);
200
273
  }
201
274
  finally {
202
275
  this.#mounted = false;
203
276
  this.#previousLines = [];
204
277
  this.#previousCursor = undefined;
205
278
  }
279
+ if (firstError !== undefined)
280
+ throw firstError;
206
281
  }
207
282
  }
208
283
  //# sourceMappingURL=ansi-frame-renderer.js.map
@@ -3,6 +3,7 @@ import type { QuietFrame } from './quiet-frame.js';
3
3
  export interface TuiAnsiSurfaceFrameProps {
4
4
  readonly frame: QuietFrame;
5
5
  readonly onError: (error: unknown) => void;
6
+ readonly clearRevision?: number;
6
7
  }
7
8
  export type TuiAnsiSurfaceProps = TuiAnsiSurfaceFrameProps;
8
9
  export declare function projectAnsiQuietFrame(frame: QuietFrame): AnsiFrame;
@@ -17,6 +17,7 @@ export function TuiAnsiSurface(props) {
17
17
  const styles = useMemo(() => resolveAnsiTextStyles(theme), [theme]);
18
18
  const rendererRef = useRef(null);
19
19
  const failedRef = useRef(false);
20
+ const clearRevisionRef = useRef(undefined);
20
21
  if (rendererRef.current === null) {
21
22
  rendererRef.current = new AnsiFullscreenRenderer({
22
23
  writer: { write: (chunk) => stdout.write(chunk) },
@@ -31,6 +32,7 @@ export function TuiAnsiSurface(props) {
31
32
  return;
32
33
  try {
33
34
  renderer.mount();
35
+ clearRevisionRef.current = props.clearRevision;
34
36
  }
35
37
  catch (error) {
36
38
  failedRef.current = true;
@@ -55,6 +57,10 @@ export function TuiAnsiSurface(props) {
55
57
  if (renderer === null)
56
58
  return;
57
59
  try {
60
+ if (clearRevisionRef.current !== props.clearRevision) {
61
+ renderer.clear();
62
+ clearRevisionRef.current = props.clearRevision;
63
+ }
58
64
  renderer.draw(projectAnsiQuietFrame(props.frame));
59
65
  }
60
66
  catch (error) {