diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-core.ts b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-core.ts new file mode 100644 index 000000000000..34de87b4e080 --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-core.ts @@ -0,0 +1,94 @@ +// REQ-125 C3-term(#550)—— 右栏终端面板的纯模型与引擎通道 seam。 +// +// 边界(seam 基线 §② 白名单 / §③ I1):终端仿真与 PTY 数据通道属可复用的非布局内核, +// alpha 只自持外壳(页签条 / 圆角深底输出区外框 / 脚条)。引擎经下方 +// `AlphaTerminalEngineChannel` typed seam 进入,本目录零 import 上游模块。 +// +// 地面真相(2026-07-24 勘破):上游引擎 = `packages/app/src/components/terminal.tsx` +// (ghostty-web WASM + PTY WebSocket 数据通道)+ `packages/app/src/context/terminal.tsx` +// (workspace 级 PTY 页签状态,`SessionProviders` 已将其挂在 alpha session 叶之上)。 +// 两者今日均不在 `@opencode-ai/app` 公开面上 —— exports map 被 +// `surface-seam-contract.test.ts` 钉死为仅 `./surface/session`(ADR-027)。接入真实引擎 +// 需要一次窄 export(ADR-027 修订 + alpha-frontend.patch),归 owner 决策,不在本票擅动; +// 在此之前 channel 缺席,面板 fail-closed 落空态。 +// +// I8:本面板不自持任何会话/工作区异步状态 —— 一切数据都是 channel 访问器的同步派生。 +// 但同 workspace 的会话切换不会重挂本子树,同步 accessor 本身证明不了背后的 PTY 状态 +// 属于当前会话:channel 投影必须在铸造时盖上 `serverKey+directory+sessionID` 三元身份 +// (`identity`),消费侧经 C1 `live.accepts(identity)` 校验;身份不符或校验器缺席一律 +// fail-closed 视为 channel 缺席(`acceptedEngineChannel`),旧 channel 无法继续渲染。 +import type { Component } from "solid-js" +import type { AlphaSessionIdentity } from "../../session-workspace/session-workspace-core" + +/** 一条终端实例(引擎侧 PTY)的页签视图模型。 */ +export interface AlphaTerminalInstance { + id: string + title: string + /** 该实例是否有任务在跑(面板页签呼吸点;缺数据 = false,不伪造)。 */ + running: boolean +} + +/** 脚条状态明细:运行状态 + 环境 + 尺寸(缺项即不渲染对应段,不伪造)。 */ +export interface AlphaTerminalFootStatus { + running: boolean + shell?: string + cols?: number + rows?: number +} + +/** + * 引擎通道 typed seam —— 面板消费的全部外部能力。 + * 未来适配器把上游 `useTerminal()`(状态)与 `Terminal`(Ghostty 嵌入)收敛成本形状。 + * 适配器在投影时盖 `identity`(I8 三元组);消费侧必须经 `acceptedEngineChannel` 校验。 + */ +export interface AlphaTerminalEngineChannel { + /** 本 channel 投影所属的会话三元身份(serverKey+directory+sessionID),铸造时固定。 */ + identity: AlphaSessionIdentity + ready(): boolean + instances(): AlphaTerminalInstance[] + activeID(): string | undefined + open(id: string): void + close(id: string): void + create(): void + footStatus(id: string): AlphaTerminalFootStatus + /** 引擎渲染的输出区内容;alpha 只提供圆角深底外框。 */ + EngineOutput: Component<{ instanceID: string }> +} + +/** + * I8 fail-closed 闸:channel 只有携带被当前 live 上下文接受的三元身份才可被消费。 + * channel 缺席、校验器缺席、身份不符 —— 一律返回 undefined(面板落空态), + * 同 workspace 切会话后旧 channel 立即失效,直到适配器按新身份重投影。 + */ +export function acceptedEngineChannel( + channel: AlphaTerminalEngineChannel | undefined, + accepts: ((identity: AlphaSessionIdentity) => boolean) | undefined, +): AlphaTerminalEngineChannel | undefined { + if (!channel || !accepts) return undefined + if (!accepts(channel.identity)) return undefined + return channel +} + +/** 激活实例解析:空列表 → undefined;activeID 失效(已关闭)→ 回落首个实例。 */ +export function resolveActiveInstance( + instances: AlphaTerminalInstance[], + activeID: string | undefined, +): AlphaTerminalInstance | undefined { + if (instances.length === 0) return undefined + return instances.find((instance) => instance.id === activeID) ?? instances[0] +} + +/** 运行指示三层中的最外层输入:任一实例在跑(右栏 tab 呼吸点数据源)。 */ +export function anyTerminalRunning(instances: AlphaTerminalInstance[]): boolean { + return instances.some((instance) => instance.running) +} + +function isPositiveSize(value: number | undefined): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 +} + +/** 脚条尺寸段:`80×24`;任一维缺失或非法 → undefined(整段不渲染)。 */ +export function formatTerminalSize(cols: number | undefined, rows: number | undefined): string | undefined { + if (!isPositiveSize(cols) || !isPositiveSize(rows)) return undefined + return `${cols}×${rows}` +} diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-panel.tsx b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-panel.tsx new file mode 100644 index 000000000000..167560b2568c --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-panel.tsx @@ -0,0 +1,172 @@ +// REQ-125 C3-term(#550)—— 右栏终端面板外壳(已批稿 session-workspace §终端)。 +// alpha 自持:实例页签条(白卡浮起 + 运行呼吸点 + 新建/关闭)、圆角深底输出区外框 +// (双主题恒深底)、脚条(运行状态 · 环境 · 尺寸)。输出区内容由引擎经 +// `AlphaTerminalEngineChannel` 渲染;channel 缺席或未就绪 → fail-closed 空态。 +import { For, Show, createMemo } from "solid-js" +import { t } from "../../../i18n" +import type { AlphaSessionIdentity } from "../../session-workspace/session-workspace-core" +import { + acceptedEngineChannel, + anyTerminalRunning, + formatTerminalSize, + resolveActiveInstance, + type AlphaTerminalEngineChannel, +} from "./terminal-rail-core" +import "./terminal-rail.css" + +const STAGE_ID = "alpha-terminal-stage" + +function PlusIcon() { + return ( + + + + ) +} + +export function TerminalRailPanel(props: { + channel?: AlphaTerminalEngineChannel + /** C1 live 上下文的身份校验(`live.accepts`);缺席 = fail-closed,channel 视为不存在。 */ + accepts?: (identity: AlphaSessionIdentity) => boolean +}) { + // I8:channel 必须携带被当前会话接受的三元身份,且 ready;任一不满足即落空态。 + const engine = () => { + const channel = acceptedEngineChannel(props.channel, props.accepts) + return channel?.ready() ? channel : undefined + } + const instances = createMemo(() => engine()?.instances() ?? []) + const active = createMemo(() => resolveActiveInstance(instances(), engine()?.activeID())) + const foot = createMemo(() => { + const current = active() + const channel = engine() + if (!current || !channel) return undefined + return channel.footStatus(current.id) + }) + + return ( + + + + + + + + {t("alpha.terminal.emptyTitle")} + {t("alpha.terminal.emptyBody")} + engine()?.create()} + > + + {t("alpha.terminal.new")} + + + } + > + {(current) => ( + <> + + + {(instance) => ( + + engine()?.open(instance.id)} + > + + + + {instance.title} + + engine()?.close(instance.id)} + > + + + + + + )} + + engine()?.create()} + > + + + + + + + {(channel) => { + const EngineOutput = channel.EngineOutput + return ( + + {(instanceID) => } + + ) + }} + + + + {(status) => ( + + + + {status().running ? t("alpha.terminal.footRunning") : t("alpha.terminal.footIdle")} + + + {(shell) => ( + <> + + · + + {shell()} + > + )} + + + {(size) => {size()}} + + + )} + + + > + )} + + + ) +} diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-test-runtime.tsx b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-test-runtime.tsx new file mode 100644 index 000000000000..3b875e67a2cb --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail-test-runtime.tsx @@ -0,0 +1,127 @@ +// REQ-125 C3-term(#550)—— 组件测试 runtime:假引擎通道驱动面板(形态同 +// session-workspace-test-runtime)。假通道只实现 seam 形状,行为与上游语义对齐: +// open 切激活、close 移除并回落激活、create 追加并激活。 +// I8:channel 投影铸造时盖三元身份;accepts 用真 sameSessionIdentity 对当前 live +// 身份校验 —— 同 workspace 切会话即拒收旧投影,重投影后恢复。 +import { createSignal } from "solid-js" +import { render } from "solid-js/web" +import { sameSessionIdentity, type AlphaSessionIdentity } from "../../session-workspace/session-workspace-core" +import { + type AlphaTerminalEngineChannel, + type AlphaTerminalFootStatus, + type AlphaTerminalInstance, +} from "./terminal-rail-core" +import { TerminalRailPanel } from "./terminal-rail-panel" + +export { render } + +interface FakeInstance extends AlphaTerminalInstance { + foot: AlphaTerminalFootStatus +} + +const WORKSPACE = { serverKey: "sidecar", directory: "/tmp/workspace" } + +const identityOf = (sessionID: string): AlphaSessionIdentity => ({ ...WORKSPACE, sessionID }) + +const initialInstances: FakeInstance[] = [ + { + id: "pty_1", + title: "终端 1", + running: true, + foot: { running: true, shell: "zsh", cols: 80, rows: 24 }, + }, + { + id: "pty_2", + title: "终端 2", + running: false, + foot: { running: false, shell: "zsh", cols: 80, rows: 24 }, + }, +] + +const [instances, setInstances] = createSignal(initialInstances) +const [activeID, setActiveID] = createSignal("pty_1") +const [ready, setReady] = createSignal(true) +const [liveIdentity, setLiveIdentity] = createSignal(identityOf("ses_a")) + +export const channelCalls: string[] = [] + +let nextCreated = 3 + +function makeChannel(identity: AlphaSessionIdentity): AlphaTerminalEngineChannel { + return { + identity, + ready, + instances, + activeID, + open(id) { + channelCalls.push(`open:${id}`) + setActiveID(id) + }, + close(id) { + channelCalls.push(`close:${id}`) + setInstances((all) => all.filter((instance) => instance.id !== id)) + setActiveID((current) => (current === id ? instances()[0]?.id : current)) + }, + create() { + channelCalls.push("create") + const id = `pty_${nextCreated}` + nextCreated += 1 + const created: FakeInstance = { + id, + title: `终端 ${nextCreated - 1}`, + running: false, + foot: { running: false, shell: "zsh", cols: 80, rows: 24 }, + } + setInstances((all) => [...all, created]) + setActiveID(id) + }, + footStatus(id) { + return instances().find((instance) => instance.id === id)?.foot ?? { running: false } + }, + EngineOutput: (props) => , + } +} + +const [channel, setChannel] = createSignal(makeChannel(identityOf("ses_a"))) + +const accepts = (identity: AlphaSessionIdentity) => sameSessionIdentity(identity, liveIdentity()) + +export function TerminalRailHarness() { + return +} + +export function TerminalRailHarnessWithoutEngine() { + return +} + +/** 同 workspace 切会话:只换 live 三元组的 sessionID(serverKey+directory 不变)。 */ +export function setTerminalLiveSession(sessionID: string) { + setLiveIdentity(identityOf(sessionID)) +} + +/** 适配器语义:按给定会话身份重投影 channel(共享同一 workspace 级 PTY 状态)。 */ +export function projectTerminalChannel(sessionID: string) { + setChannel(makeChannel(identityOf(sessionID))) +} + +export function setTerminalInstances(next: FakeInstance[]) { + setInstances(next) +} + +export function setTerminalActiveID(next: string | undefined) { + setActiveID(next) +} + +export function setTerminalChannelReady(next: boolean) { + setReady(next) +} + +export function resetTerminalRailHarness() { + setInstances(initialInstances) + setActiveID("pty_1") + setReady(true) + setLiveIdentity(identityOf("ses_a")) + setChannel(makeChannel(identityOf("ses_a"))) + nextCreated = 3 + channelCalls.splice(0) +} diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail.css b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail.css new file mode 100644 index 000000000000..188859978f7b --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail.css @@ -0,0 +1,299 @@ +/* REQ-125 C3-term(#550)· 右栏终端面板外壳 —— 已批稿 session-workspace §终端。 + * 令牌纪律(I5):全部 var() 引用为 --a-*;唯一例外是 .a-term-stage 上定义的 + * --a-term-stage-* 局部锚 —— 终端可读性约定要求输出区双主题恒深底(已批稿明文), + * 这些值不进全局主题、不受 [data-theme] 条件、不染 alpha chrome。 + * 依赖全局 tokens.css/base.css(经 session-workspace.css 于 .a-ui 根注入)。 */ + +.a-term-panel { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; +} + +/* ── 实例页签条 ─────────────────────────────────────────────── */ + +.a-term-tabs { + display: flex; + align-items: center; + gap: 4px; + flex: none; + min-width: 0; + padding: 8px 10px 0; + overflow-x: auto; +} + +.a-term-tab { + position: relative; + display: inline-flex; + align-items: center; + flex: none; + height: 28px; + border: 1px solid transparent; + border-radius: var(--a-radius-md); + color: var(--a-text-secondary); + font-size: var(--a-text-sm); + font-weight: var(--a-weight-medium); +} + +.a-term-tab:hover { + background: var(--a-bg-muted); +} + +.a-term-tab--on { + border-color: var(--a-border); + background: var(--a-surface); + box-shadow: var(--a-shadow-xs); + color: var(--a-text); +} + +.a-term-tab-open { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + height: 100%; + padding: 0 3px 0 11px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.a-term-tab-label { + overflow: hidden; + max-width: 140px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.a-term-tab-close { + display: grid; + place-items: center; + flex: none; + width: 18px; + height: 18px; + margin-right: 5px; + padding: 0; + border: 0; + border-radius: var(--a-radius-xs); + background: transparent; + color: var(--a-text-tertiary); + cursor: pointer; + opacity: 0; + transition: opacity var(--a-dur-fast) var(--a-ease-out); +} + +.a-term-tab:hover .a-term-tab-close, +.a-term-tab--on .a-term-tab-close, +.a-term-tab-close:focus-visible { + opacity: 1; +} + +.a-term-tab-close:hover { + background: var(--a-bg-muted); + color: var(--a-text); +} + +.a-term-rundot { + flex: none; + width: 6px; + height: 6px; + border-radius: var(--a-radius-full); + background: var(--a-success); + animation: a-term-pulse 1.4s var(--a-ease-in-out) infinite; +} + +.a-term-add { + display: grid; + place-items: center; + flex: none; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: var(--a-radius-md); + background: transparent; + color: var(--a-text-tertiary); + cursor: pointer; +} + +.a-term-add:hover { + background: var(--a-bg-muted); + color: var(--a-text); +} + +.a-term-icon { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.a-term-tab-close .a-term-icon { + width: 11px; + height: 11px; +} + +/* ── 输出区外框:圆角容器,双主题恒深底(引擎在内渲染)────────── */ + +.a-term-stage { + --a-term-stage-bg: #0a0b0d; + --a-term-stage-text: #d4d4d8; + --a-term-stage-text-dim: #71727a; + --a-term-stage-border: rgba(255, 255, 255, 0.08); + --a-term-stage-success: #4ade80; + + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + margin: 8px 10px 10px; + border: 1px solid var(--a-border); + border-radius: var(--a-radius-lg); + overflow: hidden; + background: var(--a-term-stage-bg); +} + +.a-term-output { + position: relative; + flex: 1; + min-height: 0; + color: var(--a-term-stage-text); + font-family: var(--a-font-mono); + font-size: var(--a-text-sm); +} + +/* ── 脚条:运行状态 · 环境 · 尺寸(mono 小字,恒居深底内)────── */ + +.a-term-foot { + display: flex; + align-items: center; + gap: 8px; + flex: none; + padding: 6px 10px; + border-top: 1px solid var(--a-term-stage-border); + color: var(--a-term-stage-text-dim); + font-family: var(--a-font-mono); + font-size: var(--a-text-2xs); +} + +.a-term-foot-state { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.a-term-foot-state i { + width: 6px; + height: 6px; + border-radius: var(--a-radius-full); + background: var(--a-term-stage-text-dim); +} + +.a-term-foot-state--running { + color: var(--a-term-stage-success); +} + +.a-term-foot-state--running i { + background: var(--a-term-stage-success); + animation: a-term-pulse 1.4s var(--a-ease-in-out) infinite; +} + +.a-term-foot-size { + margin-left: auto; +} + +/* ── 空态(无实例 / 引擎通道缺席 fail-closed)──────────────── */ + +.a-term-empty { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 30px; + text-align: center; +} + +.a-term-empty-icon { + display: grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: var(--a-radius-lg); + background: var(--a-bg-muted); + color: var(--a-text-tertiary); +} + +.a-term-empty-icon .a-term-icon { + width: 19px; + height: 19px; +} + +.a-term-empty b { + color: var(--a-text); + font-size: var(--a-text-sm); + font-weight: var(--a-weight-medium); +} + +.a-term-empty p { + margin: 0; + max-width: 30ch; + color: var(--a-text-tertiary); + font-size: var(--a-text-xs); + line-height: var(--a-leading-snug); +} + +.a-term-empty-new { + display: inline-flex; + align-items: center; + gap: 5px; + height: 28px; + padding: 0 12px; + border: 1px solid var(--a-border); + border-radius: var(--a-radius-md); + background: var(--a-surface); + color: var(--a-text); + font-size: var(--a-text-sm); + font-weight: var(--a-weight-medium); + cursor: pointer; +} + +.a-term-empty-new:hover:not(:disabled) { + background: var(--a-bg-muted); +} + +.a-term-empty-new:disabled { + color: var(--a-text-disabled); + cursor: default; +} + +@keyframes a-term-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .a-term-rundot, + .a-term-foot-state--running i { + animation: none; + } + + .a-term-tab-close { + transition: none; + } +} diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail.test.ts b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail.test.ts new file mode 100644 index 000000000000..37d5238650db --- /dev/null +++ b/packages/ui-mac/src/renderer/alpha-ui/session-rail/terminal/terminal-rail.test.ts @@ -0,0 +1,162 @@ +// REQ-125 C3-term(#550)—— 终端面板:页签状态机(纯模型)+ 真 Solid 挂载外壳 + I1/I5 静态断言。 +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join, resolve } from "node:path" +import { sameSessionIdentity, type AlphaSessionIdentity } from "../../session-workspace/session-workspace-core" +import { + acceptedEngineChannel, + anyTerminalRunning, + formatTerminalSize, + resolveActiveInstance, + type AlphaTerminalEngineChannel, + type AlphaTerminalInstance, +} from "./terminal-rail-core" + +const panel = readFileSync(join(import.meta.dir, "terminal-rail-panel.tsx"), "utf8") +const core = readFileSync(join(import.meta.dir, "terminal-rail-core.ts"), "utf8") +const runtimeSource = readFileSync(join(import.meta.dir, "terminal-rail-test-runtime.tsx"), "utf8") +const css = readFileSync(join(import.meta.dir, "terminal-rail.css"), "utf8") +const shell = readFileSync(join(import.meta.dir, "../../session-workspace/session-workspace-shell.tsx"), "utf8") + +const instance = (id: string, running = false): AlphaTerminalInstance => ({ id, title: id, running }) + +// 剥掉注释行:静态断言约束的是代码;注释里允许指名上游落点(勘破记录)。 +const codeLines = (source: string) => + source + .split("\n") + .filter((line) => !/^\s*(\/\/|\/?\*)/.test(line)) + .join("\n") + +describe("REQ-125 C3-term tab state machine", () => { + test("active resolution: empty list, stale active, and direct hit", () => { + expect(resolveActiveInstance([], "pty_1")).toBeUndefined() + expect(resolveActiveInstance([], undefined)).toBeUndefined() + + const all = [instance("pty_1"), instance("pty_2")] + expect(resolveActiveInstance(all, "pty_2")?.id).toBe("pty_2") + expect(resolveActiveInstance(all, undefined)?.id).toBe("pty_1") + expect(resolveActiveInstance(all, "pty_gone")?.id).toBe("pty_1") + }) + + test("any-running rolls up per-instance run indicators", () => { + expect(anyTerminalRunning([])).toBe(false) + expect(anyTerminalRunning([instance("a"), instance("b")])).toBe(false) + expect(anyTerminalRunning([instance("a"), instance("b", true)])).toBe(true) + }) + + test("engine channel is only consumable with an accepted identity triple (I8 fail-closed)", () => { + const identity: AlphaSessionIdentity = { serverKey: "sidecar", directory: "/tmp/workspace", sessionID: "ses_a" } + const channel = { identity } as AlphaTerminalEngineChannel + const acceptsOnly = (live: AlphaSessionIdentity) => (candidate: AlphaSessionIdentity) => + sameSessionIdentity(candidate, live) + + // channel 缺席 / 校验器缺席 → 一律拒绝(fail-closed)。 + expect(acceptedEngineChannel(undefined, acceptsOnly(identity))).toBeUndefined() + expect(acceptedEngineChannel(channel, undefined)).toBeUndefined() + + // 三元组任一维不符 → 拒绝;完全一致才放行。 + expect(acceptedEngineChannel(channel, acceptsOnly({ ...identity, sessionID: "ses_b" }))).toBeUndefined() + expect(acceptedEngineChannel(channel, acceptsOnly({ ...identity, directory: "/tmp/other" }))).toBeUndefined() + expect(acceptedEngineChannel(channel, acceptsOnly({ ...identity, serverKey: "remote" }))).toBeUndefined() + expect(acceptedEngineChannel(channel, acceptsOnly(identity))).toBe(channel) + }) + + test("foot size renders only from sane cols×rows", () => { + expect(formatTerminalSize(80, 24)).toBe("80×24") + expect(formatTerminalSize(undefined, 24)).toBeUndefined() + expect(formatTerminalSize(80, undefined)).toBeUndefined() + expect(formatTerminalSize(0, 24)).toBeUndefined() + expect(formatTerminalSize(-80, 24)).toBeUndefined() + expect(formatTerminalSize(80.5, 24)).toBeUndefined() + expect(formatTerminalSize(Number.NaN, 24)).toBeUndefined() + }) +}) + +describe("REQ-125 C3-term real Solid mount", () => { + test("component cases cover tabs, run indicators, engine embed, foot, and fail-closed states", () => { + const result = Bun.spawnSync({ + cmd: [process.execPath, "test", resolve(import.meta.dir, "../../../../../test-component/terminal-rail.cases.ts")], + cwd: resolve(import.meta.dir, "../../../../.."), + env: process.env, + }) + const output = `${result.stdout.toString()}${result.stderr.toString()}` + if (result.exitCode !== 0) throw new Error(output) + expect(output).toContain("8 pass") + expect(output).toContain("0 fail") + }) +}) + +describe("REQ-125 C3-term I1/I5 static ratchets", () => { + test("terminal shell imports no upstream module and hand-rolls no engine or data channel", () => { + const sources = codeLines(`${panel}\n${core}\n${runtimeSource}`) + const forbidden = [ + // I1:零上游 import —— 引擎只能经 typed seam(AlphaTerminalEngineChannel)进入。 + "@opencode-ai/app", + "@opencode-ai/ui", + "@opencode-ai/session-ui", + "app/src/", + "MessageTimeline", + "MessagePart", + "SessionPage", + // 零查询上游 DOM。 + "querySelector", + "MutationObserver", + // 引擎与数据通道不得在 alpha 侧重实现(白名单 = 复用,不是重写)。 + "ghostty-web", + "new WebSocket", + "window.api", + ] + forbidden.forEach((token) => expect(sources).not.toContain(token)) + expect(panel).toContain("AlphaTerminalEngineChannel") + // I8:channel 消费必须走身份闸,seam 上必须携带三元身份。 + expect(panel).toContain("acceptedEngineChannel(props.channel, props.accepts)") + expect(core).toContain("identity: AlphaSessionIdentity") + }) + + test("panel copy is fully externalized to i18n with en/zh parity", () => { + // 产品面无硬编码文案(CJK 只允许出现在注释外的字符串 = 0;此处直接禁全文件 CJK,注释亦用可审计中文? + // —— 注释允许中文,故只扫 JSX/字符串行:粗粒度为「非注释行不得含 CJK」。 + const cjkLines = panel + .split("\n") + .filter((line) => !line.trim().startsWith("//") && !line.trim().startsWith("*") && !line.trim().startsWith("/*")) + .filter((line) => /[㐀-鿿]/.test(line)) + expect(cjkLines).toEqual([]) + + const en = readFileSync(join(import.meta.dir, "../../../i18n/en.ts"), "utf8") + const zh = readFileSync(join(import.meta.dir, "../../../i18n/zh.ts"), "utf8") + const keys = [...panel.matchAll(/t\("(alpha\.[a-z.A-Z]+)"/g)].map((match) => match[1]) + expect(keys.length).toBeGreaterThan(0) + keys.forEach((key) => { + expect(en).toContain(`"${key}"`) + expect(zh).toContain(`"${key}"`) + }) + }) + + test("css uses only --a-* tokens; raw colors live only in the theme-invariant stage anchors", () => { + // I5:一切 var() 引用是 --a-*。 + expect( + [...css.matchAll(/var\((--[^,)]+)/g)].map((match) => match[1]).filter((token) => !token.startsWith("--a-")), + ).toEqual([]) + + // 颜色字面量只允许出现在 --a-term-stage-* 定义行(双主题恒深底的局部锚)。 + const rawColorLines = codeLines(css) + .split("\n") + .filter((line) => /#[0-9a-f]{3,8}\b|rgba?\(|oklch\(/i.test(line)) + .filter((line) => !/^\s*--a-term-stage-[a-z-]+:/.test(line)) + expect(rawColorLines).toEqual([]) + + // 深底不随主题条件翻转:文件内禁任何主题条件选择器。 + expect(codeLines(css)).not.toContain("data-theme") + expect(codeLines(css)).not.toContain("prefers-color-scheme") + expect(css.match(/\.a-term-stage \{[^}]*\}/s)?.[0]).toContain("background: var(--a-term-stage-bg)") + + // 呼吸点动效在 reduced-motion 下静止。 + expect(css).toContain("@media (prefers-reduced-motion: reduce)") + expect(css.match(/@media \(prefers-reduced-motion: reduce\) \{[\s\S]*$/)?.[0]).toContain("animation: none") + }) + + test("shell mounts the terminal panel only for the terminal rail state, gated by live.accepts", () => { + expect(shell).toContain(`import { TerminalRailPanel } from "../session-rail/terminal/terminal-rail-panel"`) + expect(shell).toMatch(/activePanel\(\) === "terminal"[\s\S]{0,300}/) + }) +}) diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace-shell.tsx b/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace-shell.tsx index 88a83ea4d04e..dd84b011200e 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace-shell.tsx +++ b/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace-shell.tsx @@ -1,5 +1,6 @@ import { createSignal, type Accessor, Show } from "solid-js" import { t } from "../../i18n" +import { TerminalRailPanel } from "../session-rail/terminal/terminal-rail-panel" import type { AlphaSessionIdentity, AlphaSessionLiveSnapshot } from "./session-workspace-core" export interface AlphaSessionLiveContext { @@ -118,7 +119,13 @@ export function SessionWorkspaceShell(props: { live: AlphaSessionLiveContext }) data-alpha-session-rail-host data-alpha-session-rail-panel={activePanel()} aria-label={t("alpha.session.railHost")} - /> + > + + {/* C3-term(#550):引擎通道待窄 export 落地后接入;缺席时面板 fail-closed 空态。 + I8:channel 身份必须过 live.accepts,会话切换后旧投影即刻失效。 */} + + + )} diff --git a/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace.css b/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace.css index 4ef9febdbaf3..a7e9c91caf9e 100644 --- a/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace.css +++ b/packages/ui-mac/src/renderer/alpha-ui/session-workspace/session-workspace.css @@ -191,7 +191,9 @@ } .a-swk-rail-host { + display: flex; flex: none; + flex-direction: column; width: min(400px, 38vw); min-width: 320px; min-height: 0; diff --git a/packages/ui-mac/src/renderer/i18n/en.ts b/packages/ui-mac/src/renderer/i18n/en.ts index 811c69dd25ae..52c8fac40865 100644 --- a/packages/ui-mac/src/renderer/i18n/en.ts +++ b/packages/ui-mac/src/renderer/i18n/en.ts @@ -1134,6 +1134,14 @@ export const dict = { "alpha.session.crossServerDetail": "The Alpha sidebar shows local-engine sessions. This window is connected to another server, so the session cannot open here. Return home and choose a local session.", "alpha.session.backHome": "Back to home", + "alpha.terminal.tabs": "Terminal tabs", + "alpha.terminal.new": "New terminal", + "alpha.terminal.close": "Close {{title}}", + "alpha.terminal.emptyTitle": "No terminals yet", + "alpha.terminal.emptyBody": "Create one, or ask the assistant to run commands for you from the composer.", + "alpha.terminal.footRunning": "Running", + "alpha.terminal.footIdle": "Idle", + "alpha.sidebar.appearance": "Appearance", "alpha.sidebar.themeLight": "Light", "alpha.sidebar.themeDark": "Dark", diff --git a/packages/ui-mac/src/renderer/i18n/locale-regression.test.ts b/packages/ui-mac/src/renderer/i18n/locale-regression.test.ts index 187b76610a31..3610a0160d03 100644 --- a/packages/ui-mac/src/renderer/i18n/locale-regression.test.ts +++ b/packages/ui-mac/src/renderer/i18n/locale-regression.test.ts @@ -47,6 +47,7 @@ const EXTERNALIZED_SURFACES = [ "alpha-ui/composer-autocomplete.tsx", "alpha-ui/model-picker-add.tsx", "alpha-ui/model-picker-core.ts", + "alpha-ui/session-rail/terminal/terminal-rail-panel.tsx", "alpha-ui/session-workspace/alpha-session-workspace.tsx", "alpha-ui/settings.tsx", "alpha-ui/surface-boundary.tsx", diff --git a/packages/ui-mac/src/renderer/i18n/zh.ts b/packages/ui-mac/src/renderer/i18n/zh.ts index d947c9fddf98..cd3401553845 100644 --- a/packages/ui-mac/src/renderer/i18n/zh.ts +++ b/packages/ui-mac/src/renderer/i18n/zh.ts @@ -1123,6 +1123,13 @@ export const dict = { "alpha.session.crossServerTitle": "此会话不属于当前连接的服务器", "alpha.session.crossServerDetail": "Alpha 侧栏固定显示本地引擎的会话;当前窗口已切换到其他服务器,无法在这里打开它。请返回首页后重新选择本地会话。", "alpha.session.backHome": "返回首页", + "alpha.terminal.tabs": "终端页签", + "alpha.terminal.new": "新建终端", + "alpha.terminal.close": "关闭 {{title}}", + "alpha.terminal.emptyTitle": "还没有终端", + "alpha.terminal.emptyBody": "新建一个,或在输入框里让助手替你跑命令。", + "alpha.terminal.footRunning": "运行中", + "alpha.terminal.footIdle": "空闲", "alpha.sidebar.appearance": "外观", "alpha.sidebar.themeLight": "浅色", "alpha.sidebar.themeDark": "深色", diff --git a/packages/ui-mac/test-component/terminal-rail.cases.ts b/packages/ui-mac/test-component/terminal-rail.cases.ts new file mode 100644 index 000000000000..1dfdb5ec9d44 --- /dev/null +++ b/packages/ui-mac/test-component/terminal-rail.cases.ts @@ -0,0 +1,227 @@ +import { transformAsync } from "@babel/core" +import presetTypescript from "@babel/preset-typescript" +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import presetSolid from "babel-preset-solid" + +GlobalRegistrator.register() +const solid = await import("solid-js/dist/solid.js") +mock.module("solid-js", () => solid) +const solidWeb = await import("solid-js/web/dist/web.js") +mock.module("solid-js/web", () => solidWeb) + +Bun.plugin({ + name: "terminal-rail-component-test", + setup(builder) { + builder.onLoad({ filter: /packages\/ui-mac\/src\/.*\.tsx$/ }, async (args) => { + const transformed = await transformAsync(await Bun.file(args.path).text(), { + filename: args.path, + presets: [ + [presetSolid, { generate: "dom", hydratable: false }], + [presetTypescript, { allExtensions: true, isTSX: true, onlyRemoveTypeImports: true }], + ], + sourceMaps: "inline", + }) + return { contents: transformed?.code ?? "", loader: "js" } + }) + }, +}) + +const runtime = await import("../src/renderer/alpha-ui/session-rail/terminal/terminal-rail-test-runtime") +const disposers: Array<() => void> = [] + +beforeEach(() => { + runtime.resetTerminalRailHarness() + document.body.replaceChildren() +}) + +afterEach(() => + disposers + .splice(0) + .reverse() + .forEach((dispose) => dispose()), +) + +afterAll(() => GlobalRegistrator.unregister()) + +async function flush() { + await Promise.resolve() + await Promise.resolve() +} + +function mount(component: () => unknown) { + const host = document.createElement("div") + document.body.append(host) + disposers.push(solidWeb.render(() => component(), host)) + return host +} + +const tab = (host: HTMLElement, id: string) => host.querySelector(`[data-alpha-terminal-tab="${id}"]`) +const tabButton = (host: HTMLElement, id: string) => tab(host, id)?.querySelector("[role='tab']") + +describe("REQ-125 terminal rail panel real Solid mount", () => { + test("renders instance tabs, run indicators, engine output, and the foot from the channel", async () => { + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(2) + expect(tabButton(host, "pty_1")?.getAttribute("aria-selected")).toBe("true") + expect(tabButton(host, "pty_2")?.getAttribute("aria-selected")).toBe("false") + + // 运行指示:仅在跑的实例带呼吸点;面板根暴露「任一在跑」供右栏 tab 层消费。 + expect(tab(host, "pty_1")?.querySelector(".a-term-rundot")).not.toBeNull() + expect(tab(host, "pty_2")?.querySelector(".a-term-rundot")).toBeNull() + expect(host.querySelector("[data-alpha-terminal-panel]")?.getAttribute("data-alpha-terminal-any-running")).toBe( + "true", + ) + + // 引擎输出:激活实例挂进输出区外框;脚条 = 运行中 · zsh · 80×24。 + expect( + host.querySelector("[data-alpha-terminal-output] [data-alpha-terminal-engine-output='pty_1']"), + ).not.toBeNull() + const foot = host.querySelector("[data-alpha-terminal-foot]")! + expect(foot.querySelector("[data-alpha-terminal-foot-state]")?.getAttribute("data-alpha-terminal-foot-state")).toBe( + "running", + ) + expect(foot.textContent).toContain("运行中") + expect(foot.textContent).toContain("zsh") + expect(foot.textContent).toContain("80×24") + + // 运行结束 → 呼吸点与「任一在跑」一起消失(状态驱动,非一次性渲染)。 + runtime.setTerminalInstances([ + { id: "pty_1", title: "终端 1", running: false, foot: { running: false, shell: "zsh", cols: 80, rows: 24 } }, + ]) + await flush() + expect(tab(host, "pty_1")?.querySelector(".a-term-rundot")).toBeNull() + expect( + host.querySelector("[data-alpha-terminal-panel]")?.getAttribute("data-alpha-terminal-any-running"), + ).toBeNull() + }) + + test("switching tabs goes through the channel and remounts the engine output", async () => { + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + + tabButton(host, "pty_2")!.click() + await flush() + + expect(runtime.channelCalls).toContain("open:pty_2") + expect(tabButton(host, "pty_2")?.getAttribute("aria-selected")).toBe("true") + expect(tabButton(host, "pty_1")?.getAttribute("aria-selected")).toBe("false") + expect( + host.querySelector("[data-alpha-terminal-output] [data-alpha-terminal-engine-output='pty_2']"), + ).not.toBeNull() + expect(host.querySelector("[data-alpha-terminal-engine-output='pty_1']")).toBeNull() + expect( + host + .querySelector("[data-alpha-terminal-foot] [data-alpha-terminal-foot-state]") + ?.getAttribute("data-alpha-terminal-foot-state"), + ).toBe("idle") + }) + + test("create and close go through the channel and the active tab follows", async () => { + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + + host.querySelector(".a-term-add")!.click() + await flush() + expect(runtime.channelCalls).toContain("create") + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(3) + expect(tabButton(host, "pty_3")?.getAttribute("aria-selected")).toBe("true") + + host.querySelector("[data-alpha-terminal-close='pty_3']")!.click() + await flush() + expect(runtime.channelCalls).toContain("close:pty_3") + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(2) + expect(tabButton(host, "pty_1")?.getAttribute("aria-selected")).toBe("true") + expect( + host.querySelector("[data-alpha-terminal-output] [data-alpha-terminal-engine-output='pty_1']"), + ).not.toBeNull() + }) + + test("stale activeID falls back to the first instance instead of a blank stage", async () => { + runtime.setTerminalActiveID("pty_gone") + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + + expect(tabButton(host, "pty_1")?.getAttribute("aria-selected")).toBe("true") + expect( + host.querySelector("[data-alpha-terminal-output] [data-alpha-terminal-engine-output='pty_1']"), + ).not.toBeNull() + }) + + test("no instances shows the approved empty state with a working create entry", async () => { + runtime.setTerminalInstances([]) + runtime.setTerminalActiveID(undefined) + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + + expect(host.querySelector("[data-alpha-terminal-empty]")).not.toBeNull() + expect(host.querySelector("[role='tablist']")).toBeNull() + expect(host.querySelector("[data-alpha-terminal-empty]")?.textContent).toContain("还没有终端") + + const create = host.querySelector("[data-alpha-terminal-empty] [data-alpha-terminal-new]")! + expect(create.disabled).toBe(false) + create.click() + await flush() + expect(runtime.channelCalls).toContain("create") + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(1) + }) + + test("without an engine channel the panel fails closed to the empty state", async () => { + const host = mount(() => runtime.TerminalRailHarnessWithoutEngine()) + await flush() + + expect(host.querySelector("[data-alpha-terminal-empty]")).not.toBeNull() + expect(host.querySelector("[role='tablist']")).toBeNull() + expect(host.querySelector("[data-alpha-terminal-output]")).toBeNull() + expect(host.querySelector(".a-term-rundot")).toBeNull() + expect( + host.querySelector("[data-alpha-terminal-empty] [data-alpha-terminal-new]")?.disabled, + ).toBe(true) + }) + + test("same-directory session switch rejects the stale channel until re-projection (I8)", async () => { + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(2) + + // 同 workspace(serverKey+directory 不变)切到另一会话:旧 channel 身份不再被 live.accepts + // 接受 → 面板即刻按 channel 缺席处理(空态 + 新建禁用),旧投影不得继续渲染。 + runtime.setTerminalLiveSession("ses_b") + await flush() + expect(host.querySelector("[data-alpha-terminal-empty]")).not.toBeNull() + expect(host.querySelector("[role='tablist']")).toBeNull() + expect(host.querySelector("[data-alpha-terminal-output]")).toBeNull() + expect(host.querySelector("[data-alpha-terminal-engine-output='pty_1']")).toBeNull() + expect( + host.querySelector("[data-alpha-terminal-empty] [data-alpha-terminal-new]")?.disabled, + ).toBe(true) + + // 适配器按新会话身份重投影 → 面板恢复实例形态(workspace 级 PTY 状态共享)。 + runtime.projectTerminalChannel("ses_b") + await flush() + expect(host.querySelector("[data-alpha-terminal-empty]")).toBeNull() + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(2) + expect( + host.querySelector("[data-alpha-terminal-output] [data-alpha-terminal-engine-output='pty_1']"), + ).not.toBeNull() + }) + + test("a channel that is not ready is treated the same as absent (fail closed)", async () => { + runtime.setTerminalChannelReady(false) + const host = mount(() => runtime.TerminalRailHarness()) + await flush() + + expect(host.querySelector("[data-alpha-terminal-empty]")).not.toBeNull() + expect( + host.querySelector("[data-alpha-terminal-empty] [data-alpha-terminal-new]")?.disabled, + ).toBe(true) + + // 引擎就绪后无需重挂:同一面板切换到实例形态。 + runtime.setTerminalChannelReady(true) + await flush() + expect(host.querySelector("[data-alpha-terminal-empty]")).toBeNull() + expect(host.querySelectorAll("[data-alpha-terminal-tab]")).toHaveLength(2) + }) +})
{t("alpha.terminal.emptyBody")}