{
按 Ctrl + 1 截图并导出后会显示在这里
-
-
+
+
+
+
+
+
+
+ {{ item.value }}
+
+
+
+
+
+
+ 第 {{ currentPage }} / {{ totalPages }} 页 · 共 {{ store.recent.length }} 张
+
+
+
+
+
-
-
+
+
![]()
{
+
-
+
diff --git a/src/modules/screenshot/ScreenshotOverlay.vue b/src/modules/screenshot/ScreenshotOverlay.vue
index 7cd9889..2ab92f7 100644
--- a/src/modules/screenshot/ScreenshotOverlay.vue
+++ b/src/modules/screenshot/ScreenshotOverlay.vue
@@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants'
+import { applyStoredAccent } from '@/lib/theme'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
import { commands } from '@/lib/bindings'
import {
@@ -309,6 +310,7 @@ const hint = computed(() => {
case 'drawing': return '松开鼠标完成选区'
case 'selected': return '拖动移动选区 · 拖动手柄调整大小 · 选择工具开始标注 · Enter 完成 · Esc 取消'
case 'editing': return '在选区内绘制标注 · 文字工具点击已有文字可编辑 · Ctrl+Enter 完成 · Esc 退出标注'
+ default: return ''
}
})
@@ -600,8 +602,8 @@ function normRect(a: Point, b: Point): Sel {
function clampSel() {
const s = sel.value
const min = 8
- let w = Math.min(Math.max(min, s.w), winW.value)
- let h = Math.min(Math.max(min, s.h), winH.value)
+ const w = Math.min(Math.max(min, s.w), winW.value)
+ const h = Math.min(Math.max(min, s.h), winH.value)
const x = Math.min(Math.max(0, s.x), winW.value - w)
const y = Math.min(Math.max(0, s.y), winH.value - h)
sel.value = { x, y, w, h }
@@ -1858,6 +1860,8 @@ function applyTheme() {
else if (theme === 'light') isDark = false
else isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.classList.toggle('dark', isDark)
+ // 主题色:与主窗口共用同一份设置(行内变量压住 .dark 规则,按当前模式整组重写)
+ applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
// 跟随系统时监听变化
return window.matchMedia('(prefers-color-scheme: dark)')
}
@@ -2465,7 +2469,7 @@ onUnmounted(() => {
padding: 8px 14px;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(239, 68, 68, 0.6);
- color: #fecaca;
+ color: var(--danger);
font-size: 13px;
line-height: 1.4;
border-radius: 6px;
@@ -2487,7 +2491,7 @@ onUnmounted(() => {
/* 窗口识别:框内全透明透出原画面,box-shadow 暗化框外(形成"框内透明、框外变暗") */
.win-highlight {
position: absolute;
- border: 2px solid #3b82f6;
+ border: 2px solid var(--primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 5;
@@ -2498,7 +2502,7 @@ onUnmounted(() => {
left: -2px;
max-width: 240px;
padding: 1px 6px;
- background: #3b82f6;
+ background: var(--primary);
color: #fff;
font-size: 12px;
border-radius: 3px;
@@ -2510,7 +2514,7 @@ onUnmounted(() => {
/* 拖拽中的选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
.selection {
position: absolute;
- border: 2px solid #3b82f6;
+ border: 2px solid var(--primary);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
z-index: 5;
@@ -2520,7 +2524,7 @@ onUnmounted(() => {
top: -24px;
left: 0;
padding: 2px 6px;
- background: #3b82f6;
+ background: var(--primary);
color: #fff;
font-size: 12px;
border-radius: 3px;
@@ -2548,7 +2552,7 @@ onUnmounted(() => {
.region-border {
position: absolute;
inset: 0;
- border: 2px solid #3b82f6;
+ border: 2px solid var(--primary);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
pointer-events: none;
}
@@ -2559,7 +2563,7 @@ onUnmounted(() => {
width: 9px;
height: 9px;
background: #fff;
- border: 2px solid #3b82f6;
+ border: 2px solid var(--primary);
border-radius: 2px;
transform: translate(-50%, -50%);
z-index: 8;
@@ -2742,14 +2746,14 @@ onUnmounted(() => {
color: #fff;
white-space: nowrap;
}
-.sm-mode.auto { background: #2563eb; }
-.sm-mode.manual { background: #059669; }
+.sm-mode.auto { background: var(--primary); }
+.sm-mode.manual { background: var(--success); }
/* 拼接进行中的呼吸点 */
.sm-dot {
width: 7px;
height: 7px;
border-radius: 999px;
- background: #22c55e;
+ background: var(--success);
animation: sm-pulse 1.1s ease-in-out infinite;
flex-shrink: 0;
}
diff --git a/src/modules/screenshot/ScreenshotPin.vue b/src/modules/screenshot/ScreenshotPin.vue
index b49851c..64b32a2 100644
--- a/src/modules/screenshot/ScreenshotPin.vue
+++ b/src/modules/screenshot/ScreenshotPin.vue
@@ -9,6 +9,7 @@ import { LogicalPosition, LogicalSize } from '@tauri-apps/api/dpi'
import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'
import { ChevronLeft, ChevronRight, X, Loader2 } from '@lucide/vue'
import { EVENTS, STORAGE_KEYS } from '@/lib/constants'
+import { applyStoredAccent } from '@/lib/theme'
// Rust 端通过 tauri-specta 生成的命令绑定(bindings.ts)
import { commands } from '@/lib/bindings'
@@ -220,6 +221,8 @@ function applyTheme() {
isDark = theme === 'dark'
}
root.classList.toggle('dark', isDark)
+ // 主题色:与主窗口共用同一份设置(行内变量压住 .dark 规则,按当前模式整组重写)
+ applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
}
/** 从 localStorage 读取主窗口持久化的截图历史(图片文件在应用数据目录) */
diff --git a/src/modules/screenshot/ScrollControl.vue b/src/modules/screenshot/ScrollControl.vue
index 11da7cd..7293de8 100644
--- a/src/modules/screenshot/ScrollControl.vue
+++ b/src/modules/screenshot/ScrollControl.vue
@@ -20,7 +20,7 @@ const height = ref(0)
const status = ref('')
/** 是否已收到最终事件(完成/取消),此后关闭窗口不再触发取消 */
let ended = false
-let unlisten: UnlistenFn[] = []
+const unlisten: UnlistenFn[] = []
onMounted(async () => {
status.value = auto.value ? '自动向下拼接中…' : '请滚动目标窗口,完成后点「完成」'
diff --git a/src/modules/screenshot/index.ts b/src/modules/screenshot/index.ts
index 11461db..70a69e2 100644
--- a/src/modules/screenshot/index.ts
+++ b/src/modules/screenshot/index.ts
@@ -55,5 +55,5 @@ export const moduleConfig: ModuleConfig = {
defaultEnabled: true,
loader: () => import('./ScreenshotModule.vue'),
searchItems,
- order: 30
+ order: 70
}
diff --git a/src/modules/settings/GeneralSettings.vue b/src/modules/settings/GeneralSettings.vue
index 45c3b5e..b63edf6 100644
--- a/src/modules/settings/GeneralSettings.vue
+++ b/src/modules/settings/GeneralSettings.vue
@@ -1,9 +1,10 @@
-
-
+
+
@@ -561,7 +631,7 @@ const onDragEnd = () => {
@@ -580,6 +650,88 @@ const onDragEnd = () => {
+
+
+
+
+ 主题色
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 深浅模式会自动调整同一个颜色的亮度:浅色下压暗、深色下提亮,
+ 保证按钮文字与选中态在任何色相下都清晰可读。状态色(成功 / 警告 / 危险)不随主题色变化。
+
+
+
+
@@ -673,7 +825,7 @@ const onDragEnd = () => {
{{ getProcessStatusText(module.id) }}
@@ -753,7 +905,7 @@ const onDragEnd = () => {
-
+
已是最新版本
diff --git a/src/modules/settings/index.ts b/src/modules/settings/index.ts
index 8c2e11c..57ac227 100644
--- a/src/modules/settings/index.ts
+++ b/src/modules/settings/index.ts
@@ -62,6 +62,12 @@ const searchItems: SearchIndexItem[] = [
title: '退出程序',
description: '彻底退出 Thing 应用',
keywords: ['退出', '关闭', 'quit', 'exit', '结束']
+ },
+ // 追加在末尾:searchItems 的下标与 GeneralSettings 里 registerAction 的 index 一一对应
+ {
+ title: '主题色',
+ description: '自定义应用强调色(预设色板 / 自定义颜色)',
+ keywords: ['主题色', '强调色', '配色', '颜色', 'accent', 'color', 'theme']
}
]
diff --git a/src/modules/terminal/NewTerminalWindow.vue b/src/modules/terminal/NewTerminalWindow.vue
new file mode 100644
index 0000000..bed6ed1
--- /dev/null
+++ b/src/modules/terminal/NewTerminalWindow.vue
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
+
+
+ 新建终端窗口
+
+ 选择一个本地 Shell 或已保存的 SSH 主机,会话将在新的独立窗口中打开
+
+
+
+
+
+
+
+ 最小化
+
+
+
+
+
+ 最大化 / 还原
+
+
+
+
+
+ 关闭(Esc)
+
+
+
+
+
+
+
+ 正在读取会话列表…
+
+
+
+ 本地 Shell
+
+
+
+ 没有可用的 Shell,请到「终端 → 设置 → Shell」重新探测。
+
+
+
+
+ SSH 主机
+
+
+
+
+ 还没有保存的 SSH 主机,到「终端 → 主机」新建一台。
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/terminal/TerminalModule.vue b/src/modules/terminal/TerminalModule.vue
index cbef2b2..f7e4b92 100644
--- a/src/modules/terminal/TerminalModule.vue
+++ b/src/modules/terminal/TerminalModule.vue
@@ -1,141 +1,111 @@
-
+
-
-
-
- 终端
-
-
- 主机
-
-
- 密钥
-
-
- 已知主机
-
-
- 历史
-
-
- 片段
-
-
- 设置
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
还没有打开的会话
-
- 从左侧选择一个主机,或新建本地 Shell 会话
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/modules/terminal/TerminalQuickLaunch.vue b/src/modules/terminal/TerminalQuickLaunch.vue
new file mode 100644
index 0000000..b66af74
--- /dev/null
+++ b/src/modules/terminal/TerminalQuickLaunch.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/terminal/TerminalWindow.vue b/src/modules/terminal/TerminalWindow.vue
index 6add464..f05446f 100644
--- a/src/modules/terminal/TerminalWindow.vue
+++ b/src/modules/terminal/TerminalWindow.vue
@@ -23,17 +23,65 @@
*
* 输出事件是**窗口广播**(Rust 侧 `app.emit` 发到所有窗口),主窗口的监听器
* 管不到本窗口。这正是「独立窗口能收到输出」的实现基础。
+ *
+ * # 界面:与终端页同构的「终端卡片」(2026-09-21)
+ *
+ * 此前本窗口是一条 h-8 的标题栏 + 画布 + 状态栏,与主窗口里的终端页看起来
+ * 是两套东西。现在改成与终端页**同一套结构与组件**:
+ *
+ * ```
+ * ┌───────────────────────────────────────────────────────────┐
+ * │ [图标 会话名 …可拖拽…] │ 搜索 字体 清屏 ⋯ │ ─ □ ✕ │ ← 控制栏 h-9
+ * ├───────────────────────────────────────────────────────────┤
+ * │ 画布(xterm) │
+ * ├───────────────────────────────────────────────────────────┤
+ * │ 状态栏(与主窗口同一个 TerminalStatusBar) │
+ * └───────────────────────────────────────────────────────────┘
+ * ```
+ *
+ * 控制栏左端承担原标题栏的两件事:显示「这是哪个会话」与**窗口拖拽区**
+ * (无边框窗口必须有拖拽区,否则窗口挪不动)。右侧是复用的 `TerminalToolbar`
+ * (`standalone` 模式:隐藏只对主窗口页内面板有意义的动作)+ 最小化/最大化/关闭。
+ *
+ * 窗口绑定单个会话,因此**不渲染标签栏** —— 这保持了「一个会话 ↔ 一个窗口」的
+ * 语义:主窗口那侧的「已在独立窗口中打开」提示条、标签上的「独立」徽标都以此为准。
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getCurrentWindow } from '@tauri-apps/api/window'
-import { AlertTriangle, Loader2, Menu, Minus, Square, X } from '@lucide/vue'
+import {
+ AlertTriangle,
+ Globe,
+ Loader2,
+ Minus,
+ Square,
+ TerminalSquare,
+ X
+} from '@lucide/vue'
+import { toast } from 'vue-sonner'
import { useTerminalStore } from '@/stores/terminalStore'
import { useTerminalStream } from '@/composables/useSessionStream'
import { useTerminalKeys } from '@/composables/useTerminalKeys'
+import { toggleHistoryPopover, toggleSnippetsPopover } from './useRecordPopovers'
import { createLogger } from '@/lib/logger'
+import { applyStoredAccent } from '@/lib/theme'
+import { appIsDark } from '@/lib/terminalThemes'
+import { STORAGE_KEYS } from '@/lib/constants'
import TerminalPane from './components/TerminalPane.vue'
+import TerminalToolbar from './components/TerminalToolbar.vue'
import TerminalStatusBar from './components/TerminalStatusBar.vue'
+import TerminalContextMenu from './components/TerminalContextMenu.vue'
import HostKeyPromptDialog from './components/HostKeyPromptDialog.vue'
+import AiPanel from './components/AiPanel.vue'
+import { Button } from '@/components/ui/button'
+import { Toaster } from '@/components/ui/sonner'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
const logger = createLogger('terminal')
@@ -55,6 +103,12 @@ const { effectiveAppearance, bumpFont, resetFont, bindings, init } = stream
const session = computed(() => store.sessionById(sessionId))
+// ===== 控制栏左端:会话身份(同时是窗口拖拽区) =====
+
+const isSsh = computed(() => session.value?.kind === 'ssh')
+const sessionLabel = computed(() => store.sessionLabel(session.value) || '终端')
+const sessionTarget = computed(() => (isSsh.value ? store.sessionTarget(session.value) : ''))
+
// ===== 面板引用 =====
const paneRef = ref
| null>(null)
@@ -64,7 +118,105 @@ const paneRef = ref | null>(null)
const ready = ref(false)
const fatal = ref('')
+// ===== 主题:跟随主窗口 =====
+//
+// 独立窗口有自己的 JS 运行时,**不加载 appStore**(见文件头),所以主题不能靠
+// 主窗口的 store 传过来。项目里其它独立窗口(快速面板 / 剪贴板 / 下载窗口…)
+// 的统一做法是:各自读同一份 `localStorage` 设置 → 切 `dark` class + 应用主题色
+// → 监听 `storage` 事件跟随主窗口的改动。这里沿用同一套,只是**不应用窗口效果**
+// (mica / acrylic 需要透明通道,本窗口是不透明窗口,见 terminal/window.rs)。
+//
+// 少了这段时,主窗口是深色而独立窗口仍是浅色 —— 以前窗口里几乎全是 xterm
+// (自带配色)所以不明显,控制栏与状态栏搬进来之后就露馅了。
+//
+// 另外:本窗口由 Rust **隐藏创建**(见 terminal/window.rs),应用完主题才显示 ——
+// 否则深色模式下会先闪一个纯白窗口。因此这里除了切 class,还要把**窗口底色**
+// 设成主题背景色(`--background`,见 style.css),避免显示瞬间仍是白底。
+
+/** 与 style.css 的 `--background` 对齐:浅色 #ffffff / 深色 #16161a */
+const WINDOW_BG = { light: '#ffffff', dark: '#16161a' }
+
+/** 主窗口的主题设置(独立窗口只共享 settings,不共享 store) */
+function readMainTheme(): string {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEYS.appSettings)
+ if (raw) {
+ const s = JSON.parse(raw) as { theme?: string }
+ return s.theme ?? 'system'
+ }
+ } catch {
+ /* 设置不可读时按跟随系统处理 */
+ }
+ return 'system'
+}
+
+/** system 模式下用 matchMedia 判断深浅(独立窗口拿不到主窗口的响应式状态) */
+function resolveIsDark(theme: string): boolean {
+ if (theme === 'dark') return true
+ if (theme === 'light') return false
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+}
+
+async function applyTheme() {
+ const root = document.documentElement
+ const theme = readMainTheme()
+ const isDark = resolveIsDark(theme)
+
+ // 1. 窗口原生主题 + 底色:无边框窗口用它同步 WebView 的 prefers-color-scheme,
+ // 底色则保证「显示的那一刻」就已经是主题色(而不是 WebView 默认的白)
+ try {
+ await win.setTheme(theme === 'system' ? null : (theme as 'dark' | 'light'))
+ } catch {
+ /* 平台不支持时忽略 */
+ }
+ try {
+ // 底色单独 try:setTheme 失败不该连带跳过底色,否则仍会白底闪一下
+ await win.setBackgroundColor(isDark ? WINDOW_BG.dark : WINDOW_BG.light)
+ } catch {
+ /* 忽略 */
+ }
+
+ // 2. DOM class(组件里的 bg-background / text-foreground 全靠它切色)
+ root.classList.toggle('dark', isDark)
+ // 终端画布的「跟随应用主题」要跟着本窗口的深浅走(见 terminalThemes.ts)
+ appIsDark.value = isDark
+
+ // 3. 主题色:与主窗口共用同一份设置(localStorage 的 accentId / accentHex)
+ applyStoredAccent(root, isDark ? 'dark' : 'light', STORAGE_KEYS.appSettings)
+}
+
+/**
+ * 显示本窗口。
+ *
+ * Rust 侧是隐藏创建的(见文件头说明),所以第一次显示由这里触发;
+ * 放在 `applyTheme` 之后,用户看到的第一帧就是正确配色。
+ */
+async function revealWindow() {
+ try {
+ await win.show()
+ await win.setFocus()
+ } catch (e) {
+ logger.warn(`显示独立窗口失败:${String(e)}`)
+ }
+}
+
+const onThemeChange = () => void applyTheme()
+/** 主窗口改主题时,同源的其它窗口会收到 storage 事件 —— 这就是「跟随」的实现 */
+const onStorageChange = (e: StorageEvent) => {
+ if (e.key === STORAGE_KEYS.appSettings) void applyTheme()
+}
+
+const darkMedia = window.matchMedia('(prefers-color-scheme: dark)')
+
onMounted(async () => {
+ darkMedia.addEventListener('change', onThemeChange)
+ window.addEventListener('storage', onStorageChange)
+
+ // 主题 → 显示:窗口是隐藏创建的,这一步之后用户才第一次看到它。
+ // 即使后面初始化失败(会话已结束等),窗口也已经可见 —— 错误提示才看得见。
+ await applyTheme()
+ await revealWindow()
+
if (!sessionId) {
fatal.value = '缺少会话 ID,无法确定要显示哪个会话'
ready.value = true
@@ -84,7 +236,7 @@ onMounted(async () => {
// 把标题设成会话名,方便任务栏与窗口列表识别
const info = store.sessionById(sessionId)
if (info?.title) {
- void win.setTitle(`${info.title} · 终端`)
+ void win.setTitle(`${info.title} · 终端`).catch(() => {})
}
} catch (e) {
logger.error(`独立窗口初始化失败:${String(e)}`)
@@ -105,12 +257,104 @@ onMounted(async () => {
* `invoke` 仍可发出(IPC 通道在窗口销毁前还有效),这是最后一个可靠时机。
*/
onBeforeUnmount(() => {
+ darkMedia.removeEventListener('change', onThemeChange)
+ window.removeEventListener('storage', onStorageChange)
if (!sessionId) return
void store.attachSession(sessionId).catch(e => {
logger.warn(`重置 detached 标记失败:${String(e)}`)
})
})
+// ===== 控制栏动作(终端页同款,但只保留单会话窗口成立的那些) =====
+//
+// 与 TerminalWorkspace 的同一组动作是**有意的少量重复**:那边还要管分屏、
+// SFTP、模板等页面级状态,抽成共享 composable 会把两边的状态搅在一起。
+// 这里只保留读 store 就能完成的部分。
+
+/** 搜索 */
+const searching = ref(false)
+const searchHint = ref('')
+
+function toggleSearch() {
+ searching.value = !searching.value
+ if (!searching.value) {
+ paneRef.value?.clearSearch?.()
+ searchHint.value = ''
+ }
+}
+
+function doSearch(keyword: string, back = false) {
+ const found = back ? paneRef.value?.findPrevious?.(keyword) : paneRef.value?.findNext?.(keyword)
+ searchHint.value = found === false ? '无匹配' : ''
+}
+
+function handleClear() {
+ const shellKind = store.shells.find(s => s.id === session.value?.targetId)?.kind
+ // 清屏是「让 shell 执行清屏」而不是「从 xterm 缓冲里删掉」——
+ // 后者只是视觉上清了,向上滚动仍能看到旧内容,且 shell 的行计数会错乱
+ void store.clearSession(sessionId, shellKind)
+ paneRef.value?.clear?.()
+}
+
+/** 重命名会话(控制栏没有标签,改的是窗口标题与会话标签共用的那个名称) */
+const renameOpen = ref(false)
+const renameValue = ref('')
+
+function openRename() {
+ renameValue.value = session.value?.title ?? ''
+ renameOpen.value = true
+}
+
+async function submitRename() {
+ const name = renameValue.value.trim()
+ if (!name) return
+ try {
+ await store.renameSession(sessionId, name)
+ // 窗口标题也保持一致:任务栏/窗口列表里认的是系统标题,不是会话标签
+ void win.setTitle(`${name} · 终端`).catch(() => {})
+ toast.success('已重命名')
+ renameOpen.value = false
+ } catch (e) {
+ toast.error(`重命名失败:${String(e)}`)
+ }
+}
+
+/** AI 命令助手:把当前选区作为上下文(与终端页同一口径) */
+const aiOpen = ref(false)
+function getTerminalContext(): string {
+ return paneRef.value?.getSelection?.() ?? ''
+}
+
+/** 会话日志 */
+async function toggleLogging() {
+ const sess = session.value
+ if (!sess) return
+ try {
+ await store.toggleLogging(sessionId, !sess.logging)
+ if (!sess.logging) {
+ const p = await store.logPath(sessionId)
+ toast.success(p ? `已开始记录会话日志:${p}` : '已开始记录会话日志')
+ } else {
+ toast.success('已停止记录会话日志(文件已保留)')
+ }
+ } catch (e) {
+ toast.error(`切换日志记录失败:${String(e)}`)
+ }
+}
+
+async function openLogDir() {
+ try {
+ const p = await store.logPath(sessionId)
+ if (!p) {
+ toast.info('当前会话未开启日志')
+ return
+ }
+ await store.revealLocalPath(p)
+ } catch (e) {
+ toast.error(`打开日志位置失败:${String(e)}`)
+ }
+}
+
// ===== 快捷键(独立窗口内只保留与本会话相关的动作) =====
useTerminalKeys({
@@ -118,8 +362,8 @@ useTerminalKeys({
onAction: actionId => {
switch (actionId) {
case 'copy': {
- const text = paneRef.value?.getSelection?.()
- if (text) void navigator.clipboard.writeText(text.replace(/\n+$/, ''))
+ // 与右键菜单走同一个函数:都遵守「复制时去掉末尾换行」设置
+ void doCopy()
break
}
case 'paste':
@@ -140,6 +384,17 @@ useTerminalKeys({
case 'fontReset':
resetFont()
break
+ case 'search':
+ toggleSearch()
+ break
+ // 命令历史 / 片段:控制栏上的两个浮层,独立窗口里同样适用
+ // (它们只需要当前会话,不依赖终端页的分屏与面板容器)
+ case 'history':
+ toggleHistoryPopover()
+ break
+ case 'snippets':
+ toggleSnippetsPopover()
+ break
// 标签类/分屏类动作在独立窗口里没有意义,静默忽略
default:
break
@@ -147,15 +402,89 @@ useTerminalKeys({
}
})
-// ===== 无边框窗口的自定义标题栏 =====
+// ===== 右键菜单 =====
+//
+// 菜单本身与终端页共用同一个组件(`TerminalContextMenu`)。此前这里没有右键处理,
+// 而 main.ts 全局 `preventDefault` 掉了浏览器原生菜单 —— 独立窗口里右键毫无反应。
+
+const contextMenu = ref<{ x: number; y: number } | null>(null)
+
+/** 右键菜单的「复制」是否可用(取决于当时是否有选区) */
+const contextHasSelection = computed(() => !!paneRef.value?.getSelection?.())
+
+function closeContextMenu() {
+ contextMenu.value = null
+}
/**
- * 无边框窗口需要自己实现拖拽与最小化/最大化/关闭。
- *
- * 但本窗口**不实现拖拽区域**:终端面板占满整个窗口,若在顶部加拖拽条,
- * 用户会失去一块垂直空间;而拖拽整个窗口的需求可以通过系统的方式完成
- * (Alt+Space、或拖窗口边缘)。这里只保留最小化/最大化/关闭三个按钮。
+ * 面板右键的统一入口,与终端页同一套三种行为(`selection.rightClick`):
+ * - `menu`:弹菜单
+ * - `paste`:直接粘贴
+ * - `select-word`:先选中右键处的词,再弹菜单(只选中不弹菜单会让用户无法继续操作)
*/
+function onPaneContextMenu(payload: { x: number; y: number }) {
+ const mode = store.settings?.selection?.rightClick ?? 'menu'
+
+ if (mode === 'paste') {
+ void doPaste()
+ return
+ }
+ if (mode === 'select-word') {
+ paneRef.value?.selectWordAt?.(payload.x, payload.y)
+ }
+ contextMenu.value = payload
+}
+
+async function doCopy() {
+ const text = paneRef.value?.getSelection?.()
+ closeContextMenu()
+ if (!text) {
+ toast.info('没有选中内容')
+ return
+ }
+ const trim = store.settings?.selection?.trimTrailingNewline ?? true
+ const payload = trim && text.endsWith('\n') ? text.replace(/\n+$/, '') : text
+ try {
+ await navigator.clipboard.writeText(payload)
+ } catch (e) {
+ toast.error(`复制失败:${String(e)}`)
+ }
+}
+
+async function doPaste() {
+ closeContextMenu()
+ try {
+ const text = await navigator.clipboard.readText()
+ if (!text) return
+ await store.write(sessionId, text)
+ } catch (e) {
+ toast.error(`粘贴失败:${String(e)}`)
+ }
+}
+
+function doSelectAll() {
+ paneRef.value?.selectAll?.()
+ closeContextMenu()
+}
+
+function doClearFromMenu() {
+ closeContextMenu()
+ handleClear()
+}
+
+function doRenameFromMenu() {
+ closeContextMenu()
+ openRename()
+}
+
+async function doCloseFromMenu() {
+ closeContextMenu()
+ await store.closeSession(sessionId)
+}
+
+// ===== 无边框窗口的窗口按钮 =====
+//
+// 拖拽区在控制栏左端(见模板),这里只留最小化/最大化/关闭三个按钮。
async function minimize() {
await win.minimize()
}
@@ -168,42 +497,90 @@ async function closeWindow() {
-
-
-
-
-
- {{ session?.title || '终端' }}
-
+
+
+
+
+
+
+
+
+ {{ sessionLabel }}
+
+ {{ sessionTarget }}
+
+
-
-
-
+
+
+
+
+
+
+
+
+ 最小化
+
+
+
+
+
+ 最大化 / 还原
+
+
+
+
+
+ 关闭窗口(会话保留,回到主窗口)
+
-
+
@@ -225,12 +602,29 @@ async function closeWindow() {
:session-id="sessionId"
:appearance="effectiveAppearance"
:visible="true"
+ @contextmenu="onPaneContextMenu"
/>
-
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/terminal/components/AiPanel.vue b/src/modules/terminal/components/AiPanel.vue
index 98fdfe8..dff1a86 100644
--- a/src/modules/terminal/components/AiPanel.vue
+++ b/src/modules/terminal/components/AiPanel.vue
@@ -10,7 +10,7 @@
* # 交互与片段库同一套安全语义
*
* 建议默认「填入命令行」(用户自己按回车),「直接执行」是显式第二动作。
- * 理由与 SnippetPanel 一致:命令被自动执行与等待用户确认,在心理上完全不同;
+ * 理由与命令片段(SnippetPopover)一致:命令被自动执行与等待用户确认,在心理上完全不同;
* 而且模型的建议未经本地验证,用户应当有机会先看一眼再回车。
*
* # 上下文
@@ -26,6 +26,7 @@ import { useTerminalStore } from '@/stores/terminalStore'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { ScrollArea } from '@/components/ui/scroll-area'
import {
Dialog,
DialogContent,
@@ -135,7 +136,7 @@ function engineLabel(e: AiEngineOption): string {