From cf4d0d0ba5811a71902f9beee7d03f5936060744 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Mon, 3 Aug 2026 14:28:31 +0800 Subject: [PATCH] Refine management terminal logs and command UX --- .../components/ServerLiveOperations.tsx | 287 +++++++++++++++--- platform_web/pages/ConsolePages.test.tsx | 18 +- platform_web/theme/base-css.test.js | 9 +- platform_web/theme/base.css | 39 ++- 4 files changed, 295 insertions(+), 58 deletions(-) diff --git a/platform_web/components/ServerLiveOperations.tsx b/platform_web/components/ServerLiveOperations.tsx index 7c10bff..b4da7d8 100644 --- a/platform_web/components/ServerLiveOperations.tsx +++ b/platform_web/components/ServerLiveOperations.tsx @@ -1,5 +1,5 @@ import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react"; -import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { LogEntryBody, LogStreamResponse } from "../api/types"; @@ -9,16 +9,24 @@ import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews" type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string }; -type TerminalLine = { id: string; tone: "input" | "info" | "success" | "error"; text: string; at: string }; +type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number }; +type TerminalQuickCommand = { label: string; command: string; hint: string }; -const liveLogPollMs = 2000; +const liveLogPollMs = 1000; +const terminalLogPollMs = 1000; +const logStreamPollMs = 5000; +const liveLogHistoryWindow = 100; +const terminalLogQueryLimit = 150; +const terminalHistoryWindow = 150; const maxLogEntries = 500; -const terminalQuickCommands = [ - { label: "查询玩家", command: "ListPlayers" }, - { label: "服务器状态", command: "ServerInfo" }, - { label: "设为中午", command: "SetTime 12" }, - { label: "保存世界", command: "SaveWorld" } -]; +const maxTerminalLines = 600; +const terminalQuickCommandCatalog: Record = { + "game.scum": [ + { label: "查询玩家", command: "ListPlayers", hint: "SCUM RCON" }, + { label: "服务器信息", command: "ServerInfo", hint: "SCUM RCON" }, + { label: "设为中午", command: "SetTime 12", hint: "SCUM RCON" } + ] +}; interface LiveOperationDrawerProps { open: boolean; @@ -29,9 +37,10 @@ interface LiveOperationDrawerProps { backdropClassName?: string; panelClassName?: string; bodyClassName?: string; + hideHeader?: boolean; } -function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName }: LiveOperationDrawerProps) { +function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) { useEffect(() => { if (!open) return undefined; const previous = document.body.style.overflow; @@ -50,13 +59,15 @@ function LiveOperationDrawer({ open, title, description, onClose, children, back return (
@@ -79,9 +90,9 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser const [keyword, setKeyword] = useState(""); const [lastRefreshAt, setLastRefreshAt] = useState(""); - const loadStreams = useCallback(async () => { + const loadStreams = useCallback(async (showLoading = true) => { if (!open) return; - setStreams({ status: "loading" }); + if (showLoading) setStreams({ status: "loading" }); try { const response = await platformApiClient.listServerLiveLogs(serverId); setStreams({ status: "ready", data: response.items }); @@ -99,13 +110,20 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser void loadStreams(); }, [loadStreams, open]); + useEffect(() => { + if (!open) return undefined; + const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs); + return () => window.clearInterval(timer); + }, [loadStreams, open]); + const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined; const tailSelectedStream = useCallback(async () => { if (!open || !selectedStream) return; - const afterSeq = cursorByStream[selectedStream.id] ?? 0; + const afterSeq = cursorByStream[selectedStream.id] ?? initialLogCursor(selectedStream, liveLogHistoryWindow); const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 }); - setCursorByStream((current) => ({ ...current, [selectedStream.id]: Math.max(current[selectedStream.id] ?? 0, cursor.nextSeq, cursor.latestSeq) })); + const nextSeq = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq); + setCursorByStream((current) => updateCursor(current, selectedStream.id, nextSeq)); setLastRefreshAt(new Date().toLocaleTimeString()); if (cursor.entries.length === 0) return; setEntries((current) => [ @@ -131,10 +149,20 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq })); } + function selectLogStream(nextStreamId: string) { + setSelectedStreamId(nextStreamId); + setEntries([]); + setCursorByStream((current) => { + const next = { ...current }; + delete next[nextStreamId]; + return next; + }); + } + return (
- selectLogStream(event.target.value)}> {streams.status === "ready" && streams.data.map((stream) => )} setKeyword(event.target.value)} /> @@ -175,60 +203,173 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu const [command, setCommand] = useState(""); const [pending, setPending] = useState(false); const [lines, setLines] = useState([]); + const [streams, setStreams] = useState>({ status: "loading" }); + const [cursorByStream, setCursorByStream] = useState>({}); + const [commandHistory, setCommandHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(null); const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); - const supportsCommands = pluginId === "game.scum"; + const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); + const supportsCommands = quickCommands.length > 0; + const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]); + + const appendLines = useCallback((incoming: TerminalLine[]) => { + if (incoming.length === 0) return; + setLines((current) => mergeTerminalLines(current, incoming)); + }, []); + + const loadStreams = useCallback(async (showLoading = true) => { + if (!open) return; + if (showLoading) setStreams({ status: "loading" }); + try { + const response = await platformApiClient.listServerLiveLogs(serverId); + setStreams({ status: "ready", data: response.items }); + } catch (error) { + setStreams({ status: "error", reason: error instanceof Error ? error.message : "实时日志源加载失败" }); + } + }, [open, serverId]); + + const tailTerminalLogs = useCallback(async (targetStreams = terminalStreams) => { + if (!open || targetStreams.length === 0) return; + const cursorUpdates: Record = {}; + const batches = await Promise.all(targetStreams.map(async (stream) => { + const afterSeq = cursorByStream[stream.id] ?? initialLogCursor(stream, terminalHistoryWindow); + const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq, limit: terminalLogQueryLimit }); + cursorUpdates[stream.id] = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq); + return cursor.entries.map((entry) => terminalLineFromLog(stream, entry)); + })); + setCursorByStream((current) => { + let changed = false; + const next = { ...current }; + for (const [streamId, cursor] of Object.entries(cursorUpdates)) { + const nextCursor = Math.max(next[streamId] ?? 0, cursor); + if (nextCursor !== next[streamId]) { + next[streamId] = nextCursor; + changed = true; + } + } + return changed ? next : current; + }); + appendLines(batches.flat().sort(compareTerminalLines)); + }, [appendLines, cursorByStream, open, terminalStreams]); useEffect(() => { if (!open) return; setCommand(""); setPending(false); setResult(null); - setLines([{ id: `open-${Date.now()}`, tone: "info", text: supportsCommands ? "SCUM 管理终端已连接到平台 Source RCON 调度通道。" : "该插件暂未声明可用的管理终端命令通道。", at: new Date().toLocaleTimeString() }]); - }, [open, supportsCommands]); + setCursorByStream({}); + setHistoryIndex(null); + setLines([terminalSystemLine("info", supportsCommands ? "读取平台历史日志,后续按游标实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); + void loadStreams(); + }, [loadStreams, open, supportsCommands]); + + useEffect(() => { + if (!open) return undefined; + const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs); + return () => window.clearInterval(timer); + }, [loadStreams, open]); + + useEffect(() => { + if (!open || streams.status !== "ready") return; + void tailTerminalLogs(terminalRelevantStreams(streams.data)).catch((error) => appendLines([terminalSystemLine("error", error instanceof Error ? error.message : "历史日志读取失败", "LOGS")])); + }, [appendLines, open, streams]); + + useEffect(() => { + if (!open || terminalStreams.length === 0) return undefined; + const timer = window.setInterval(() => void tailTerminalLogs().catch(() => undefined), terminalLogPollMs); + return () => window.clearInterval(timer); + }, [open, tailTerminalLogs, terminalStreams]); + + function selectQuickCommand(item: TerminalQuickCommand) { + setCommand(item.command); + setHistoryIndex(null); + } + + function handleCommandKeyDown(event: ReactKeyboardEvent) { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + if (commandHistory.length === 0) return; + event.preventDefault(); + const lastIndex = commandHistory.length - 1; + if (event.key === "ArrowUp") { + const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1); + setHistoryIndex(nextIndex); + setCommand(commandHistory[nextIndex] ?? ""); + return; + } + if (historyIndex === null) return; + const nextIndex = historyIndex + 1; + if (nextIndex > lastIndex) { + setHistoryIndex(null); + setCommand(""); + return; + } + setHistoryIndex(nextIndex); + setCommand(commandHistory[nextIndex] ?? ""); + } + + function clearTerminalBuffer() { + setLines([]); + } async function submitCommand(event: FormEvent) { event.preventDefault(); if (!supportsCommands || !canManage || pending || !command.trim()) return; const submitted = command.trim(); setCommand(""); + setHistoryIndex(null); + setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50)); setPending(true); setResult({ status: "pending", label: "正在提交命令" }); - setLines((current) => [...current, { id: `input-${Date.now()}`, tone: "input", text: `> ${submitted}`, at: new Date().toLocaleTimeString() }]); + appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]); try { const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted)); const label = `已排队 · 任务 ${response.jobId}`; setResult({ status: "succeeded", label }); - setLines((current) => [...current, { id: `ok-${response.jobId}`, tone: "success", text: `${label} · ${response.message || response.status}`, at: new Date().toLocaleTimeString() }]); + appendLines([terminalSystemLine("success", `${label} · ${response.message || response.status}`, "PLATFORM", `ok-${response.jobId}`)]); + void tailTerminalLogs().catch(() => undefined); } catch (error) { const label = error instanceof Error ? error.message : "命令提交失败"; setResult({ status: "failed", label }); - setLines((current) => [...current, { id: `err-${Date.now()}`, tone: "error", text: label, at: new Date().toLocaleTimeString() }]); + appendLines([terminalSystemLine("error", label, "ERROR")]); } finally { setPending(false); } } return ( - +
+
+
+ {serverName} + 最近历史 + {terminalLogPollMs / 1000}s 实时刷新 · 日志源 {logStreamPollMs / 1000}s 探测 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"} +
+
+ + +
+
- {lines.map((line) =>
{line.text}
)} + {streams.status === "error" &&
LOGS{streams.reason}
} + {streams.status === "ready" && streams.data.length === 0 &&
LOGS暂无日志源。需要 Run 上报或历史日志回填后,这里才会持续追加。
} + {lines.map((line) =>
{line.streamKey || line.level || "LOG"}{line.text}
)}
- 快捷指令 + {quickCommands.length > 0 ? "插件快捷指令" : "命令输入"} {result && }
-
- {terminalQuickCommands.map((item) => )} -
+ {quickCommands.length > 0 &&
+ {quickCommands.map((item) => )} +
} {!supportsCommands && } title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />} {supportsCommands && (
void submitCommand(event)}>
@@ -244,3 +385,79 @@ function levelClass(level?: string): string { if (normalized === "warn" || normalized === "warning") return "log-level-warn"; return "log-level-info"; } + +function initialLogCursor(stream: LogStreamResponse, historyWindow: number): number { + return Math.max(0, stream.latestSeq - historyWindow); +} + +function nextCursorSeq(afterSeq: number, entries: LogEntryBody[], nextSeq: number): number { + if (entries.length === 0) return Math.max(afterSeq, nextSeq); + return Math.max(afterSeq, nextSeq, entries[entries.length - 1]?.seq ?? afterSeq); +} + +function updateCursor(current: Record, streamId: string, cursor: number): Record { + const nextCursor = Math.max(current[streamId] ?? 0, cursor); + if (nextCursor === current[streamId]) return current; + return { ...current, [streamId]: nextCursor }; +} + +function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { + return terminalQuickCommandCatalog[pluginId] ?? []; +} + +function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] { + return [...streams].sort(compareTerminalStreams).slice(0, 8); +} + +function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number { + return terminalStreamRank(a) - terminalStreamRank(b) || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id); +} + +function terminalStreamRank(stream: LogStreamResponse): number { + const key = `${stream.source}:${stream.streamKey}`.toLowerCase(); + if (key.includes("management-program")) return 0; + if (key.includes("stderr")) return 1; + if (key.includes("stdout")) return 2; + return 3; +} + +function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { + return { + id: `log-${stream.id}-${entry.seq}`, + tone: terminalTone(entry), + text: entry.line, + at: new Date(entry.timestamp).toLocaleTimeString(), + sortKey: Date.parse(entry.timestamp) || Date.now(), + streamKey: stream.streamKey || stream.source, + level: entry.level, + seq: entry.seq + }; +} + +function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine { + const now = Date.now(); + return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey }; +} + +function terminalTone(entry: LogEntryBody): TerminalLine["tone"] { + const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase(); + if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error"; + if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn"; + if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success"; + return "info"; +} + +function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] { + const seen = new Set(current.map((line) => line.id)); + const merged = [...current]; + for (const line of incoming) { + if (seen.has(line.id)) continue; + seen.add(line.id); + merged.push(line); + } + return merged.sort(compareTerminalLines).slice(-maxTerminalLines); +} + +function compareTerminalLines(a: TerminalLine, b: TerminalLine): number { + return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id); +} diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 2e1574b..3a855a5 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -292,9 +292,23 @@ describe("first-party console pages", () => { it("renders management terminal as a large command console with quick commands", () => { expect(serverLiveOperationsSource).toContain("management-terminal-drawer"); + expect(serverLiveOperationsSource).toContain("terminal-output-topbar"); expect(serverLiveOperationsSource).toContain("terminal-command-dock"); - expect(serverLiveOperationsSource).toContain("terminalQuickCommands"); - expect(serverLiveOperationsSource).toContain("setCommand(item.command)"); + expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog"); + expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin"); + expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)"); + expect(serverLiveOperationsSource).toContain("hideHeader"); + expect(serverLiveOperationsSource).toContain("handleCommandKeyDown"); + expect(serverLiveOperationsSource).toContain("commandHistory"); + expect(serverLiveOperationsSource).toContain("ArrowUp"); + expect(serverLiveOperationsSource).toContain("listServerLiveLogs"); + expect(serverLiveOperationsSource).toContain("queryLogStream"); + expect(serverLiveOperationsSource).toContain("terminalLogPollMs = 1000"); + expect(serverLiveOperationsSource).toContain("logStreamPollMs = 5000"); + expect(serverLiveOperationsSource).toContain("mergeTerminalLines"); + expect(serverLiveOperationsSource).toContain("nextCursorSeq"); + expect(serverLiveOperationsSource).toContain("initialLogCursor"); + expect(serverLiveOperationsSource).not.toContain("SaveWorld"); }); it("renders plugin catalog bridge readiness", () => { diff --git a/platform_web/theme/base-css.test.js b/platform_web/theme/base-css.test.js index 5256f1b..f884873 100644 --- a/platform_web/theme/base-css.test.js +++ b/platform_web/theme/base-css.test.js @@ -168,9 +168,16 @@ describe("platform web shared theme CSS", () => { it("keeps the management terminal opaque and large", () => { const css = compact(readThemeCss()); + expect(css).toContain(".terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:10dvh00;background:#000;backdrop-filter:none}"); expect(css).toContain(".management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh"); - expect(css).toContain("background:#05090f;backdrop-filter:none"); + expect(css).toContain("background:#000;backdrop-filter:none;box-shadow:none"); expect(css).toContain(".management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr)auto"); + expect(css).toContain(".terminal-output-panel{display:grid;grid-template-rows:autominmax(0,1fr);min-height:0;border:1pxsolid#222;border-radius:8px;background:#000"); + expect(css).toContain(".terminal-output-topbar{display:flex;align-items:center;justify-content:space-between"); + expect(css).toContain(".terminal-line{display:grid;grid-template-columns:76px148pxminmax(0,1fr)"); + expect(css).toContain(".terminal-line-success.terminal-stream,.terminal-line-success.terminal-text{color:#7ee787}"); + expect(css).toContain(".terminal-line-warn.terminal-stream,.terminal-line-warn.terminal-text{color:#d29922}"); + expect(css).toContain(".terminal-line-error.terminal-stream,.terminal-line-error.terminal-text{color:#ff7b72}"); expect(css).toContain(".terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr))"); }); diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index fc7b0d0..9a9a17e 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -490,31 +490,29 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .live-operation-toolbar .icon-command{min-height:36px} .live-log-list{max-height:min(58dvh,560px)} .live-log-line{cursor:default} -.terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:5dvh 0 0;background:rgba(2,6,10,.76);backdrop-filter:none} -.management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh;align-content:stretch;grid-template-rows:auto minmax(0,1fr);padding:16px 18px;border:1px solid var(--line-strong);border-bottom:0;border-radius:8px 8px 0 0;background:#05090f;backdrop-filter:none;box-shadow:0 -18px 48px rgba(0,0,0,.56),inset 0 1px 0 color-mix(in srgb,var(--rim-light) 24%,transparent)} -.management-terminal-drawer>.panel-header{top:-16px;margin:-16px -18px 0;padding:14px 18px 12px;background:#07101a;-webkit-backdrop-filter:none;backdrop-filter:none} -.management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr) auto;gap:12px} -.terminal-output-panel{display:grid;min-height:0} -.terminal-output{display:grid;gap:6px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:12px;border-radius:8px;background:radial-gradient(circle at 92% 0,rgba(255,255,255,.1),transparent 36%),var(--code-surface);font-family:var(--font-mono);font-size:12.5px;color:var(--code-ink);box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 14px 32px rgba(255,255,255,.12)} +.terminal-drawer-backdrop{align-items:flex-end;justify-content:center;padding:10dvh 0 0;background:#000;backdrop-filter:none} +.management-terminal-drawer{width:100%;max-width:none;height:90dvh;max-height:90dvh;align-content:stretch;grid-template-rows:minmax(0,1fr);padding:12px 14px;border:1px solid #242424;border-bottom:0;border-radius:8px 8px 0 0;background:#000;backdrop-filter:none;box-shadow:none} +.management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr) auto;gap:10px} +.terminal-output-panel{display:grid;grid-template-rows:auto minmax(0,1fr);min-height:0;border:1px solid #222;border-radius:8px;background:#000;overflow:hidden} +.terminal-output-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:44px;padding:8px 10px;border-bottom:1px solid #1f2933;background:#000;color:#dbeafe} +.terminal-output-topbar>div{display:flex;align-items:center;gap:8px;min-width:0}.terminal-output-topbar>div:first-child{display:grid;gap:2px}.terminal-output-topbar strong{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-output-topbar span{font-size:11px;color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.terminal-output-action{min-height:30px;display:inline-flex;align-items:center;gap:5px;padding:0 9px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#c9d1d9;cursor:pointer;font:inherit;font-size:12px}.terminal-output-action:hover,.terminal-output-action:focus-visible{border-color:#58a6ff;color:#fff;outline:0} +.terminal-output{display:grid;align-content:start;gap:2px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:10px;border-radius:0;background:#000;font-family:var(--font-mono);font-size:12.5px;color:#c9d1d9;box-shadow:none} .management-terminal-body .terminal-output{height:100%;min-height:0;max-height:none} -.terminal-line{display:grid;grid-template-columns:72px minmax(0,1fr);gap:10px;align-items:start} -.terminal-line time{color:var(--code-muted)} -.terminal-line span{overflow-wrap:anywhere} -.terminal-line-input span{color:var(--accent)} -.terminal-line-success span{color:var(--teal)} -.terminal-line-error span{color:var(--danger)} -.terminal-command-dock{display:grid;gap:10px;padding:12px;border:1px solid var(--line-strong);border-radius:8px;background:#08111b;box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 14px 32px rgba(0,0,0,.28)} +.terminal-line{display:grid;grid-template-columns:76px 148px minmax(0,1fr);gap:10px;align-items:start;min-height:20px;padding:1px 0} +.terminal-line time{color:#6e7681}.terminal-stream{color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-text{overflow-wrap:anywhere;color:#c9d1d9} +.terminal-line-input .terminal-stream,.terminal-line-input .terminal-text{color:#79c0ff}.terminal-line-success .terminal-stream,.terminal-line-success .terminal-text{color:#7ee787}.terminal-line-warn .terminal-stream,.terminal-line-warn .terminal-text{color:#d29922}.terminal-line-error .terminal-stream,.terminal-line-error .terminal-text{color:#ff7b72} +.terminal-command-dock{display:grid;gap:10px;padding:10px;border:1px solid #222;border-radius:8px;background:#000;box-shadow:none} .terminal-command-dock-header{display:flex;align-items:center;justify-content:space-between;gap:10px} -.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:var(--ink);font-weight:800} +.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:#f0f6fc;font-weight:800} .terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px} -.terminal-quick-command{display:grid;gap:4px;min-height:52px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#0b1722;color:var(--ink-soft);cursor:pointer;text-align:left} -.terminal-quick-command:hover,.terminal-quick-command:focus-visible{border-color:var(--accent);outline:0;color:var(--ink)} +.terminal-quick-command{display:grid;gap:3px;min-height:56px;padding:8px 10px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#8b949e;cursor:pointer;text-align:left} +.terminal-quick-command:hover,.terminal-quick-command:focus-visible{border-color:#58a6ff;outline:0;color:#f0f6fc} .terminal-quick-command:disabled{cursor:not-allowed;opacity:.56} -.terminal-quick-command strong{font-size:12px;color:var(--ink)} -.terminal-quick-command span{font-family:var(--font-mono);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.terminal-quick-command strong{font-size:12px;color:#f0f6fc}.terminal-quick-command span,.terminal-quick-command small{font-family:var(--font-mono);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-quick-command small{color:#6e7681} .terminal-command-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;align-items:end} -.terminal-command-form label{display:grid;gap:6px;color:var(--ink-soft);font-size:12px} -.terminal-command-form input{min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit} +.terminal-command-form label{display:grid;gap:4px;color:#c9d1d9;font-size:12px}.terminal-command-form label small{color:#6e7681} +.terminal-command-form input{min-height:38px;border:1px solid #30363d;border-radius:6px;padding:0 10px;background:#050505;color:#f0f6fc;font:inherit}.terminal-command-form input:focus{border-color:#58a6ff;outline:0}.terminal-command-form .primary-command{border-color:#30363d;background:#161b22;color:#f0f6fc;box-shadow:none}.terminal-command-form .primary-command:hover,.terminal-command-form .primary-command:focus-visible{border-color:#58a6ff;color:#fff} .plugin-detail-panel{width:100%;height:auto;overflow:visible;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--panel-shadow)} .management-dialog-panel{width:min(720px,calc(100vw - 32px));height:auto;max-height:min(760px,calc(100dvh - 32px));overflow-x:hidden;overflow-y:auto;border:1px solid var(--line);border-left:1px solid var(--line);border-radius:8px;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 48px var(--glass-shadow),0 0 34px var(--moonbeam)} .management-dialog-wide{width:min(920px,calc(100vw - 32px))} @@ -712,6 +710,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .provider-table,.resource-table{min-width:680px} .log-line{grid-template-columns:minmax(0,1fr);gap:2px} .terminal-quick-command-list{grid-template-columns:repeat(2,minmax(0,1fr))} +.terminal-line{grid-template-columns:64px minmax(0,1fr);gap:5px 8px}.terminal-stream,.terminal-text{grid-column:2}.terminal-output-topbar{align-items:stretch}.terminal-output-topbar,.terminal-output-topbar>div{display:grid}.terminal-output-topbar>div:last-child{grid-template-columns:repeat(2,minmax(0,1fr))} .management-terminal-drawer{height:92dvh;max-height:92dvh} .ai-provider-toolbar{align-items:stretch} .icon-command,.segmented-button{flex:1 1 auto}