[Bug]: Clearing terminal history resets the live program's screen and input modes
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 22/100
- Issue 类型
- 缺陷
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- typescript, wasm
调研方向
The defect starts at the cleared event in applyTerminalAttachStreamEvent in packages/client-runtime/src/state/terminalSession.ts, and the reset happens in writeTerminalOutputUpdate in apps/web/src/components/ThreadTerminalDrawer.tsx, which calls the core's full reset. Run repro-clear.mjs against the checkout first and confirm it reports the defect. Done means the script exits 0 with 'RESULT: expected behavior', so Clear keeps the alternate screen, Kitty flags, application cursor keys and mouse tracking.
由索引模型根据 Issue 内容生成。
描述
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
- 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.
- 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. - Apply a
clearedevent throughapplyTerminalAttachStreamEvent,readTerminalOutputUpdate, andwriteTerminalOutputUpdate, 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
clearedevent 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.
- 主要语言
- TypeScript
- 星标
- 24.8k
- 派生
- 6.4k
- 平均合并
- 59 分钟
- 30 天内合并 PR
- 418
环境准备
在浏览器里用你自己的 GitHub 账号启动这个项目的开发容器。
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
pingdotgg/t3code 的其他 Issue
-
难度 2/5 1-3 小时 新手友好度 66/100
维护者通常 1 天内回复
-
via-triage
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
难度 2/5 半天 新手友好度 68/100
维护者通常 1 天内回复
-
Question card options show LaTeX as raw source, though the question text renders it可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 2/5 1-3 小时 新手友好度 76/100
维护者通常 1 天内回复
-
[Bug]: Settings inheritance popover shows raw slugs for branch naming, default permissions and response streaming可能已有人在做 @juliusmarminge 于 1 天前认领。 未关闭vouch:denounced
难度 2/5 1-3 小时 新手友好度 84/100
维护者通常 1 天内回复
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 72/100
nikoheikkila/nikoheikkila.fi#1131 ·
维护者通常 1 天内回复
-
Resources: Update work of urumqi可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 2/5 1-3 小时 新手友好度 65/100
railmapgen/rmp-gallery#4176 ·
维护者通常 2 天内回复
-
GraphQL custom headers duplicate differently cased default headers可能已有人在做 @yxr-2025 今天认领。 未关闭
难度 2/5 1-3 小时 新手友好度 70/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 76/100
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 65/100
github/forgoodfirstissue#574 ·