feat(terminal): 实时终端改造为侧边栏面板,支持拖拽分割与自动开关

- 将实时终端从独立页面改造为 Vue3 侧边栏面板,与 Git 面板上下排列
- 使用 xterm.js + Grid 布局,支持三主题配色适配
- 上下拖拽分割条,动态调整终端/Git 面板比例
- 输入栏上方新增终端数量按钮,点击快捷打开
- 个人空间新增「自动打开终端面板」开关(外观与显示分类)
- REST API /api/terminals 获取终端列表,多时机自动刷新
- 有终端自动打开面板,无终端自动关闭
This commit is contained in:
JOJO 2026-06-12 00:18:27 +08:00
parent 788753f6fd
commit 2e54f2c343
17 changed files with 1022 additions and 33 deletions

24
package-lock.json generated
View File

@ -14,6 +14,8 @@
"@tiptap/suggestion": "^3.23.6",
"@tiptap/vue-3": "^3.23.6",
"@types/html2canvas": "^0.5.35",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"diff": "^8.0.3",
"dompurify": "^3.2.7",
"enquirer": "^2.4.1",
@ -33,7 +35,8 @@
"socket.io-client": "^4.7.5",
"unified": "^11.0.5",
"vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0"
"vue-stick-to-bottom": "^1.0.0",
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/node": "^20.10.5",
@ -2609,6 +2612,18 @@
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==",
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-web-links": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -7367,6 +7382,13 @@
"node": ">=0.4.0"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@ -21,6 +21,8 @@
"@tiptap/suggestion": "^3.23.6",
"@tiptap/vue-3": "^3.23.6",
"@types/html2canvas": "^0.5.35",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"diff": "^8.0.3",
"dompurify": "^3.2.7",
"enquirer": "^2.4.1",
@ -40,7 +42,8 @@
"socket.io-client": "^4.7.5",
"unified": "^11.0.5",
"vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0"
"vue-stick-to-bottom": "^1.0.0",
"xterm": "^5.3.0"
},
"devDependencies": {
"@types/node": "^20.10.5",

View File

@ -292,6 +292,7 @@
:block-upload="policyUiBlocks.block_upload"
:block-tool-toggle="policyUiBlocks.block_tool_toggle"
:block-realtime-terminal="policyUiBlocks.block_realtime_terminal"
:terminal-count="terminalSessions ? Object.keys(terminalSessions).length : 0"
:block-focus-panel="policyUiBlocks.block_focus_panel"
:block-token-panel="policyUiBlocks.block_token_panel"
:block-compress-conversation="policyUiBlocks.block_compress_conversation"
@ -354,13 +355,30 @@
</div>
</main>
<div
v-if="!isMobileViewport && gitChangesPanelOpen"
class="resize-handle resize-handle--git-changes"
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
class="resize-handle resize-handle--right-panels"
@mousedown="startResize('right', $event)"
></div>
<transition name="git-changes-panel-slide">
<transition name="right-panels-slide">
<div
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
class="right-panels"
:style="rightPanelsStyle"
>
<div class="right-panels__slot">
<TerminalPanel
:width="Math.min(rightWidth || 420, 600)"
:workspace-id="currentHostWorkspaceId"
@close="closeTerminalPanel"
/>
</div>
<div
v-if="terminalPanelOpen && gitChangesPanelOpen"
class="right-panels__split-handle"
@mousedown="startRightSplitResize"
></div>
<div class="right-panels__slot">
<GitChangesPanel
v-if="!isMobileViewport && gitChangesPanelOpen"
:width="Math.min(rightWidth || 420, 600)"
:loading="gitChangesLoading"
:error="gitChangesError"
@ -371,6 +389,8 @@
@expand-context="expandGitChangesContext"
@reset-context="resetGitChangesContext"
/>
</div>
</div>
</transition>
</div>
<transition name="desktop-approval-fade">

View File

@ -4,6 +4,7 @@ import ConversationSidebar from '../components/sidebar/ConversationSidebar.vue';
import LeftPanel from '../components/panels/LeftPanel.vue';
import ToolApprovalPanel from '../components/panels/ToolApprovalPanel.vue';
import GitChangesPanel from '../components/panels/GitChangesPanel.vue';
import TerminalPanel from '../components/panels/TerminalPanel.vue';
import TokenDrawer from '../components/token/TokenDrawer.vue';
import QuickMenu from '../components/input/QuickMenu.vue';
import InputComposer from '../components/input/InputComposer.vue';
@ -50,6 +51,7 @@ export const appComponents = {
LeftPanel,
ToolApprovalPanel,
GitChangesPanel,
TerminalPanel,
TokenDrawer,
PersonalizationDrawer,
QuickMenu,

View File

@ -218,5 +218,21 @@ export const computed = {
},
showScrollToBottomButton() {
return this.chatDisplayMode === 'chat' && !this.stickIsNearBottom;
},
rightPanelsStyle() {
const w = Math.min(this.rightWidth || 420, 600);
const t = this.terminalPanelOpen;
const g = this.gitChangesPanelOpen;
const ratio = this.rightSplitRatio ?? 0.5;
const r = Math.max(0.15, Math.min(0.85, Number(ratio) || 0.5));
let rows: string;
if (t && g) rows = `${r}fr 2px ${1 - r}fr`;
else if (t) rows = '1fr 0px 0fr';
else if (g) rows = '0fr 0px 1fr';
else rows = '0fr 0px 0fr';
return {
width: w + 'px',
gridTemplateRows: rows
};
}
};

View File

@ -44,6 +44,8 @@ export async function mounted() {
this.refreshProjectGitSummary?.();
this.startProjectGitSummaryIdleRefresh?.();
this.startConnectionHeartbeat();
this.fetchTerminalCount();
this.startTerminalCountIdleRefresh();
this.checkTutorialPrompt();
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
initialDataPromise
@ -120,6 +122,7 @@ export function beforeUnmount() {
this.subAgentStopPolling();
this.backgroundCommandStopPolling();
this.stopProjectGitSummaryIdleRefresh?.();
this.stopTerminalCountIdleRefresh?.();
this.resourceStopContainerStatsPolling();
this.resourceStopProjectStoragePolling();
this.resourceStopUsageQuotaPolling();

View File

@ -339,6 +339,7 @@ export const conversationMethods = {
this.skipConversationHistoryReload = true;
this.currentConversationId = conversationId;
this.refreshProjectGitSummary?.();
this.fetchTerminalCount();
this.currentConversationTitle = result.title;
this.titleReady = true;
this.suppressTitleTyping = false;

View File

@ -1308,6 +1308,7 @@ export const taskPollingMethods = {
const terminalStatuses = new Set(['completed', 'failed', 'timeout', 'terminated', 'cancelled', 'canceled']);
if (terminalStatuses.has(String(data.status))) {
this.refreshProjectGitSummary?.();
this.fetchTerminalCount();
}
}
if (data.result !== undefined) {

View File

@ -165,6 +165,20 @@ export const uiMethods = {
}, 5000);
},
startTerminalCountIdleRefresh() {
if (this.terminalCountRefreshTimer) return;
this.terminalCountRefreshTimer = window.setInterval(() => {
if (!this.isConnected) return;
this.fetchTerminalCount();
}, 5000);
},
stopTerminalCountIdleRefresh() {
if (!this.terminalCountRefreshTimer) return;
window.clearInterval(this.terminalCountRefreshTimer);
this.terminalCountRefreshTimer = null;
},
stopProjectGitSummaryIdleRefresh() {
if (!this.projectGitSummaryRefreshTimer) {
return;
@ -1226,6 +1240,7 @@ export const uiMethods = {
type: 'success'
});
this.refreshProjectGitSummary?.();
this.fetchTerminalCount();
} catch (error) {
this.conversations = previousConversationList;
this.searchActive = previousSearchActive;
@ -2229,7 +2244,65 @@ export const uiMethods = {
if (this.isPolicyBlocked('block_realtime_terminal', '实时终端已被管理员禁用')) {
return;
}
this.openRealtimeTerminal();
this.toggleTerminalPanel();
},
toggleTerminalPanel() {
this.terminalPanelOpen = !this.terminalPanelOpen;
if (this.terminalPanelOpen) {
this.subscribeTerminalEvents();
}
},
closeTerminalPanel() {
this.terminalPanelOpen = false;
},
subscribeTerminalEvents() {
const socket = this.socket;
if (!socket) return;
socket.emit('terminal_subscribe', { all: true });
},
// REST API 获取终端列表,用于输入栏按钮显示
async fetchTerminalCount() {
try {
const res = await fetch('/api/terminals');
if (!res.ok) return;
const data = await res.json();
if (data?.sessions) {
const sessions: Record<string, any> = {};
for (const s of data.sessions) {
const name = s.session_name || s.name || s.session || s.id;
if (name) sessions[name] = {};
}
this.terminalSessions = sessions;
// 自动打开/关闭终端面板
const personalizationStore = usePersonalizationStore();
const autoOpen = personalizationStore.form.auto_open_terminal_panel !== false;
const hasTerminals = Object.keys(sessions).length > 0;
if (autoOpen && hasTerminals && !this.terminalPanelOpen) {
this.toggleTerminalPanel();
} else if (autoOpen && !hasTerminals && this.terminalPanelOpen) {
this.closeTerminalPanel();
}
}
} catch {}
},
setTerminalSessions(sessions: Record<string, { working_dir?: string; shell?: string }>) {
this.terminalSessions = sessions;
},
setTerminalActiveSession(session: string) {
this.terminalActiveSession = session;
},
switchTerminalSession(name: string) {
this.terminalActiveSession = name;
if (this.socket) {
this.socket.emit('get_terminal_output', { session: name, lines: 0 });
}
},
handleFocusPanelToggleClick() {
@ -2836,6 +2909,36 @@ export const uiMethods = {
stopPanelResize(this);
},
// 右侧面板上下分割拖拽
startRightSplitResize(event) {
event.preventDefault();
const container = event.target.closest('.right-panels') as HTMLElement;
if (!container) return;
const startY = event.clientY;
const startHeight = container.offsetHeight;
const startRatio = this.rightSplitRatio ?? 0.5;
// 拖拽期间禁用 CSS transition确保跟手
container.classList.add('right-panels--resizing');
const onMove = (e: MouseEvent) => {
const dy = e.clientY - startY;
const newRatio = startRatio + dy / startHeight;
this.rightSplitRatio = Math.max(0.15, Math.min(0.85, newRatio));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
container.classList.remove('right-panels--resizing');
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
},
getInputComposerRef() {
return this.$refs.inputComposer || null;
},
@ -3395,11 +3498,7 @@ export const uiMethods = {
connectionDiag('log', 'heartbeat-stop', {}, { force: true });
},
openRealtimeTerminal() {
const { protocol, hostname, port } = window.location;
const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`;
window.open(target, '_blank');
},
async loadInitialData() {
try {

View File

@ -38,6 +38,7 @@ export function dataState() {
runtimeQueueSyncLockUntil: 0,
projectGitSummary: null,
projectGitSummaryRefreshTimer: null,
terminalCountRefreshTimer: null,
projectGitSummaryRefreshing: false,
gitChangesPanelOpen: false,
gitChangesLoading: false,
@ -46,6 +47,15 @@ export function dataState() {
gitChangesFileContexts: {},
gitChangesFoldContexts: {},
gitChangesDiff: null,
// 实时终端面板
terminalPanelOpen: false,
terminalSessions: {} as Record<string, { working_dir?: string; shell?: string }>,
terminalActiveSession: '',
terminalSessionLogs: {} as Record<string, string>,
terminalHydrated: {} as Record<string, boolean>,
terminalCount: 0,
// 右侧面板上下分割比例(终端占比 0.15~0.85
rightSplitRatio: 0.5,
// 输入区动态保留高度(用于同步扩大消息区可滚动范围)
composerReservedHeight: 80,
// 记录上一次成功加载历史的对话ID防止初始化阶段重复加载导致动画播放两次

View File

@ -132,6 +132,14 @@
>
等待回答问题
</button>
<button
v-if="terminalCount > 0"
type="button"
class="floating-project-status__terminal"
@click.stop="$emit('realtime-terminal')"
>
{{ terminalCount }} 个终端
</button>
<button
v-if="projectGitSummaryForRender"
type="button"
@ -524,6 +532,7 @@ const props = defineProps<{
goalRunning?: boolean;
goalProgress?: Record<string, any> | null;
hasPendingRuntimeGuidance?: boolean;
terminalCount?: number;
}>();
const inputStore = useInputStore();
@ -2499,6 +2508,27 @@ onBeforeUnmount(() => {
color: var(--git-diff-del-text);
}
.floating-project-status__terminal {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 8px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.2;
cursor: pointer;
transition: background 140ms ease, color 140ms ease;
}
.floating-project-status__terminal:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
box-shadow: 0 1px 2px var(--shadow-color);
}
.floating-project-status__submenu {
position: absolute;
top: calc(100% + 8px);

View File

@ -0,0 +1,581 @@
<template>
<aside class="terminal-panel" :style="{ width: width + 'px' }">
<!-- 顶部栏 -->
<header class="terminal-panel__header">
<div class="terminal-panel__tabs">
<div v-if="!sessionKeys.length" class="terminal-panel__empty-tab">
<span>等待终端会话...</span>
</div>
<button
v-for="name in sessionKeys"
:key="name"
type="button"
class="terminal-panel__tab"
:class="{ active: name === activeSession }"
:title="sessions[name]?.working_dir || name"
@click="switchToSession(name)"
>
<span class="terminal-panel__tab-name">{{ name }}</span>
</button>
</div>
<button
type="button"
class="terminal-panel__close"
aria-label="关闭终端面板"
@click="$emit('close')"
>&times;</button>
</header>
<!-- 终端容器 -->
<div class="terminal-panel__body" ref="terminalContainer">
<div v-if="!sessionKeys.length" class="terminal-panel__idle">
<svg class="terminal-panel__idle-svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="2" y="3" width="20" height="16" rx="2" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 7h12M6 10h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>暂无活动终端</span>
<span class="terminal-panel__idle-hint">AI 使用终端时将自动显示</span>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { Terminal } from 'xterm';
import { io as createSocketClient } from 'socket.io-client';
import 'xterm/css/xterm.css';
defineOptions({ name: 'TerminalPanel' });
const props = defineProps<{
width: number;
workspaceId?: string;
}>();
const emit = defineEmits<{
(event: 'close'): void;
}>();
// ---- socket ----
const sessions = ref<Record<string, { working_dir?: string; shell?: string }>>({});
const activeSession = ref('');
const sessionLogs = ref<Record<string, string>>({});
const sessionHydrated = ref<Record<string, boolean>>({});
let socket: ReturnType<typeof createSocketClient> | null = null;
const terminalContainer = ref<HTMLElement | null>(null);
let term: Terminal | null = null;
let themeObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
const _historyReady = ref<Record<string, boolean>>({});
const sessionKeys = computed(() => Object.keys(sessions.value));
// ---- ----
function getCurrentTheme(): 'light' | 'dark' {
const theme = document.documentElement.getAttribute('data-theme');
return theme === 'dark' ? 'dark' : 'light';
}
function lightTerminalTheme() {
return {
background: '#ffffff',
foreground: '#1f1f1f',
cursor: '#3d3929',
black: '#1f1f1f',
red: '#c04a2f',
green: '#4b8f60',
yellow: '#b48a2c',
blue: '#4a6ea9',
magenta: '#9b4d88',
cyan: '#2a8c8c',
white: '#f4f0ea',
brightBlack: '#6b6b6b',
brightRed: '#d94a3a',
brightGreen: '#5c9e6f',
brightYellow: '#c49a3c',
brightBlue: '#5a7eb9',
brightMagenta: '#ab5d98',
brightCyan: '#3a9c9c',
brightWhite: '#faf9f5'
};
}
function darkTerminalTheme() {
return {
background: '#181818',
foreground: '#d4d4d8',
cursor: '#d4d4d8',
black: '#181818',
red: '#f87171',
green: '#4ade80',
yellow: '#fbbf24',
blue: '#60a5fa',
magenta: '#c084fc',
cyan: '#22d3ee',
white: '#e4e4e7',
brightBlack: '#3f3f46',
brightRed: '#fb7185',
brightGreen: '#86efac',
brightYellow: '#fcd34d',
brightBlue: '#93c5fd',
brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9',
brightWhite: '#fafafa'
};
}
function applyTerminalTheme() {
if (!term) return;
const isDark = getCurrentTheme() === 'dark';
term.options.theme = isDark ? darkTerminalTheme() : lightTerminalTheme();
}
// ---- xterm ----
function initTerminal() {
if (!terminalContainer.value) return;
const isDark = getCurrentTheme() === 'dark';
const theme = isDark ? darkTerminalTheme() : lightTerminalTheme();
term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: '"JetBrains Mono", "SF Mono", "Fira Code", "Consolas", monospace',
theme,
scrollback: 10000,
convertEol: true,
allowTransparency: false,
disableStdin: true
});
term.open(terminalContainer.value);
// resize FitAddonFitAddon CSS
resizeObserver = new ResizeObserver(() => {
if (!term || !terminalContainer.value) return;
const rect = terminalContainer.value.getBoundingClientRect();
if (rect.width < 40 || rect.height < 40) return;
//
const charW = term._core._renderService.dimensions.css.cell.width || 7.8;
const charH = term._core._renderService.dimensions.css.cell.height || 17;
const cols = Math.max(2, Math.floor((rect.width - 16) / charW));
const rows = Math.max(1, Math.floor((rect.height - 16) / charH));
if (cols !== term.cols || rows !== term.rows) {
term.resize(cols, rows);
}
});
resizeObserver.observe(terminalContainer.value);
themeObserver = new MutationObserver(() => applyTerminalTheme());
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
}
function disposeTerminal() {
resizeObserver?.disconnect();
resizeObserver = null;
themeObserver?.disconnect();
themeObserver = null;
term?.dispose();
term = null;
fitAddon = null;
}
// ---- ----
// clear
function appendToTerm(data: string) {
if (term && activeSession.value) {
term.write(data);
}
}
// + /
function renderSessionLog() {
if (!term || !activeSession.value) return;
const log = sessionLogs.value[activeSession.value] || '';
console.log('[TerminalPanel] renderSessionLog:', activeSession.value, 'len:', log.length, 'ready:', _historyReady.value[activeSession.value], 'hydrated:', sessionHydrated.value[activeSession.value]);
term.clear();
if (log) {
term.write(log);
}
}
function switchToSession(name: string) {
console.log('[TerminalPanel] switchToSession:', name);
if (!name) return;
activeSession.value = name;
if (!sessionHydrated.value[name]) {
_historyReady.value = { ..._historyReady.value, [name]: false };
if (socket?.connected) {
console.log('[TerminalPanel] requesting history for:', name);
socket.emit('get_terminal_output', { session: name, lines: 0 });
}
}
// renderSessionLog watch(activeSession)
}
// ---- watchers ----
watch(activeSession, (val) => {
console.log('[TerminalPanel] activeSession:', val);
if (term) renderSessionLog();
});
watch(sessionKeys, (keys) => {
console.log('[TERMINAL_DEBUG] sessionKeys:', keys);
if (keys.length > 0 && term && !activeSession.value) {
switchToSession(keys[0]);
}
});
//
watch(() => props.workspaceId, (newId, oldId) => {
if (newId && oldId && newId !== oldId) {
console.log('[TerminalPanel] workspace changed:', oldId, '->', newId);
//
if (socket) {
socket.disconnect();
socket = null;
}
sessions.value = {};
activeSession.value = '';
sessionLogs.value = {};
sessionHydrated.value = {};
_historyReady.value = {};
if (term) term.clear();
initSocket();
}
});
// ---- socket ----
async function initSocket() {
console.log('[TerminalPanel] initSocket start');
socket = createSocketClient('/', {
transports: ['websocket', 'polling'],
autoConnect: false
});
const assignToken = async (): Promise<boolean> => {
const w = window as any;
if (typeof w.requestSocketToken !== 'function') {
console.warn('[TerminalPanel] requestSocketToken 不可用');
return false;
}
try {
const token = await w.requestSocketToken();
(socket as any).auth = { socket_token: token };
return true;
} catch (e) {
console.error('[TerminalPanel] 获取 token 失败:', e);
return false;
}
};
socket.io.on('reconnect_attempt', () => assignToken());
const ready = await assignToken();
if (!ready) {
console.error('[TerminalPanel] token 获取失败,放弃 socket');
socket = null;
return;
}
socket.on('connect', () => {
console.log('[TerminalPanel] socket connected');
socket!.emit('terminal_subscribe', { all: true });
});
socket.on('disconnect', () => {
console.log('[TerminalPanel] socket disconnected');
});
socket.on('terminal_subscribed', (data: any) => {
console.log('[TerminalPanel] terminal_subscribed:', data);
if (data?.terminals) {
const map: Record<string, { working_dir?: string; shell?: string }> = {};
for (const t of data.terminals) {
map[t.name] = { working_dir: t.working_dir, shell: 'bash' };
}
sessions.value = map;
if (data.terminals.length > 0 && !activeSession.value) {
switchToSession(data.terminals[0].name);
}
}
});
socket.on('terminal_started', (data: any) => {
console.log('[TerminalPanel] terminal_started:', data);
sessions.value = {
...sessions.value,
[data.session]: { working_dir: data.working_dir, shell: 'bash' }
};
if (!activeSession.value) switchToSession(data.session);
});
socket.on('terminal_list_update', (data: any) => {
console.log('[TerminalPanel] terminal_list_update:', data);
if (data?.terminals) {
const map: Record<string, { working_dir?: string; shell?: string }> = {};
for (const t of data.terminals) {
map[t.name] = { working_dir: t.working_dir, shell: 'bash' };
}
sessions.value = map;
if (data.active && !activeSession.value) switchToSession(data.active);
}
});
socket.on('terminal_output', (data: any) => {
const s = data.session;
if (!s) return;
const delta = (data.data || '') as string;
if (s === activeSession.value && term && _historyReady.value[s]) {
console.log('[TerminalPanel] terminal_output WRITE:', s, 'len:', delta.length);
term.write(delta);
// sessionLogs
sessionLogs.value[s] = (sessionLogs.value[s] || '') + delta;
} else {
console.log('[TerminalPanel] terminal_output SKIP:', s, 'len:', delta.length, 'isActive:', s === activeSession.value, 'ready:', _historyReady.value[s]);
}
if (!activeSession.value) switchToSession(s);
});
socket.on('terminal_input', (data: any) => {
console.log('[TerminalPanel] terminal_input:', data.session);
// terminal_output prompt +
//
if (!activeSession.value && data.session) switchToSession(data.session);
});
socket.on('terminal_closed', (data: any) => {
console.log('[TerminalPanel] terminal_closed:', data);
const updated = { ...sessions.value };
delete updated[data.session];
sessions.value = updated;
const remaining = Object.keys(updated);
if (activeSession.value === data.session) {
activeSession.value = remaining.length > 0 ? remaining[0] : '';
}
});
socket.on('terminal_reset', (data: any) => {
console.log('[TerminalPanel] terminal_reset:', data);
const target = data.session || activeSession.value;
if (target) {
sessionLogs.value = { ...sessionLogs.value, [target]: '' };
sessionHydrated.value = { ...sessionHydrated.value, [target]: true };
if (target === activeSession.value) renderSessionLog();
}
});
socket.on('terminal_switched', (data: any) => {
console.log('[TerminalPanel] terminal_switched:', data);
if (data.current) switchToSession(data.current);
});
const handleHistory = (data: any) => {
console.log('[TerminalPanel] handleHistory FIRED, event session:', data.session, 'data keys:', Object.keys(data).join(','));
const s = data.session || data.active;
if (!s) return;
const raw = data.output || data.data || '';
let payload = '';
if (typeof raw === 'string') {
payload = raw;
} else if (Array.isArray(raw)) {
payload = raw.join('\n');
}
console.log('[TerminalPanel] handleHistory:', s, 'rawLen:', raw.length, 'payloadLen:', payload.length, 'isActive:', s === activeSession.value);
if (payload) {
payload = payload.replace(/\x1b\[1;32m➜.*?\x1b\[0m\n?/g, '');
sessionLogs.value[s] = payload;
sessionHydrated.value = { ...sessionHydrated.value, [s]: true };
_historyReady.value = { ..._historyReady.value, [s]: true };
console.log('[TerminalPanel] handleHistory SAVED:', s, 'finalLen:', payload.length, 'calling render:', s === activeSession.value);
if (s === activeSession.value) {
renderSessionLog();
}
}
if (!sessionHydrated.value[s]) {
sessionHydrated.value = { ...sessionHydrated.value, [s]: true };
}
};
socket.on('terminal_output_history', handleHistory);
socket.on('terminal_history', handleHistory);
socket.connect();
console.log('[TerminalPanel] socket connecting...');
}
// ---- ----
onMounted(() => {
nextTick(() => {
initTerminal();
initSocket();
});
});
onBeforeUnmount(() => {
disposeTerminal();
if (socket) {
socket.disconnect();
socket = null;
}
});
</script>
<style scoped>
.terminal-panel {
display: flex;
flex-direction: column;
background: var(--surface-rail);
border-left: 1px solid var(--border-default);
color: var(--text-primary);
height: 100%;
overflow: hidden;
}
.terminal-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 38px;
padding: 0 8px;
border-bottom: 1px solid var(--border-default);
background: var(--surface-rail);
flex-shrink: 0;
}
.terminal-panel__tabs {
display: flex;
gap: 6px;
overflow-x: auto;
flex: 1;
min-width: 0;
scrollbar-width: none;
}
.terminal-panel__tabs::-webkit-scrollbar {
display: none;
}
.terminal-panel__empty-tab {
display: flex;
align-items: center;
padding: 4px 10px;
font-size: 12px;
color: var(--text-muted);
}
.terminal-panel__tab {
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-size: 12px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s, color 0.12s;
flex-shrink: 0;
}
.terminal-panel__tab:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
}
.terminal-panel__tab.active {
background: var(--theme-tab-active);
color: var(--text-primary);
}
[data-theme='dark'] .terminal-panel__tab:hover,
[data-theme='dark'] .terminal-panel__tab.active {
background: var(--hover-bg);
}
.terminal-panel__tab-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-panel__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
margin-left: 4px;
}
.terminal-panel__close:hover {
background: var(--theme-tab-active);
color: var(--claude-text);
box-shadow: 0 1px 2px var(--shadow-color);
}
.terminal-panel__body {
flex: 1;
overflow: hidden;
min-height: 0;
}
.terminal-panel__idle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
}
.terminal-panel__idle-svg {
width: 36px;
height: 36px;
opacity: 0.35;
}
.terminal-panel__idle-hint {
font-size: 11px;
opacity: 0.55;
}
.terminal-panel__body :deep(.xterm) {
width: 100% !important;
height: 100% !important;
padding: 8px;
}
.terminal-panel__body :deep(.xterm-screen) {
width: 100% !important;
height: 100% !important;
}
.terminal-panel__body :deep(.xterm-viewport) {
scrollbar-width: none;
}
.terminal-panel__body :deep(.xterm-viewport::-webkit-scrollbar) {
display: none;
}
</style>

View File

@ -702,6 +702,28 @@
class="fancy-path"
></path></svg></span
></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">自动打开终端面板</span
><span class="settings-row-desc"
>创建终端时自动打开侧边栏终端面板</span
></span
><input
type="checkbox"
:checked="form.auto_open_terminal_panel"
@change="
personalization.updateField({
key: 'auto_open_terminal_panel',
value: $event.target.checked
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">堆叠块显示模式</span

View File

@ -624,6 +624,9 @@ export async function initializeLegacySocket(ctx: any) {
ctx.isConnected = true;
socketLog('WebSocket已连接');
// 自动订阅终端事件,确保终端面板数据始终就绪
ctx.socket.emit('terminal_subscribe', { all: true });
// 首次连接且历史已在加载/已就绪时,避免重复清空与重复动画
if (!isReconnect && (historyLoadingSame || historyReady)) {
socketLog('初次连接已存在历史,跳过重复重置与加载');
@ -1763,6 +1766,119 @@ export async function initializeLegacySocket(ctx: any) {
ctx.addSystemMessage(`命令失败: ${data.message}`);
}
});
// === 实时终端事件 ===
ctx.socket.on('terminal_subscribed', (data) => {
console.log('[Socket] terminal_subscribed:', data);
if (data?.terminals) {
const sessions: Record<string, { working_dir?: string; shell?: string }> = {};
for (const t of data.terminals) {
sessions[t.name] = {
working_dir: t.working_dir,
shell: 'bash'
};
}
ctx.setTerminalSessions(sessions);
if (data.terminals.length > 0 && !ctx.terminalActiveSession) {
ctx.setTerminalActiveSession(data.terminals[0].name);
}
}
});
ctx.socket.on('terminal_started', (data) => {
console.log('[Socket] terminal_started:', data);
if (data?.session) {
const sessions = { ...ctx.terminalSessions };
sessions[data.session] = {
working_dir: data.working_dir,
shell: 'bash'
};
ctx.setTerminalSessions(sessions);
if (!ctx.terminalActiveSession) {
ctx.setTerminalActiveSession(data.session);
}
}
});
ctx.socket.on('terminal_list_update', (data) => {
if (data?.terminals) {
const sessions: Record<string, { working_dir?: string; shell?: string }> = {};
for (const t of data.terminals) {
sessions[t.name] = {
working_dir: t.working_dir,
shell: 'bash'
};
}
ctx.setTerminalSessions(sessions);
if (!ctx.terminalActiveSession && data.terminals.length > 0) {
ctx.setTerminalActiveSession(data.terminals[0].name);
}
}
});
ctx.socket.on('terminal_closed', (data) => {
if (data?.session) {
const sessions = { ...ctx.terminalSessions };
delete sessions[data.session];
ctx.setTerminalSessions(sessions);
if (ctx.terminalActiveSession === data.session) {
const names = Object.keys(sessions);
ctx.setTerminalActiveSession(names.length > 0 ? names[0] : '');
}
}
});
ctx.socket.on('terminal_reset', (data) => {
if (data?.session) {
const logs = { ...ctx.terminalSessionLogs };
logs[data.session] = '';
ctx.terminalSessionLogs = logs;
}
});
ctx.socket.on('terminal_output_history', (data) => {
if (data?.session && data?.output) {
const logs = { ...ctx.terminalSessionLogs };
logs[data.session] = (logs[data.session] || '') + data.output;
ctx.terminalSessionLogs = logs;
}
});
ctx.socket.on('terminal_history', (data) => {
if (data?.session && data?.output) {
const logs = { ...ctx.terminalSessionLogs };
// 历史数据前置(完整替换),因为这是完整的历史记录
logs[data.session] = data.output;
ctx.terminalSessionLogs = logs;
// 标记已水合
const hydrated = { ...ctx.terminalHydrated };
hydrated[data.session] = true;
ctx.terminalHydrated = hydrated;
}
});
ctx.socket.on('terminal_output', (data) => {
if (data?.session && data?.data) {
const logs = { ...ctx.terminalSessionLogs };
logs[data.session] = (logs[data.session] || '') + data.data;
if (logs[data.session] && logs[data.session].length > 400000) {
logs[data.session] = logs[data.session].slice(-400000);
}
ctx.terminalSessionLogs = logs;
}
});
ctx.socket.on('terminal_input', (data) => {
if (data?.session && data?.data) {
const formatted = `\x1b[1;32m➤ ${data.data}\x1b[0m`;
const logs = { ...ctx.terminalSessionLogs };
logs[data.session] = (logs[data.session] || '') + formatted;
if (logs[data.session] && logs[data.session].length > 400000) {
logs[data.session] = logs[data.session].slice(-400000);
}
ctx.terminalSessionLogs = logs;
}
});
} catch (error) {
console.error('Socket初始化失败:', error);
}

View File

@ -26,6 +26,7 @@ interface PersonalForm {
enhanced_tool_display: boolean;
compact_message_display: CompactMessageDisplay;
show_git_status_bar: boolean;
auto_open_terminal_panel: boolean;
stacked_hide_borders: boolean;
enhanced_tool_display_categories: string[];
enabled_skills: string[];
@ -124,6 +125,7 @@ const defaultForm = (): PersonalForm => ({
enhanced_tool_display: true,
compact_message_display: 'full',
show_git_status_bar: true,
auto_open_terminal_panel: true,
stacked_hide_borders: false,
enhanced_tool_display_categories: [],
enabled_skills: [],
@ -315,6 +317,7 @@ export const usePersonalizationStore = defineStore('personalization', {
enhanced_tool_display: data.enhanced_tool_display !== false,
compact_message_display: data.compact_message_display === 'brief' ? 'brief' : 'full',
show_git_status_bar: data.show_git_status_bar !== false,
auto_open_terminal_panel: data.auto_open_terminal_panel !== false,
stacked_hide_borders: !!data.stacked_hide_borders,
enhanced_tool_display_categories: Array.isArray(data.enhanced_tool_display_categories)
? data.enhanced_tool_display_categories.filter((item: unknown) => typeof item === 'string')

View File

@ -75,6 +75,7 @@ interface UiState {
leftWidth: number;
rightWidth: number;
rightCollapsed: boolean;
rightSplitRatio: number;
isResizing: boolean;
resizingPanel: ResizingPanel;
minPanelWidth: number;
@ -101,6 +102,7 @@ export const useUiStore = defineStore('ui', {
leftWidth: 350,
rightWidth: 420,
rightCollapsed: true,
rightSplitRatio: 0.5,
isResizing: false,
resizingPanel: null,
minPanelWidth: 350,
@ -161,6 +163,9 @@ export const useUiStore = defineStore('ui', {
setRightCollapsed(collapsed: boolean) {
this.rightCollapsed = collapsed;
},
setRightSplitRatio(ratio: number) {
this.rightSplitRatio = Math.max(0.15, Math.min(0.85, ratio));
},
setResizing(state: boolean, panel: ResizingPanel = null) {
this.isResizing = state;
this.resizingPanel = state ? panel : null;

View File

@ -480,23 +480,78 @@ body[data-theme='dark'] .git-change-file__open-icon {
color: var(--state-danger);
}
.git-changes-panel-slide-enter-active,
.git-changes-panel-slide-leave-active {
transition:
width 0.22s ease,
min-width 0.22s ease,
opacity 0.18s ease,
transform 0.22s ease;
/* === 外层容器width 滑入/滑出(长写属性,不覆盖 grid 过渡) === */
/* 基础过渡:只用于 grid-template-rows */
.right-panels {
transition-property: grid-template-rows;
transition-duration: 0.28s;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.git-changes-panel-slide-enter-from,
.git-changes-panel-slide-leave-to {
/* enter/leave 时追加 width/opacity/transform 过渡 */
.right-panels-slide-enter-active,
.right-panels-slide-leave-active {
transition-property: width, min-width, opacity, transform;
transition-duration: 0.22s, 0.22s, 0.18s, 0.22s;
transition-timing-function: ease, ease, ease, ease;
}
.right-panels-slide-enter-from,
.right-panels-slide-leave-to {
width: 0 !important;
min-width: 0;
min-width: 0 !important;
opacity: 0;
transform: translateX(18px);
}
/* leave 时强制收缩 grid配合过渡 */
.right-panels-slide-leave-to {
grid-template-rows: 0fr 0px 0fr !important;
}
/* === 右侧面板容器Grid 布局grid-template-rows 过渡) === */
.right-panels {
display: grid;
min-width: 340px;
max-width: min(600px, 52vw);
height: var(--app-viewport);
overflow: hidden;
}
.right-panels__slot {
overflow: hidden;
min-height: 0;
}
/* 强制 Git 面板始终在第 3 行,即使 split-handle 不渲染 */
.right-panels__slot:last-child {
grid-row: 3;
}
.right-panels .git-changes-panel,
.right-panels .terminal-panel {
width: auto !important;
min-width: 0;
max-width: none;
height: 100% !important;
}
/* 上下分割拖拽条:纯交互层,分隔线由 Git 面板 border-top 提供 */
.right-panels__split-handle {
cursor: row-resize;
}
/* 拖拽期间禁用所有过渡,确保跟手 */
.right-panels--resizing {
transition: none !important;
}
/* Git 面板上边线 */
.right-panels .git-changes-panel {
border-top: 1px solid var(--border-default);
}
.no-files {
text-align: center;
color: var(--claude-text-secondary);