feat(terminal): 实时终端改造为侧边栏面板,支持拖拽分割与自动开关
- 将实时终端从独立页面改造为 Vue3 侧边栏面板,与 Git 面板上下排列 - 使用 xterm.js + Grid 布局,支持三主题配色适配 - 上下拖拽分割条,动态调整终端/Git 面板比例 - 输入栏上方新增终端数量按钮,点击快捷打开 - 个人空间新增「自动打开终端面板」开关(外观与显示分类) - REST API /api/terminals 获取终端列表,多时机自动刷新 - 有终端自动打开面板,无终端自动关闭
This commit is contained in:
parent
788753f6fd
commit
2e54f2c343
24
package-lock.json
generated
24
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,防止初始化阶段重复加载导致动画播放两次
|
||||
|
||||
@ -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);
|
||||
|
||||
581
static/src/components/panels/TerminalPanel.vue
Normal file
581
static/src/components/panels/TerminalPanel.vue
Normal 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')"
|
||||
>×</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 而不是用 FitAddon(FitAddon 会操作 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>
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user