From 1a1e00f37e0f4558dc930b7b0147f01411e02e76 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:33:20 -0500 Subject: [PATCH] fix(desktop): stop injecting ctrl-l into terminal startup Remove the prompt-gap cleanup that sent Ctrl-L into the user's shell; it could render as literal ^L and create the exact top-line gap it was meant to hide. Keep first-prompt cleanup renderer-side only, and parse short ESC charset sequences so the initial newline stripper does not disarm early. Also add a Close all action to the terminal tab context menu. --- .../src/app/right-sidebar/terminal/rail.tsx | 2 + .../app/right-sidebar/terminal/terminals.ts | 19 +++++- .../terminal/use-terminal-session.ts | 59 ++++--------------- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 4 ++ apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 4 ++ apps/desktop/src/i18n/zh.ts | 1 + 8 files changed, 41 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index 8a9c74cb511..c5a07bb8e6d 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -19,6 +19,7 @@ import { setTerminalTakeover } from '../store' import { $activeTerminalId, $terminals, + closeAllTerminals, closeOtherTerminals, closeTerminal, createTerminal, @@ -167,6 +168,7 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T closeOtherTerminals(term.id)}> {t.rightSidebar.terminalCloseOthers} + {t.rightSidebar.terminalCloseAll} setTerminalTakeover(false)}>{t.rightSidebar.terminalHide} diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index f008780a5ce..1e7d36240da 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -110,7 +110,10 @@ export function cycleTerminal(direction: 1 | -1): void { return } - const current = Math.max(0, list.findIndex(term => term.id === $activeTerminalId.get())) + const current = Math.max( + 0, + list.findIndex(term => term.id === $activeTerminalId.get()) + ) $activeTerminalId.set(list[(current + direction + list.length) % list.length].id) } @@ -162,6 +165,16 @@ export function closeActiveTerminal(): void { } } +export function closeAllTerminals(): void { + if ($terminals.get().length === 0) { + return + } + + $terminals.set([]) + $activeTerminalId.set(null) + setTerminalTakeover(false) +} + export function closeOtherTerminals(id: string): void { const keep = $terminals.get().find(term => term.id === id) @@ -174,7 +187,9 @@ export function closeOtherTerminals(id: string): void { export function renameTerminal(id: string, title: string): void { const trimmed = title.trim() - $terminals.set($terminals.get().map(term => (term.id === id ? { ...term, title: trimmed || term.title, auto: false } : term))) + $terminals.set( + $terminals.get().map(term => (term.id === id ? { ...term, title: trimmed || term.title, auto: false } : term)) + ) } /** A live terminal reports its resolved shell; adopt it as the label only while diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts index b9f349dc7b2..9a7e690c7c8 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts @@ -66,6 +66,14 @@ function readEscapeSequence(data: string, index: number) { } } + // Character-set and other short ESC forms are three bytes (e.g. ESC ( B). + // Treating only ESC+( as a sequence leaves the final selector ("B") as + // printable text, which disarms the initial prompt-gap stripper before it can + // eat the shell's leading newline. + if (['(', ')', '*', '+', '-', '.', '/'].includes(kind) && index + 2 < data.length) { + return data.slice(index, index + 3) + } + return data.slice(index, Math.min(index + 2, data.length)) } @@ -419,18 +427,9 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh host.removeEventListener('drop', onDrop) }) - // A fresh prompt should sit at the top. Every resize SIGWINCHes the shell, - // which reprints its prompt and can leave stale blank rows above it. While - // the session is pristine (nothing run yet) we ask the shell to clear + - // redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves - // multi-line prompts (term.clear() would drop all but the cursor row) and we - // stop the moment real output exists, so command scrollback is never wiped. - let promptPristine = true - let gapCleanupTimer = 0 - - // While armed, strip leading blank rows so the prompt lands at the very top - // (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the - // resize cleanup doesn't reintroduce the blank line. + // While armed, strip leading blank rows so the first prompt lands at the + // very top (no starship `add_newline` gap). Do this only on renderer output: + // never inject Ctrl-L or other cleanup keystrokes into the user's shell. let stripLeading = true const armedWrite = (data: string) => { @@ -459,35 +458,6 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh term.write(next) } - const scheduleGapCleanup = () => { - if (!promptPristine) { - return - } - - if (gapCleanupTimer) { - window.clearTimeout(gapCleanupTimer) - } - - gapCleanupTimer = window.setTimeout(() => { - gapCleanupTimer = 0 - const id = sessionIdRef.current - - if (disposed || !id || !promptPristine) { - return - } - - stripLeading = true - void terminalApi.write(id, '\f') - term.clearSelection() - }, 120) - } - - cleanup.push(() => { - if (gapCleanupTimer) { - window.clearTimeout(gapCleanupTimer) - } - }) - const fitAndResize = () => { if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) { return @@ -504,7 +474,6 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) { lastSentSize = { cols: term.cols, rows: term.rows } void terminalApi.resize(id, { cols: term.cols, rows: term.rows }) - scheduleGapCleanup() } } @@ -543,12 +512,6 @@ export function useTerminalSession({ id, cwd, active, onAddSelectionToChat, onSh const id = sessionIdRef.current if (id) { - // Once the user submits a line, real output may follow — stop the - // pristine-prompt gap cleanup so we never clear command scrollback. - if (promptPristine && data.includes('\r')) { - promptPristine = false - } - void terminalApi.write(id, data) } }) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 74df4c8f0b4..8e9243bb259 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1887,6 +1887,7 @@ export const en: Translations = { terminalsAria: 'Terminals', terminalNew: 'New terminal', terminalCloseOthers: 'Close others', + terminalCloseAll: 'Close all', addToChat: 'Add to chat' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index bdf4e882929..d3b29ce9f2c 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2004,6 +2004,10 @@ export const ja = defineLocale({ loadingTree: 'ファイルツリーを読み込み中', loadingFiles: 'ファイルを読み込み中', terminalHide: 'ターミナルを非表示', + terminalsAria: 'ターミナル', + terminalNew: '新しいターミナル', + terminalCloseOthers: '他を閉じる', + terminalCloseAll: 'すべて閉じる', addToChat: 'チャットに追加' }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index aace3e5e9ab..31a26dc187a 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1540,6 +1540,7 @@ export interface Translations { terminalsAria: string terminalNew: string terminalCloseOthers: string + terminalCloseAll: string addToChat: string } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 24e0ff12dac..6fc1c64203f 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1942,6 +1942,10 @@ export const zhHant = defineLocale({ loadingTree: '正在載入檔案樹', loadingFiles: '正在載入檔案', terminalHide: '隱藏終端機', + terminalsAria: '終端機', + terminalNew: '新增終端機', + terminalCloseOthers: '關閉其他', + terminalCloseAll: '全部關閉', addToChat: '新增至聊天' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 4564e746139..7d7cd5d5016 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2057,6 +2057,7 @@ export const zh: Translations = { terminalsAria: '终端', terminalNew: '新建终端', terminalCloseOthers: '关闭其他', + terminalCloseAll: '关闭全部', addToChat: '添加到对话' },