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")}

+ + + } + > + {(current) => ( + <> +
+ + {(instance) => ( + + + + + )} + + +
+
+
+ + {(channel) => { + const EngineOutput = channel.EngineOutput + return ( + + {(instanceID) => } + + ) + }} + +
+ + {(status) => ( +
+ +