Skip to content
Source
Sign inSign up

[Bug]: Clearing terminal history resets the live program's screen and input modes #36006

Send to my agent
Opencoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

Clearing a running terminal's history resets its emulator to the main screen and ordinary keyboard and mouse input, while the PTY program continues running with its previously negotiated modes. A full-screen program can therefore lose mouse clicks and key-release events and receive different arrow sequences immediately after Clear. This was reproduced headlessly with the production shared terminal reducer, output writer, web terminal core, and vendored WASM engine; the mobile Clear entry point and server lifecycle were traced in source.

Before submitting

  • Related issues: related clear and cold-open reports are listed under Additional context; open and closed issue/PR searches found no report of Clear discarding the live program's negotiated modes.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/web

Observed behavior

With alternate screen, Kitty flags 7, application cursor keys, and SGR mouse reporting active, feeding the production client reducer a cleared event produces an empty reset output update. Applying that update through the production web output writer calls the core's full terminal reset. The alternate-screen, application-cursor, and mouse-tracking reads all change from true to false; the Kitty query changes from ESC[?7u to ESC[?0u, an ArrowUp press changes from ESC[1;1:1A to ESC[A, an a release stops producing ESC[97;1:3u, and a click stops producing ESC[<0;3;2M.

The supported user entry point is the mobile terminal's Clear toolbar action at apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx#L728-L745. The RPC handler delegates to TerminalManager.clear at apps/server/src/ws.ts#L3189-L3189. Source-only lifecycle observation: apps/server/src/terminal/Manager.ts#L3494-L3514 clears retained output and publishes cleared without replacing the PTY or sending mode-reset bytes to its program. A web/desktop viewer attached to that terminal consumes this shared event. The probe starts at that event boundary; no mobile button, WebSocket server, native mobile runtime, or live shell was driven.

Reproduction

  1. Run the script below against a clean checkout of the commit in Version. It loads production source as in-memory JavaScript modules after stripping TypeScript syntax and replacing import URLs; it loads the actual vendored WASM artifacts, with no dependency install.
  2. Establish a production Ghostty core and shared client snapshot containing ESC[?1049h ESC[>7u ESC[?1;1000;1006h ESC= with the spaces removed. Verify the active modes and input encodings, and verify that replaying the complete history preserves them.
  3. Apply a cleared event through applyTerminalAttachStreamEvent, readTerminalOutputUpdate, and writeTerminalOutputUpdate, then read the same modes and encode the same input again.

The final script reproduced the defect in its recorded run. Two preliminary fixture runs exited inconclusive because they used the pinned header's UNKNOWN mouse button 0; changing the fixture to LEFT button 1 made the live-input control pass. The final reproduction also checks alternate-screen entry/exit for 47, 1047, and 1049, cursor save/restore for 1048, and independent main/alternate Kitty stacks with push, query, set, and pop.

Save as repro-clear.mjs and run node --disable-warning=ExperimentalWarning repro-clear.mjs /path/to/t3code. Prerequisites are Node with node:module.stripTypeScriptTypes and Git; the measured versions appear below. The script reads the checkout without changing it, creates a disposable directory with mkdtempSync, redirects HOME, TMPDIR, and T3CODE_HOME there, and removes it on exit. All terminal replies stay in callback arrays. Exit 0 and RESULT: expected behavior mean Clear preserved the negotiated modes; exit 1 and RESULT: defect reproduced mean it changed them; exit 2 and RESULT: inconclusive mean setup or a control failed.

Reproduction script: repro-clear.mjs
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { stripTypeScriptTypes } from 'node:module';
import { execFileSync } from 'node:child_process';

const checkout = resolve(process.argv[2] ?? '.');
const scratch = mkdtempSync(join(tmpdir(), 'terminal-modes-'));
const saved = { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, T3CODE_HOME: process.env.T3CODE_HOME };
Object.assign(process.env, { HOME: scratch, TMPDIR: scratch, T3CODE_HOME: scratch });
const cores = [];
const read = p => readFileSync(join(checkout, p), 'utf8');
const moduleURL = source => 'data:text/javascript;base64,' + Buffer.from(stripTypeScriptTypes(source, { mode: 'strip' })).toString('base64');
const wasmURL = p => 'data:application/wasm;base64,' + readFileSync(join(checkout, p)).toString('base64');
let status = 2;
try {
  console.log('NODE', process.version);
  console.log('GIT', execFileSync('git', ['--version'], { encoding: 'utf8' }).trim());
  console.log('REVISION', execFileSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim());
  console.log('ENGINE', read('native/libghostty-vt/VERSION').trim());
  Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { platform: 'Linux x86_64' } });
  const shared = read('packages/shared/src/keybindings.ts');
  const matching = read('packages/shared/src/keybindingMatching.ts');
  const helper = moduleURL(shared.match(/export const US_SHIFTED_TO_UNSHIFTED_KEYS[\s\S]*?\n\]\);/)[0] + '\n' + matching.match(/export function isMacPlatform[\s\S]*?\n\}/)[0]);
  const keys = moduleURL(read('apps/web/src/terminal/ghostty/keyCodes.ts').replace('"@t3tools/shared/keybindings"', JSON.stringify(helper)));
  const runtime = moduleURL(read('apps/web/src/terminal/ghostty/runtime.ts')
    .replace(/import ghosttyWasmUrl from [^;]+;/, 'const ghosttyWasmUrl = ' + JSON.stringify(wasmURL('apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm')) + ';')
    .replace(/import ghosttyWritePtyWasmUrl from [^;]+;/, 'const ghosttyWritePtyWasmUrl = ' + JSON.stringify(wasmURL('apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm')) + ';'));
  const { GhosttyTerminalCore } = await import(moduleURL(read('apps/web/src/terminal/ghostty/core.ts').replace('"./keyCodes"', JSON.stringify(keys)).replace('"./runtime"', JSON.stringify(runtime))));
  const contractSource = read('packages/contracts/src/terminal.ts');
  const contracts = moduleURL(contractSource.slice(contractSource.indexOf('export const DEFAULT_TERMINAL_ID'), contractSource.indexOf('const TrimmedNonEmptyStringSchema')));
  const outputURL = moduleURL(read('packages/client-runtime/src/state/terminalOutput.ts').replace('"@t3tools/contracts"', JSON.stringify(contracts)));
  const sessionURL = moduleURL(read('packages/client-runtime/src/state/terminalSession.ts').replaceAll('"@t3tools/contracts"', JSON.stringify(contracts)).replaceAll('"./terminalOutput.ts"', JSON.stringify(outputURL)));
  const { retainedTerminalHistory, DEFAULT_MAX_TERMINAL_BUFFER_BYTES: cap } = await import(contracts);
  const { readTerminalOutputUpdate, INITIAL_TERMINAL_OUTPUT_CURSOR } = await import(outputURL);
  const { nextTerminalAttachSeedState, applyTerminalAttachStreamEvent } = await import(sessionURL);
  const drawer = read('apps/web/src/components/ThreadTerminalDrawer.tsx');
  const { writeTerminalOutputUpdate } = await import(moduleURL(drawer.slice(drawer.indexOf('export function writeTerminalOutputUpdate('), drawer.indexOf('// Avoid formatting unbounded'))));
  const theme = { foreground: {r:255,g:255,b:255}, background:{r:0,g:0,b:0}, cursor:{r:255,g:255,b:255} };
  async function create() {
    const replies = [];
    const core = await GhosttyTerminalCore.create(80, 24, 8, 16, theme, data => replies.push(data));
    cores.push(core);
    return { core, replies };
  }
  const key = (code, key) => ({ code, key, repeat:false, shiftKey:false, ctrlKey:false, altKey:false, metaKey:false, isComposing:false, getModifierState:()=>false });
  const mouse = { action:'press', button:1, mods:0, x:16, y:16, screenWidth:640, screenHeight:384, cellWidth:8, cellHeight:16, paddingTop:0, paddingBottom:0, paddingLeft:0, paddingRight:0, anyButtonPressed:false };
  function modes({core,replies}) {
    replies.length = 0;
    core.write('\x1b[?u');
    return { alternate:core.isAlternateScreen(), application:core.isApplicationCursorKeys(), mouse:core.isMouseTracking(), kitty:replies.join(''), arrow:core.encodeKey(key('ArrowUp','ArrowUp')), keypad:core.encodeKey(key('Numpad1','1')), release:core.encodeKey(key('KeyA','a'),'release'), click:core.encodeMouse(mouse) };
  }
  const snapshot = history => ({type:'snapshot', snapshot:{threadId:'mode-repro',terminalId:'term-1',cwd:'/fixture',worktreePath:null,status:'running',pid:123,history,exitCode:null,exitSignal:null,label:'Terminal',updatedAt:new Date().toISOString()}});
  const init = '\x1b[?1049h\x1b[>7u\x1b[?1;1000;1006h\x1b=';
  const live = await create();
  live.core.write(init);
  const expected = modes(live);
  if (!expected.alternate || !expected.application || !expected.mouse || !expected.release || !expected.click) throw Error('live mode control failed');
  const replay = await create();
  replay.core.resetAndWrite(init);
  if (JSON.stringify(modes(replay)) !== JSON.stringify(expected)) throw Error('complete replay control failed');
  console.log('CONTROL_LIVE_AND_FULL_REPLAY', JSON.stringify(expected));
  for (const mode of [47,1047,1049]) {
    const screen = await create();
    screen.core.write(`main\x1b[5;6H\x1b[?${mode}h`);
    if (!screen.core.isAlternateScreen()) throw Error('alternate entry control failed: ' + mode);
    screen.core.write(`alt\x1b[?${mode}l`);
    if (screen.core.isAlternateScreen()) throw Error('alternate exit control failed: ' + mode);
  }
  const kitty = await create();
  const flags = () => {kitty.replies.length=0;kitty.core.write('\x1b[?u');return kitty.replies.join('');};
  kitty.core.write('\x1b[>1u\x1b[?1049h\x1b[>7u');
  if(flags()!=='\x1b[?7u') throw Error('alternate kitty push failed');
  kitty.core.write('\x1b[?1049l');
  if(flags()!=='\x1b[?1u') throw Error('kitty screen independence failed');
  kitty.core.write('\x1b[=2;2u');
  if(flags()!=='\x1b[?3u') throw Error('kitty set/or failed');
  kitty.core.write('\x1b[<u');
  if(flags()!=='\x1b[?0u') throw Error('kitty pop failed');
  console.log('CONTROL_SCREEN_AND_KITTY_OPERATIONS', 'passed');
  const savedCursor = await create();
  savedCursor.core.write('\x1b[5;6H\x1b[?1048h\x1b[10;12H\x1b[?1048l');
  const savedSnapshot = savedCursor.core.snapshot();
  if (savedSnapshot.cursorX !== 5 || savedSnapshot.cursorY !== 4 || savedCursor.core.isAlternateScreen()) throw Error('1048 cursor control failed');
  console.log('CONTROL_1048_CURSOR', savedSnapshot.cursorX, savedSnapshot.cursorY);
  let state = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), snapshot(init));
  const first = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
  const cleared = await create();
  writeTerminalOutputUpdate(cleared.core, first);
  state = applyTerminalAttachStreamEvent(state, {type:'cleared',threadId:'mode-repro',terminalId:'term-1'});
  const update = readTerminalOutputUpdate(state.output, first.cursor);
  writeTerminalOutputUpdate(cleared.core, update);
  const afterClear = modes(cleared);
  console.log('CLEAR_UPDATE', JSON.stringify({type:update.type,data:update.data}));
  console.log('AFTER_CLEAR', JSON.stringify(afterClear));
  status = JSON.stringify(afterClear) === JSON.stringify(expected) ? 0 : 1;
} catch (error) {
  console.log('ERROR', error.message.split('\n')[0].slice(0,300));
} finally {
  for (const core of cores) core.dispose();
  for (const [key,value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
  rmSync(scratch,{recursive:true,force:true});
}
console.log(status === 1 ? 'RESULT: defect reproduced' : status === 0 ? 'RESULT: expected behavior' : 'RESULT: inconclusive');
process.exitCode = status;

Expected behavior

Clearing retained terminal text while the same PTY program remains alive must preserve the program's screen selection and negotiated input formats. The server's clear lifecycle explicitly retains the process, and docs/internals/terminal-runtime.md#L3-L6 promises that clients can share running sessions. The XTerm screen and input-mode definitions give screen selection, application cursor keys, and mouse tracking separate mode setters/resetters; the Kitty keyboard protocol maintains independent per-screen flag stacks until matching protocol operations change them. The successful full-replay control establishes that the pinned engine supports this input and preserves it before Clear.

Restoration check: the script's mode reads, Kitty reply, arrow sequence, key-release sequence, and SGR click remain equal to the pre-clear control after Clear, while the terminal's retained text is cleared and the same program continues. Reconnecting another viewer after Clear must agree with the running program's modes; that reconnect-after-clear check was not executed in this probe.

Impact and scope

Impact: P2 - An explicit history-clear action changes input delivered to an already running terminal program; clicks and release events disappear and arrow encoding changes. The trigger is bounded to Clear while such modes are active.

The web engine/reducer result is deterministic in this probe. The source path starts at mobile Clear and also reaches attached web/desktop viewers. Native iOS/Android input behavior and a real multi-device session were not run. Avoiding Clear while a mode-dependent program is active avoids this trigger. NOT_ENOUGH_INFO: no recovery preserving an already affected application's state was exercised; a real application lifecycle check would establish recovery.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Vendored libghostty-vt revision 9f62873bf195e4d8a762d768a1405a5f2f7b1697 from native/libghostty-vt/VERSION.

Runtime and platform

Darwin arm64, Node v26.12.1, Git 2.56.0. Headless production TypeScript code and actual vendored WebAssembly. navigator.platform is a fixture set to Linux x86_64 for the browser adapter's key helper; no browser or Linux runtime was tested.

Interface

UI: mobile terminal Clear action, via terminal.clear RPC and shared client events, affecting a web terminal core attached to the same session.

Earliest failure stage

Cleanup or lifecycle.

Relevant output or evidence

Observed final-script output, with no private identifiers or machine paths:

NODE v26.12.1
GIT git version 2.56.0
REVISION 59e64eef881632a23f89c4138bcf37f23d92bf93
ENGINE 9f62873bf195e4d8a762d768a1405a5f2f7b1697
CONTROL_LIVE_AND_FULL_REPLAY {"alternate":true,"application":true,"mouse":true,"kitty":"\u001b[?7u","arrow":"\u001b[1;1:1A","keypad":"1","release":"\u001b[97;1:3u","click":"\u001b[<0;3;2M"}
CONTROL_SCREEN_AND_KITTY_OPERATIONS passed
CONTROL_1048_CURSOR 5 4
CLEAR_UPDATE {"type":"reset","data":""}
AFTER_CLEAR {"alternate":false,"application":false,"mouse":false,"kitty":"\u001b[?0u","arrow":"\u001b[A","keypad":"1","release":"","click":""}
RESULT: defect reproduced

Expected source: the successful live/full-replay controls plus the protocol references and server's process-preserving clear lifecycle above. Failure source: the production reducer resets the output on cleared at packages/client-runtime/src/state/terminalSession.ts#L193-L199, the output reader emits reset at packages/client-runtime/src/state/terminalOutput.ts#L259-L268, the drawer applies it at apps/web/src/components/ThreadTerminalDrawer.tsx#L158-L167, and the core performs ghostty_terminal_reset at apps/web/src/terminal/ghostty/core.ts#L375-L383.

Evidence provenance: observed for the reducer/core/input results, static for the mobile action and server RPC lifecycle. Local verification: reproduced at the production event-to-engine boundary. Reproduction completeness: complete for that boundary; incomplete for an actual mobile-to-server-to-web session. Missing fact: whether native iOS/Android show the same input changes requires native checks; this report's concrete input observations are web-core observations.

Additional context

  • https://github.com/pingdotgg/t3code/pull/31752 — concerns Clear waiting for continuously queued persistence; it retains the cleared event and does not preserve input modes. This probe uses an idle fixture with no persistence worker.
  • https://github.com/pingdotgg/t3code/pull/5440 — addressed iOS surface recreation/crashes after Clear followed by input; this report concerns the web core's loss of live input state through the shared event, with no surface teardown race.
  • https://github.com/pingdotgg/t3code/issues/9219 — concerns dead-process history contaminating a new shell; this report concerns a still-running program losing its requested modes when history is cleared.
  1. tanstack-source added labelsbug

  2. github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced

Sign in to join this conversation.