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.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 21:33:20 -05:00
parent 6c52e4a318
commit 1a1e00f37e
8 changed files with 41 additions and 50 deletions

View file

@ -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
<ContextMenuItem disabled={!canCloseOthers} onSelect={() => closeOtherTerminals(term.id)}>
{t.rightSidebar.terminalCloseOthers}
</ContextMenuItem>
<ContextMenuItem onSelect={closeAllTerminals}>{t.rightSidebar.terminalCloseAll}</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => setTerminalTakeover(false)}>{t.rightSidebar.terminalHide}</ContextMenuItem>
</ContextMenuContent>

View file

@ -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

View file

@ -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)
}
})

View file

@ -1887,6 +1887,7 @@ export const en: Translations = {
terminalsAria: 'Terminals',
terminalNew: 'New terminal',
terminalCloseOthers: 'Close others',
terminalCloseAll: 'Close all',
addToChat: 'Add to chat'
},

View file

@ -2004,6 +2004,10 @@ export const ja = defineLocale({
loadingTree: 'ファイルツリーを読み込み中',
loadingFiles: 'ファイルを読み込み中',
terminalHide: 'ターミナルを非表示',
terminalsAria: 'ターミナル',
terminalNew: '新しいターミナル',
terminalCloseOthers: '他を閉じる',
terminalCloseAll: 'すべて閉じる',
addToChat: 'チャットに追加'
},

View file

@ -1540,6 +1540,7 @@ export interface Translations {
terminalsAria: string
terminalNew: string
terminalCloseOthers: string
terminalCloseAll: string
addToChat: string
}

View file

@ -1942,6 +1942,10 @@ export const zhHant = defineLocale({
loadingTree: '正在載入檔案樹',
loadingFiles: '正在載入檔案',
terminalHide: '隱藏終端機',
terminalsAria: '終端機',
terminalNew: '新增終端機',
terminalCloseOthers: '關閉其他',
terminalCloseAll: '全部關閉',
addToChat: '新增至聊天'
},

View file

@ -2057,6 +2057,7 @@ export const zh: Translations = {
terminalsAria: '终端',
terminalNew: '新建终端',
terminalCloseOthers: '关闭其他',
terminalCloseAll: '关闭全部',
addToChat: '添加到对话'
},