Compare commits

..
10 Commits
Author SHA1 Message Date
Bill 97fc7171cc chore: bump version to 1.0.10 2026-09-15 13:17:28 +08:00
Bill d3d029fe4f fix: wire plain Ctrl/Cmd+V to paste and split the check by line count
Plain Ctrl+V was never the terminal's: it went to the pty as a literal ^V
(0x16) — the command history even recorded 'cd \u0016' — so nothing pasted
and the confirm dialog never saw it. It is now handled on the terminal host
in the capture phase, with preventDefault, feeding the same path as
Ctrl+Shift+V.

The paste check is now shape-based rather than length-based: two or more
lines confirm (a stray newline executes an unreviewed command), a single
line pastes straight through unless it is unusually long.
2026-09-15 11:45:17 +08:00
Bill 77fd241b43 chore(dev): give dev builds their own userData and single-instance lock
A dev instance shared the installed build's userData directory and lock, so
starting it demanded killing the real app and it wrote test settings and
session snapshots into the live profile. Dev now uses OpenTerminal-dev and
tags its window title '(dev)'; both instances run side by side.
2026-09-15 01:02:10 +08:00
Bill 4b336360f8 fix(release): scope the proxy to GitHub so the domestic channel stays direct
setGlobalDispatcher routed every request — including the Gitea release and
channel PUTs — through the local proxy, which reset mid-upload once and left
the channel half-written. The proxy agent is now passed explicitly on the
GitHub requests only.
2026-09-15 00:34:30 +08:00
Bill d7abbaad9c fix: paste through xterm so large multi-line blocks insert instead of executing
confirmPaste wrote the raw text to the pty, so a large paste bypassed
bracketed paste and PowerShell ran it line by line; it also skipped
onData, which is why history/cwd tracking needed a separate mirror. Now the
text goes through term.paste(): xterm adds the \x1b[200~ markers when the
shell enabled bracketed paste (one edit, no execution) and the normal
onData path restores tracking for free.

The risky-paste bar becomes a proper dialog (确认粘贴 / 本次会话不再提示 /
关闭后续粘贴检测), matching the behaviour users expect from other
terminals.
2026-09-15 00:27:19 +08:00
Bill cbf4c224fb fix: allocate terminal titles by lowest free number instead of a counter
Closing 终端 6/7 then opening a new one produced 终端 8 rather than
refilling the gap. nextTerminalTitle now scans live panel titles and takes
the smallest unused N, which also subsumes the restore-time counter
seeding (syncTitleSeq is gone) — duplicate retitling after a restore fills
the lowest free number too.
2026-09-14 22:24:36 +08:00
Bill 03aa79a691 chore: bump version to 1.0.8 2026-09-14 21:41:46 +08:00
Bill 458dd9c88b fix: write session logs as plain text instead of raw PTY soup
Recorded logs carried the raw stream: SGR colors, cursor moves,
synchronized-output markers, and every TUI redraw frame — unreadable in an
editor and far larger than the visible output (a short kimi session logged
21.5KB of which 4.4KB is text). A per-session sanitizer now strips ANSI
statefully across chunk boundaries, collapses carriage-return overwrites
(progress bars keep only their final text), and suppresses alternate-screen
frames with a marker line. Ink-style inline TUIs redraw in the normal
buffer and cannot be frame-collapsed without screen emulation; their
committed lines are preserved.
2026-09-14 21:17:45 +08:00
Bill 28d8af6982 fix: keep terminal titles unique across session restore
The 终端 N counter lives only in memory, so after a restore it restarted at
zero and the next new terminal duplicated a restored title. Broadcast keys
targets by session id so duplicate titles never broke the fan-out itself,
but the target list became indistinguishable. Seed the counter past the
restored maximum on session restore / template apply, and retitle
duplicates already baked into existing snapshots.
2026-09-14 20:56:02 +08:00
Bill f09bed06b5 remove: drop the quick-input panel
The toggle state was a global singleton while the bolt button renders in
every pane's tab bar, so all panes showed the same open/close state and
the single floating panel (window bottom-right, sends to the *active*
session) never corresponded to the pane whose button was clicked. A true
per-pane rework is a medium refactor for a feature that overlaps with the
inline completion and the sidebar commands panel — removing instead.
2026-09-14 20:47:21 +08:00
13 changed files with 340 additions and 461 deletions

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
## 常用命令 ## 常用命令
- 开发:`npm run dev`(主进程改动不热重建,需重启) - 开发:`npm run dev`(主进程改动不热重建,需重启)
- dev 实例使用独立用户数据目录 `%APPDATA%\OpenTerminal-dev` 与独立单实例锁(`src/main/index.ts` 顶部 `!app.isPackaged` 分支),窗口标题带 `(dev)`:**可与已安装的正式版同时运行,互不干扰**,也不会把测试设置/会话写进真实配置
- 类型检查:`npx tsc --noEmit -p tsconfig.web.json` - 类型检查:`npx tsc --noEmit -p tsconfig.web.json`
- 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap) - 打包:`npm run dist`,产物在 `release/`(msi + exe + latest.yml + blockmap)
- 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist` - 国内网络需镜像:`ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run dist`
+1 -1
View File
@@ -73,7 +73,7 @@
## M6 — 多终端协同 ✅ ## M6 — 多终端协同 ✅
- [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭 - [x] 广播输入(broadcastStore):选中 ≥2 个会话同步键入;标签广播标记 + 按钮计数徽标;目标 <2 自动关闭
- [x] 快捷输入面板(Quick Input):右下角浮层 = 命令输入 + 自动执行(\r) + 快捷命令 + 最近命令;广播感知发送 - [x] ~~快捷输入面板(Quick Input)~~(已移除:全局单例开关与面板不对一一对应,功能与行内补全/侧边栏命令面板重叠)
- [x] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs) - [x] ZMODEM(rz/sz):主进程引擎(zmodem.js,仅 SSH 会话;本地 ConPTY 不支持二进制),文件选择/保存目录对话框 + 传输面板进度 + 看门狗超时;双向 e2e(tests/zmodem-e2e.mjs)
- [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide) - [x] 快捷键系统:Ctrl+=/-/0 字号(持久化)、Ctrl+PgUp/PgDn 切换终端、全局唤起/隐藏(设置页可配 globalShowHide)
- 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测) - 验收:3 个分屏同步执行命令 ✅(活体验证:终端 3 键入扇出到终端 4);rz 传输 ✅(e2e 双向内容一致;真实服务器 rz/sz 待用户复测)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "open-terminal", "name": "open-terminal",
"productName": "OpenTerminal", "productName": "OpenTerminal",
"version": "1.0.7", "version": "1.0.10",
"description": "Open-source terminal with SSH, split panes, themes and fonts", "description": "Open-source terminal with SSH, split panes, themes and fonts",
"main": "out/main/index.js", "main": "out/main/index.js",
"author": "Bill", "author": "Bill",
+13 -9
View File
@@ -48,14 +48,16 @@ if (ymlVersion !== V) {
process.exit(1) process.exit(1)
} }
// Proxy applies to GitHub only. The domestic Gitea host is fastest — and most
// reliable — over a direct connection; routing it through a local proxy is
// what once broke a channel upload (ECONNRESET mid-PUT), and a proxy that is
// down would take the domestic channel with it. So no global dispatcher here:
// the agent is passed explicitly on GitHub requests.
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY
if (proxy) { const proxyAgent = proxy ? new (require('undici').ProxyAgent)(proxy) : undefined
const { ProxyAgent, setGlobalDispatcher } = require('undici') if (proxy) console.log('proxy for GitHub only:', proxy)
setGlobalDispatcher(new ProxyAgent(proxy))
console.log('using proxy:', proxy)
}
async function upload(url, file, token, authScheme) { async function upload(url, file, token, authScheme, viaProxy = false) {
const name = path.basename(file) const name = path.basename(file)
const stat = fs.statSync(file) const stat = fs.statSync(file)
const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, { const resp = await fetch(`${url}${url.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`, {
@@ -66,7 +68,8 @@ async function upload(url, file, token, authScheme) {
'Content-Length': String(stat.size) 'Content-Length': String(stat.size)
}, },
body: fs.createReadStream(file), body: fs.createReadStream(file),
duplex: 'half' duplex: 'half',
...(viaProxy && proxyAgent ? { dispatcher: proxyAgent } : {})
}) })
console.log(` upload ${name}: HTTP ${resp.status}`) console.log(` upload ${name}: HTTP ${resp.status}`)
if (!resp.ok) { if (!resp.ok) {
@@ -136,14 +139,15 @@ async function github() {
'User-Agent': 'OpenTerminal', 'User-Agent': 'OpenTerminal',
Accept: 'application/vnd.github+json' Accept: 'application/vnd.github+json'
}, },
body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }) body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }),
...(proxyAgent ? { dispatcher: proxyAgent } : {})
}) })
if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) } if (!resp.ok) { throw new Error(`GitHub release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) }
const rel = await resp.json() const rel = await resp.json()
console.log('release created, id =', rel.id) console.log('release created, id =', rel.id)
const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets` const up = `https://uploads.github.com/repos/billowliu2/OpenTerminal/releases/${rel.id}/assets`
for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) { for (const f of [...files, path.join(R, `OpenTerminal-${V}-setup.exe.blockmap`), path.join(R, 'latest.yml')]) {
await upload(up, f, env.GH_TOKEN, 'Bearer') await upload(up, f, env.GH_TOKEN, 'Bearer', true)
} }
} }
+19 -1
View File
@@ -26,6 +26,7 @@ import { join } from 'path'
import type { CommandItem, SessionLogMeta } from '../shared/commands' import type { CommandItem, SessionLogMeta } from '../shared/commands'
import { DEFAULT_SETTINGS } from '../shared/settings' import { DEFAULT_SETTINGS } from '../shared/settings'
import { loadSettings } from './settingsStore' import { loadSettings } from './settingsStore'
import { LogSanitizer } from './logSanitizer'
/** Upper bound on recorded history entries when no limit is configured. */ /** Upper bound on recorded history entries when no limit is configured. */
const HISTORY_CAP = 500 const HISTORY_CAP = 500
@@ -77,6 +78,8 @@ export class CommandsStore {
* onto the previous one keeps the file append-only in call order. * onto the previous one keeps the file append-only in call order.
*/ */
private readonly pendingAppends = new Map<string, Promise<void>>() private readonly pendingAppends = new Map<string, Promise<void>>()
/** Plain-text transformer per actively-logged session (see logSanitizer). */
private readonly sanitizers = new Map<string, LogSanitizer>()
constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) { constructor(userData?: string, openPathImpl?: (p: string) => Promise<unknown>) {
const ud = userData ?? app.getPath('userData') const ud = userData ?? app.getPath('userData')
@@ -269,6 +272,7 @@ export class CommandsStore {
const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() } const meta: SessionLogMeta = { sessionId, file, fileName, startedAt: Date.now() }
this.metasByFile.set(file, meta) this.metasByFile.set(file, meta)
this.activeBySession.set(sessionId, meta) this.activeBySession.set(sessionId, meta)
this.sanitizers.set(sessionId, new LogSanitizer())
this.persistIndex() this.persistIndex()
return meta return meta
} }
@@ -280,9 +284,12 @@ export class CommandsStore {
// not logging (or already stopped) -> ignore, prevents stop/append race // not logging (or already stopped) -> ignore, prevents stop/append race
return return
} }
// Raw PTY output is escape-sequence soup in a text file; log plain text.
const clean = this.sanitizers.get(sessionId)?.push(data) ?? ''
if (!clean) return
// Chain onto any in-flight append so rapid writes land in call order. // Chain onto any in-flight append so rapid writes land in call order.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve() const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, data, 'utf8')).then( const next = prev.then(() => appendFile(meta.file, clean, 'utf8')).then(
() => undefined, () => undefined,
() => { () => {
// file may have been removed after stop -> ignore // file may have been removed after stop -> ignore
@@ -299,6 +306,17 @@ export class CommandsStore {
logStop(sessionId: string): void { logStop(sessionId: string): void {
const meta = this.activeBySession.get(sessionId) const meta = this.activeBySession.get(sessionId)
if (!meta) return if (!meta) return
const tail = this.sanitizers.get(sessionId)?.flush() ?? ''
this.sanitizers.delete(sessionId)
if (tail) {
// Best effort: the trailing partial line belongs in the file too.
const prev = this.pendingAppends.get(meta.file) ?? Promise.resolve()
const next = prev.then(() => appendFile(meta.file, tail, 'utf8')).then(
() => undefined,
() => undefined
)
this.pendingAppends.set(meta.file, next)
}
this.finishLog(meta) this.finishLog(meta)
} }
+18
View File
@@ -20,6 +20,15 @@ function showOrCreate(): void {
} }
} }
// Dev runs get their own userData directory (settings, session snapshot,
// command history, logs) *and* their own single-instance lock — both are keyed
// on that path. Without this a dev instance fights the installed build: it
// takes the lock, so the other side is refused / must be killed, and it writes
// test data straight into the real profile. Must run before the lock below.
if (!app.isPackaged) {
app.setPath('userData', join(app.getPath('appData'), 'OpenTerminal-dev'))
}
// Single instance: a second launch just surfaces the existing window (pulls // Single instance: a second launch just surfaces the existing window (pulls
// it out of the tray if hidden there) instead of starting another process. // it out of the tray if hidden there) instead of starting another process.
const gotSingleInstanceLock = app.requestSingleInstanceLock() const gotSingleInstanceLock = app.requestSingleInstanceLock()
@@ -64,6 +73,15 @@ function createWindow(): void {
win.on('ready-to-show', () => win.show()) win.on('ready-to-show', () => win.show())
// Dev window wears a "(dev)" tag so it is never confused with the installed
// build sitting next to it (they no longer share userData — see above).
if (!app.isPackaged) {
win.on('page-title-updated', (e) => {
e.preventDefault()
win.setTitle('OpenTerminal (dev)')
})
}
// Close button → tray / exit per the closeAction setting (ask by default). // Close button → tray / exit per the closeAction setting (ask by default).
win.on('close', (e) => { win.on('close', (e) => {
void onMainWindowClose(win, e, showOrCreate) void onMainWindowClose(win, e, showOrCreate)
+147
View File
@@ -0,0 +1,147 @@
/**
* Plain-text sanitizer for session logs.
*
* Raw PTY output is unreadable in a text file: SGR colors, cursor moves,
* synchronized-output markers — and for full-screen TUIs (vim, htop) every
* redrawn frame. For each logged session this filter:
* 1. strips ANSI escape sequences (CSI / OSC / single-char), statefully
* across chunk boundaries (a sequence may split between two PTY chunks);
* 2. collapses carriage-return overwrites (progress bars, spinner lines)
* keeping only the final text of the line — a `\r` directly followed by
* `\n` is a line ending, not an overwrite;
* 3. drops output produced while the alternate screen is active (TUI
* redraw frames), emitting one marker line per suppressed span.
*
* Note: inline TUIs that redraw in the *normal* buffer (Ink-based CLIs such
* as Claude Code / kimi) cannot be frame-collapsed without full screen
* emulation; their committed lines are preserved as-is.
*/
const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n'
type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc'
/** Alt-screen toggles: CSI ? 1049|1047|47 h / l */
const ALT_ON = /^\?(?:1049|1047|47)h$/
const ALT_OFF = /^\?(?:1049|1047|47)l$/
export class LogSanitizer {
private mode: Mode = 'text'
/** raw bytes of the escape sequence in flight (bounded: OSC can be long) */
private seq = ''
private alt = false
/** some output was suppressed since the last marker */
private altDirty = false
/** current line, not yet newline-terminated */
private line = ''
/** previous text char was \r — the next char decides ending vs overwrite */
private cr = false
/** Transform one raw chunk; returns the plain text to append ('' for none). */
push(data: string): string {
let out = ''
for (let i = 0; i < data.length; i++) {
const c = data[i]
// A pending \r: `\r\n` completes the line; anything else overwrites it.
if (this.cr) {
this.cr = false
if (c === '\n') {
out += this.emitLine()
continue
}
this.line = ''
// fall through and process c normally
}
switch (this.mode) {
case 'text':
if (c === '\x1b') {
this.mode = 'esc'
this.seq = ''
} else if (c === '\r') {
this.cr = true
} else if (c === '\n') {
out += this.emitLine()
} else if (c === '\t' || c >= ' ' || c === '\x7f') {
// printable + tab; DEL and C0 controls (bell etc.) are dropped
if (this.alt) this.altDirty = true
else this.line += c
}
break
case 'esc':
if (c === '[') this.mode = 'csi'
else if (c === ']') this.mode = 'osc'
else if (c === '(' || c === ')' || c === '#' || c === '%') this.mode = 'esc-skip1'
else this.mode = 'text' // single-character escape, consumed
break
case 'esc-skip1':
this.mode = 'text'
break
case 'csi':
if (c >= '@' && c <= '~') {
// the final byte is not part of `seq` — test the full sequence
const full = this.seq + c
if (ALT_ON.test(full)) {
this.alt = true
} else if (ALT_OFF.test(full)) {
this.alt = false
if (this.altDirty) {
this.altDirty = false
out += MARKER
}
}
this.seq = ''
this.mode = 'text'
} else if (this.seq.length < 1024) {
this.seq += c
}
break
case 'osc':
if (c === '\x07') this.mode = 'text'
else if (c === '\x1b') this.mode = 'osc-esc'
break
case 'osc-esc':
// ST is ESC \; a new ESC [ / ESC ] starts the next sequence
if (c === '\\') this.mode = 'text'
else if (c === '[') {
this.mode = 'csi'
this.seq = ''
} else if (c === ']') this.mode = 'osc'
else this.mode = 'text'
break
}
}
return out
}
/** Flush the pending partial line at log stop. */
flush(): string {
let out = ''
if (this.cr) {
this.cr = false
// A trailing \r at stop: treat as line ending rather than overwrite.
out += this.emitLine()
} else if (this.line && !this.alt) {
out += this.line
this.line = ''
}
if (this.altDirty) {
this.altDirty = false
out += MARKER
}
return out
}
/** Complete the current line: hand it out with a newline and reset. */
private emitLine(): string {
if (this.alt) {
if (this.line) this.altDirty = true
this.line = ''
return ''
}
const out = this.line ? this.line + '\n' : '\n'
this.line = ''
return out
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ export function RenderSettingsTab(): React.JSX.Element {
/> />
<SettingRow <SettingRow
label="粘贴风险确认" label="粘贴风险确认"
desc="粘贴前弹出风险确认提示" desc="多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴"
control={ control={
<Switch <Switch
checked={settings.terminal.pasteRiskConfirm} checked={settings.terminal.pasteRiskConfirm}
+96 -45
View File
@@ -7,6 +7,7 @@ import { WebglAddon } from '@xterm/addon-webgl'
import { SearchAddon } from '@xterm/addon-search' import { SearchAddon } from '@xterm/addon-search'
import '@xterm/xterm/css/xterm.css' import '@xterm/xterm/css/xterm.css'
import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { ClearOutlined, CopyOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons'
import { Checkbox, Modal } from 'antd'
import { getThemeById } from '@shared/theme' import { getThemeById } from '@shared/theme'
import type { CommandItem } from '@shared/commands' import type { CommandItem } from '@shared/commands'
import type { TerminalSettings } from '@shared/settings' import type { TerminalSettings } from '@shared/settings'
@@ -31,12 +32,27 @@ function toFontWeight(n: number): 'normal' | 'bold' | number {
return n return n
} }
/** Paste-risk heuristic: multi-line or unreasonably long text. */ /**
function isRiskyPaste(text: string): boolean { * Paste-risk heuristic, split by shape rather than by raw length:
if (text.includes('\n') || text.includes('\r')) return true * - two or more lines always confirm — a stray newline executes a command
return text.length > 100 * the user never reviewed;
* - a single line pastes straight through, unless it is unusually long
* (those are pasted scripts rather than something typed by hand).
*/
const SINGLE_LINE_CONFIRM_LENGTH = 1000
function needsPasteConfirm(text: string): boolean {
const normalized = text.replace(/\r\n?/g, '\n').replace(/\n$/, '')
if (normalized.includes('\n')) return true
return normalized.length > SINGLE_LINE_CONFIRM_LENGTH
} }
/**
* "本次会话不再提示" from the paste confirmation dialog. Deliberately in memory
* only: it lasts until the app exits and never becomes a stored preference
* (that is what 关闭后续粘贴检测 in the dialog is for).
*/
let pasteConfirmedForSession = false
const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = { const SEARCH_DECORATIONS: NonNullable<NonNullable<Parameters<SearchAddon['findNext']>[1]>['decorations']> = {
matchBackground: '#3b8eea', matchBackground: '#3b8eea',
matchBorder: '#2d2d2d', matchBorder: '#2d2d2d',
@@ -306,7 +322,7 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
// time (decides if 复制 is enabled). // time (decides if 复制 is enabled).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null) const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null)
const [menuHasSel, setMenuHasSel] = useState(false) const [menuHasSel, setMenuHasSel] = useState(false)
const [paste, setPaste] = useState<string | null>(null) const [paste, setPaste] = useState<{ noPrompt: boolean; disableDetection: boolean } | null>(null)
const [dead, setDead] = useState(false) const [dead, setDead] = useState(false)
const [exitCode, setExitCode] = useState(0) const [exitCode, setExitCode] = useState(0)
const [recording, setRecording] = useState(false) const [recording, setRecording] = useState(false)
@@ -457,40 +473,20 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
[sessionId] [sessionId]
) )
/**
* Pasted text is written straight to the pty (confirmPaste → writeBroadcast),
* bypassing xterm's onData, so the line buffer / history / cwd tracker would
* never see it — a pasted `cd X` used to be recorded as the bare prefix typed
* before it. Mirror the shell: each completed line is a submitted command,
* the trailing partial line stays pending in the buffer.
*/
const trackPastedText = useCallback(
(data: string): void => {
if (termRef.current?.buffer.active.type === 'alternate') return
const parts = data.replace(/\r\n/g, '\n').split('\n')
const tail = parts.pop() ?? ''
for (const line of parts) {
const t = line.trim()
lineBufRef.current = ''
if (t) trackSubmittedLine(t)
}
if (tail) lineBufRef.current += tail
},
[trackSubmittedLine]
)
const confirmPaste = useCallback( const confirmPaste = useCallback(
(text: string) => { (text: string) => {
const data = nativePasteRef.current ?? text const data = nativePasteRef.current ?? text
nativePasteRef.current = null nativePasteRef.current = null
setPaste(null) setPaste(null)
if (!deadRef.current) { // Route through xterm's own paste: when the shell asked for bracketed
writeBroadcast(sessionId, data) // paste (PSReadLine does), the text is wrapped in \x1b[200~ / \x1b[201~
trackPastedText(data) // and inserted as ONE edit instead of executing line by line — which is
} // what a large multi-line paste needs. It also feeds onData, so the line
// buffer, command history and cwd memory see the pasted text.
if (!deadRef.current) termRef.current?.paste(data)
termRef.current?.focus() termRef.current?.focus()
}, },
[sessionId, trackPastedText] []
) )
const cancelPaste = useCallback(() => { const cancelPaste = useCallback(() => {
@@ -523,16 +519,28 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const beginPaste = useCallback( const beginPaste = useCallback(
(text: string) => { (text: string) => {
if (!deadRef.current && settings.terminal.pasteRiskConfirm && isRiskyPaste(text)) { // Read the setting at call time: this runs from a long-lived keydown
// listener, so a captured value would go stale after a settings change.
const confirmOn = useSettingsStore.getState().settings.terminal.pasteRiskConfirm
if (!deadRef.current && confirmOn && !pasteConfirmedForSession && needsPasteConfirm(text)) {
nativePasteRef.current = text nativePasteRef.current = text
setPaste(text) setPaste({ noPrompt: false, disableDetection: false })
return return
} }
confirmPaste(text) confirmPaste(text)
}, },
[settings.terminal.pasteRiskConfirm, confirmPaste] [confirmPaste]
) )
/** Confirm the risky-paste dialog: apply its choices, then paste. */
const acceptPaste = useCallback((): void => {
if (paste?.noPrompt) pasteConfirmedForSession = true
if (paste?.disableDetection) {
void useSettingsStore.getState().updateTerminal({ pasteRiskConfirm: false })
}
confirmPaste(nativePasteRef.current ?? '')
}, [paste, confirmPaste])
const copySelection = useCallback(async () => { const copySelection = useCallback(async () => {
const term = termRef.current const term = termRef.current
if (!term) return if (!term) return
@@ -871,9 +879,36 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
observerRef.current = observer observerRef.current = observer
} }
// Plain Ctrl/Cmd+V: not a chord xterm handles, so it used to reach the pty
// as a literal ^V (0x16) — nothing pasted. Handled here, on the terminal
// host in the capture phase, with preventDefault so the pty never sees it
// and Chromium's own paste cannot double up. Text follows the same
// single-line / multi-line rule as the Ctrl+Shift+V path.
const host = hostRef.current
const onPasteKey = (e: KeyboardEvent): void => {
if (e.type !== 'keydown' || e.altKey) return
const mod = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
if (!mod || e.shiftKey || e.key.toLowerCase() !== 'v') return
const target = e.target as HTMLElement | null
const isXtermHelper =
target instanceof HTMLTextAreaElement && target.classList.contains('xterm-helper-textarea')
if (
target &&
!isXtermHelper &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
) {
return // ordinary inputs keep the browser's native paste
}
e.preventDefault()
e.stopPropagation()
void pasteFromClipboard()
}
host?.addEventListener('keydown', onPasteKey, true)
return () => { return () => {
if (carryTimer !== undefined) window.clearTimeout(carryTimer) if (carryTimer !== undefined) window.clearTimeout(carryTimer)
if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current) if (ptyTimerRef.current) window.clearTimeout(ptyTimerRef.current)
host?.removeEventListener('keydown', onPasteKey, true)
for (const unsubscribe of unsubscribes) unsubscribe() for (const unsubscribe of unsubscribes) unsubscribe()
for (const disposable of disposables) disposable.dispose() for (const disposable of disposables) disposable.dispose()
observer?.disconnect() observer?.disconnect()
@@ -1103,17 +1138,33 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
<FolderOpenOutlined /> <FolderOpenOutlined />
</button> </button>
</div> </div>
{paste !== null && !dead && ( <Modal
<div className="term-pastebar"> open={paste !== null}
<span className="term-pastebar-text">检测到多行/长文本粘贴</span> title="确认粘贴"
<button type="button" className="term-pastebar-btn term-pastebar-btn-ok" onClick={() => confirmPaste(paste)}> okText="粘贴"
粘贴 cancelText="取消"
</button> width={420}
<button type="button" className="term-pastebar-btn term-pastebar-btn-cancel" onClick={cancelPaste}> maskClosable={false}
取消 onOk={acceptPaste}
</button> onCancel={cancelPaste}
>
<div className="term-paste-confirm">
<p>检测到多行或潜在危险命令,确定要粘贴到终端吗?</p>
<Checkbox
checked={paste?.noPrompt ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))}
>
本次会话不再提示
</Checkbox>
<Checkbox
checked={paste?.disableDetection ?? false}
onChange={(e) => setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))}
>
关闭后续粘贴检测
</Checkbox>
<p className="term-paste-confirm-hint">可在通用终端设置中重新开启或切换检测策略。</p>
</div> </div>
)} </Modal>
{/* Relative-positioned wrapper around the xterm surface: the suggestion {/* Relative-positioned wrapper around the xterm surface: the suggestion
popup anchors to this box, so its coordinates start at the terminal popup anchors to this box, so its coordinates start at the terminal
content rows (the search bar above never offsets the top). */} content rows (the search bar above never offsets the top). */}
+12 -43
View File
@@ -328,51 +328,20 @@
} }
/* ---- paste risk confirm bar ---- */ /* ---- paste risk confirm bar ---- */
.term-pastebar { /* ---- risky-paste confirmation dialog body ---- */
.term-paste-confirm p {
margin: 0 0 12px;
}
.term-paste-confirm .ant-checkbox-wrapper {
display: flex; display: flex;
align-items: center; margin-bottom: 8px;
gap: 8px; }
flex: none;
padding: 4px 8px; .term-paste-confirm-hint {
background: #3a2e1a; margin: 12px 0 0 !important;
border-bottom: 1px solid #5c4a26;
font-size: 12px; font-size: 12px;
color: #e8d9a8; opacity: 0.65;
}
.term-pastebar-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.term-pastebar-btn {
flex: none;
padding: 3px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
.term-pastebar-btn-ok {
background: var(--chrome-accent);
color: #fff;
}
.term-pastebar-btn-ok:hover {
filter: brightness(1.1);
}
.term-pastebar-btn-cancel {
background: transparent;
color: var(--chrome-fg);
}
.term-pastebar-btn-cancel:hover {
background: rgba(255, 255, 255, 0.1);
} }
/* ---- terminal docked area (flex:1) ---- */ /* ---- terminal docked area (flex:1) ---- */
@@ -1,173 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { App, Input } from 'antd'
import { create } from 'zustand'
import type { CommandItem } from '@shared/commands'
import { writeBroadcast } from './broadcastStore'
/** Open state shared between the tab-bar toggle button and the floating panel. */
export const useQuickInputStore = create<{
open: boolean
toggle: () => void
setOpen: (open: boolean) => void
}>((set) => ({
open: window.localStorage.getItem('ot.quickInputOpen') === '1',
toggle: () =>
set((state) => {
const next = !state.open
window.localStorage.setItem('ot.quickInputOpen', next ? '1' : '0')
return { open: next }
}),
setOpen: (open) => {
window.localStorage.setItem('ot.quickInputOpen', open ? '1' : '0')
set({ open })
}
}))
/**
* M6 quick-input panel (bottom-right floating). Three sections:
* 1. free-text command line (with auto-execute(\r) toggle)
* 2. library commands (api.listLibrary())
* 3. recent history (api.listHistory(), top 10)
*
* Sends target = the active session via `onResolveActiveSession`; when broadcast
* is enabled and the active session is a target, the send routes through the
* broadcast fan-out so it reaches every target. Lists refresh on open.
*/
export interface QuickInputPanelProps {
/** resolve the currently active terminal session id ('' when none) */
onResolveActiveSession: () => string
}
export function QuickInputPanel({ onResolveActiveSession }: QuickInputPanelProps): React.JSX.Element | null {
const { message } = App.useApp()
const open = useQuickInputStore((s) => s.open)
const close = useQuickInputStore((s) => s.setOpen)
// auto-execute(\r); persisted across sessions.
const [autoEnter, setAutoEnter] = useState<boolean>(() => window.localStorage.getItem('ot.quickInputEnter') !== '0')
const [value, setValue] = useState('')
const [history, setHistory] = useState<CommandItem[]>([])
const [library, setLibrary] = useState<CommandItem[]>([])
/** tracks the previous `open` so lists refresh on the false→true transition */
const prevOpenRef = useRef(open)
// Refresh lists every time the panel opens (and stay fresh if it was open).
const refresh = useCallback(async (): Promise<void> => {
try {
const [h, l] = await Promise.all([window.api.listHistory(), window.api.listLibrary()])
setHistory(h.slice(0, 10))
setLibrary(l)
} catch {
setHistory([])
setLibrary([])
}
}, [])
useEffect(() => {
if (open && !prevOpenRef.current) void refresh()
prevOpenRef.current = open
}, [open, refresh])
const send = useCallback(
(text: string) => {
const target = onResolveActiveSession()
if (!target) {
void message.info('没有激活的终端会话')
return
}
const data = autoEnter ? `${text}\r` : text
writeBroadcast(target, data)
// keep "最近命令" fresh immediately after a send
void window.api
.listHistory()
.then((h: CommandItem[]) => setHistory(h.slice(0, 10)))
.catch(() => undefined)
setValue('')
},
[autoEnter, onResolveActiveSession]
)
const onEnter = useCallback((): void => {
const t = value.trim()
if (t) send(t)
}, [value, send])
const onToggleAutoEnter = useCallback((checked: boolean): void => {
setAutoEnter(checked)
window.localStorage.setItem('ot.quickInputEnter', checked ? '1' : '0')
}, [])
if (!open) return null
return (
<div className="quick-input-panel">
<div className="quick-input-headbar">
<span className="quick-input-headbar-title">快捷输入</span>
<button type="button" className="quick-input-close" title="收起" aria-label="收起" onClick={() => close(false)}>
×
</button>
</div>
<div className="quick-input-row">
<Input
className="quick-input-field"
placeholder="输入命令…"
value={value}
spellCheck={false}
autoFocus
onChange={(e) => setValue(e.target.value)}
onPressEnter={onEnter}
/>
<label className="quick-input-autoenter" title="关闭后只键入不回车">
<input
type="checkbox"
checked={autoEnter}
onChange={(e) => onToggleAutoEnter(e.target.checked)}
/>
<span>自动执行(\r)</span>
</label>
</div>
<div className="quick-input-section">
<div className="quick-input-head">快捷命令</div>
{library.length === 0 ? (
<div className="quick-input-empty">暂无快捷命令</div>
) : (
<div className="quick-input-list">
{library.map((item) => (
<button
key={item.id}
type="button"
className="quick-input-item"
title={item.command}
onClick={() => send(item.command)}
>
<span className="quick-input-item-cmd">{item.command}</span>
{item.name && <span className="quick-input-item-tag">{item.name}</span>}
</button>
))}
</div>
)}
</div>
<div className="quick-input-section">
<div className="quick-input-head">最近命令</div>
{history.length === 0 ? (
<div className="quick-input-empty">暂无历史记录</div>
) : (
<div className="quick-input-list">
{history.map((item) => (
<button
key={item.id}
type="button"
className="quick-input-item"
title={item.command}
onClick={() => send(item.command)}
>
<span className="quick-input-item-cmd">{item.command}</span>
</button>
))}
</div>
)}
</div>
</div>
)
}
+30 -45
View File
@@ -10,8 +10,7 @@ import {
MenuUnfoldOutlined, MenuUnfoldOutlined,
PlusOutlined, PlusOutlined,
SettingOutlined, SettingOutlined,
ShareAltOutlined, ShareAltOutlined
ThunderboltOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { DockviewReact } from 'dockview-react' import { DockviewReact } from 'dockview-react'
import type { import type {
@@ -33,7 +32,6 @@ import { HostKeyModal } from './HostKeyModal'
import { TerminalPanel } from './TerminalPanel' import { TerminalPanel } from './TerminalPanel'
import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab' import { ApplyTemplateModal, SaveTemplateModal, TerminalTab } from './TerminalTab'
import { useBroadcastStore, writeBroadcast } from './broadcastStore' import { useBroadcastStore, writeBroadcast } from './broadcastStore'
import { QuickInputPanel, useQuickInputStore } from './QuickInputPanel'
import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore' import { forgetSessionCwd, getSessionCwd, onSessionCwdChange, setSessionCwd } from './sessionCwdStore'
import { useSettingsStore } from '@renderer/settings/store' import { useSettingsStore } from '@renderer/settings/store'
import type { SessionSnapshot, SessionPanelState } from '@shared/ipc' import type { SessionSnapshot, SessionPanelState } from '@shared/ipc'
@@ -63,14 +61,20 @@ const TERMINAL_COMPONENT = 'terminal'
const TERMINAL_TAB_COMPONENT = 'terminal-tab' const TERMINAL_TAB_COMPONENT = 'terminal-tab'
/** /**
* Per-workspace monotonic title counter for "终端 N". Lives on `window` so * "终端 N" titles fill the lowest free number among live panels: closing a
* dev hot-reloads (which reset module state) don't restart the numbering and * pane frees its number for the next new terminal, and a session restore can
* produce duplicate tab titles. * no longer leave the numbering behind (the old in-memory counter restarted
* at zero on launch and produced duplicate titles).
*/ */
const titleSeqHolder = window as unknown as { __otTitleSeq?: number } function nextTerminalTitle(existing: Iterable<string | undefined>): string {
function nextTerminalTitle(): string { const used = new Set<number>()
titleSeqHolder.__otTitleSeq = (titleSeqHolder.__otTitleSeq ?? 0) + 1 for (const title of existing) {
return `终端 ${titleSeqHolder.__otTitleSeq}` const n = title?.match(/^终端 (\d+)$/)?.[1]
if (n) used.add(Number(n))
}
let n = 1
while (used.has(n)) n++
return `终端 ${n}`
} }
function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined { function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined {
@@ -190,7 +194,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
id: panelId(), id: panelId(),
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(containerApi.panels.map((p) => p.title)),
params: { sessionId: result.id, sessionKind: 'local' }, params: { sessionId: result.id, sessionKind: 'local' },
position: { referenceGroup: group.id, direction: 'within' } position: { referenceGroup: group.id, direction: 'within' }
}) })
@@ -201,7 +205,6 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr
<div className="workspace-tabbar-actions"> <div className="workspace-tabbar-actions">
<AddTerminalGroupButton onClick={handleAdd} /> <AddTerminalGroupButton onClick={handleAdd} />
<BroadcastToggleButton /> <BroadcastToggleButton />
<QuickInputToggleButton />
</div> </div>
) )
} }
@@ -240,7 +243,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
/** Per-workspace active panel used as the split anchor. */ /** Per-workspace active panel used as the split anchor. */
const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined) const activeTerminalPanelRef = useRef<IDockviewPanel | undefined>(undefined)
const activeSshPanelRef = useRef<IDockviewPanel | undefined>(undefined) const activeSshPanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** The currently-visible mode's active panel (broadcast / quick-input target). */ /** The currently-visible mode's active panel (broadcast target). */
const activePanelRef = useRef<IDockviewPanel | undefined>(undefined) const activePanelRef = useRef<IDockviewPanel | undefined>(undefined)
/** Sessions we already asked to kill — kill once, never twice. */ /** Sessions we already asked to kill — kill once, never twice. */
@@ -621,7 +624,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
id: panelId(), id: panelId(),
component: TERMINAL_COMPONENT, component: TERMINAL_COMPONENT,
tabComponent: TERMINAL_TAB_COMPONENT, tabComponent: TERMINAL_TAB_COMPONENT,
title: nextTerminalTitle(), title: nextTerminalTitle(current.panels.map((p) => p.title)),
params: { sessionId, sessionKind: 'local' }, params: { sessionId, sessionKind: 'local' },
position: position:
reference && direction reference && direction
@@ -864,6 +867,19 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
const renames = new Map([...terminalLayout.renames, ...sshLayout.renames]) const renames = new Map([...terminalLayout.renames, ...sshLayout.renames])
await rebindSessionPanels(terminal, snapshot, true, renames) await rebindSessionPanels(terminal, snapshot, true, renames)
await rebindSessionPanels(ssh, snapshot, true, renames) await rebindSessionPanels(ssh, snapshot, true, renames)
// Retitle duplicates left over from the old counter bug (the snapshot it
// wrote keeps colliding titles forever otherwise): the first panel keeps
// the title, later ones get the lowest free number.
const seenTitles = new Set<string>()
for (const p of [...(terminal?.panels ?? []), ...(ssh?.panels ?? [])]) {
const t = p.title ?? ''
if (!t) continue
if (seenTitles.has(t)) {
const fresh = nextTerminalTitle(seenTitles)
p.setTitle(fresh)
seenTitles.add(fresh)
} else seenTitles.add(t)
}
lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current lastLocalCwdRef.current = snapshot.lastLocalCwd ?? lastLocalCwdRef.current
useWorkspaceModeStore.getState().setMode(snapshot.mode) useWorkspaceModeStore.getState().setMode(snapshot.mode)
recountAll() recountAll()
@@ -961,13 +977,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
} }
} }
/** Session id of the currently active, visible terminal panel ('' when none). */
const resolveActiveSessionId = useCallback((): string => {
const panel = activePanelRef.current
if (!panel) return ''
return sessionIdOf(panel) ?? ''
}, [])
return ( return (
<div className="workspace-root"> <div className="workspace-root">
<IconRail <IconRail
@@ -1056,11 +1065,6 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
</div> </div>
</div> </div>
{/* M6: floating quick-input panel (bottom-right). Sends to the active
session; broadcast target → fan-out. Sits above the sftp transfer
overlay's 14px bottom offset so the two never overlap. */}
<QuickInputPanel onResolveActiveSession={resolveActiveSessionId} />
{/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */} {/* SSH connect flow: secret prompt (cancellable) → connecting modal (not). */}
{requestedConn != null && ( {requestedConn != null && (
<ConnectFlow <ConnectFlow
@@ -1206,25 +1210,6 @@ function WorkspaceBroadcastPopover({
) )
} }
/** 快捷输入 panel toggle (ThunderboltOutlined) — state lives in the shared store. */
function QuickInputToggleButton(): React.JSX.Element {
const toggle = useQuickInputStore((s) => s.toggle)
const open = useQuickInputStore((s) => s.open)
return (
<Tooltip title={open ? '收起快捷输入面板' : '打开快捷输入面板'}>
<button
type="button"
className={['workspace-tabbar-btn2', open ? ' is-active' : ''].join('')}
aria-label="快捷输入面板"
aria-pressed={open}
onClick={toggle}
>
<ThunderboltOutlined />
</button>
</Tooltip>
)
}
/** /**
* XTerminal-style slim icon rail on the left edge — replaces the top toolbar. * XTerminal-style slim icon rail on the left edge — replaces the top toolbar.
* Top: workspace actions; bottom: settings. * Top: workspace actions; bottom: settings.
+1 -142
View File
@@ -446,7 +446,7 @@ body.ssh-resizing .ssh-bottom-divider {
color: var(--tab-accent-fg, #3fd06a); color: var(--tab-accent-fg, #3fd06a);
} }
/* ---- M6 tab-bar workspace actions (broadcast + quick-input) ---- */ /* ---- M6 tab-bar workspace actions (broadcast) ---- */
.workspace-tabbar-actions { .workspace-tabbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -574,147 +574,6 @@ body.ssh-resizing .ssh-bottom-divider {
user-select: none; user-select: none;
} }
/* ---- M6 quick-input floating panel (bottom-right) ----
bottom: 44px so it clears the sftp transfer overlay (right/bottom 14px). */
.quick-input-panel {
position: fixed;
right: 14px;
bottom: 44px;
z-index: 950;
width: 320px;
max-height: 60vh;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: #1f1f1f;
border: 1px solid var(--chrome-border);
border-radius: 8px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
color: var(--chrome-fg);
}
.quick-input-headbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.quick-input-headbar-title {
font-size: 12px;
font-weight: 600;
color: var(--chrome-fg);
}
.quick-input-close {
border: none;
background: none;
color: #8a8a8a;
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.quick-input-close:hover {
color: #ffffff;
}
.quick-input-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.quick-input-field .ant-input {
background: var(--chrome-bg);
border-color: var(--chrome-border);
color: var(--chrome-fg);
}
.quick-input-autoenter {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #9a9a9a;
cursor: pointer;
user-select: none;
}
.quick-input-autoenter input {
accent-color: var(--chrome-accent);
}
.quick-input-section {
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.quick-input-head {
font-size: 11px;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.quick-input-list {
display: flex;
flex-direction: column;
gap: 1px;
max-height: 140px;
overflow-y: auto;
}
.quick-input-item {
display: flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
color: var(--chrome-fg);
font: inherit;
font-size: 12px;
text-align: left;
padding: 3px 6px;
border-radius: 4px;
cursor: pointer;
min-width: 0;
}
.quick-input-item:hover {
background: rgba(59, 142, 234, 0.18);
color: #ffffff;
}
.quick-input-item-cmd {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Cascadia Mono', 'Consolas', 'SFMono-Regular', monospace;
}
.quick-input-item-tag {
flex: 0 0 auto;
font-size: 10px;
line-height: 1;
padding: 2px 5px;
border-radius: 4px;
color: #9a9a9a;
background: rgba(255, 255, 255, 0.08);
}
.quick-input-empty {
padding: 8px 4px;
color: #8a8a8a;
font-size: 12px;
text-align: center;
}
/* ---- custom tab (title + close + context menu) ---- */ /* ---- custom tab (title + close + context menu) ---- */
.workspace-terminal-tab { .workspace-terminal-tab {
position: relative; position: relative;