TUI 交互设计
本文档详细介绍 OpenCode TUI 的用户交互机制,包括快捷键、命令面板、对话框系统等。
交互架构
快捷键系统
快捷键配置
TUI 使用分层快捷键系统,支持组合键和 Leader 模式。
export type KeybindConfig = {
leader: boolean // Leader 模式状态
bindings: Record<string, string[]> // 快捷键映射
}
const defaultBindings: KeybindConfig = {
leader: false,
bindings: {
// 命令面板
command_list: [
["ctrl", "x"],
["ctrl", "x"],
],
// 输入
input_submit: [["enter"]],
input_clear: [["ctrl", "u"]],
input_paste: [
["ctrl", "v"],
["ctrl", "shift", "v"],
],
// 会话
session_interrupt: [["escape"]],
session_new: [["ctrl", "n"]],
session_share: [["ctrl", "s"]],
session_rename: [["ctrl", "r"]],
// 导航
messages_page_up: [["ctrl"], ["u"]],
messages_page_down: [["ctrl"], ["d"]],
messages_first: [["ctrl"], ["g"], ["g"]],
messages_last: [["g"]],
// 历史
history_previous: [["up"]],
history_next: [["down"]],
// 侧边栏
sidebar_toggle: [["ctrl"], ["b"]],
// 主题
theme_list: [["ctrl"], ["t"]],
// Agent/Model
agent_cycle: [["ctrl"], ["a"]],
model_list: [["ctrl"], ["m"]],
// 退出
app_exit: [["ctrl"], ["c"]],
},
}
快捷键匹配
const keybind = useKeybind()
// 检查快捷键
if (keybind.match("session_interrupt", event)) {
// 执行中断
sdk.client.session.abort({ sessionID })
}
// 在组件中使用
useKeyboard((evt) => {
if (evt.ctrl && evt.name === "c") {
exit()
}
})
Leader 模式
Leader 模式允许通过两键序列执行命令,避免快捷键冲突。
Leader 模式快捷键:
| Leader 键 | 命令 | 说明 |
|---|---|---|
| S | session_share | 分享会话 |
| N | session_new | 新建会话 |
| M | model_list | 选择模型 |
| A | agent_cycle | 切换 Agent |
| B | sidebar_toggle | 切换侧边栏 |
| T | theme_list | 切换主题 |
| C | app_exit | 退出应用 |
快捷键冲突处理
TUI 通过以下方式避免快捷键冲突:
- 上下文感知: 不同页面/组件有不同的快捷键
- Leader 模式: 使用两键序列
- 优先级: 组件级快捷键优先于全局
- 可配置: 用户可自定义快捷键
命令面板
功能概述
命令面板(Command Palette)提供统一的命令执行入口,支持模糊搜索和快捷键触发。
触发方式
| 方式 | 说明 |
|---|---|
| Ctrl+X | 打开命令面板 |
/ 输入 | 触发斜杠命令补全 |
命令注册
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
slash: {
name: "sessions",
aliases: ["resume", "continue"],
},
onSelect: () => {
dialog.replace(() => <DialogSessionList />)
},
},
{
title: "New session",
value: "session.new",
keybind: "session_new",
category: "Session",
slash: {
name: "new",
aliases: ["clear"],
},
onSelect: () => {
route.navigate({ type: "home" })
dialog.clear()
},
},
])
命令结构
export type Command = {
title: string // 显示标题
value: string // 命令值
category?: string // 分类分类
keybind?: string // 关联快捷键
slash?: {
// 斜杠命令配置
name: string // 命令名
aliases?: string[] // 别名
}
suggested?: boolean // 是否建议显示
hidden?: boolean // 是否隐藏
enabled?: boolean // 是否启用
disabled?: boolean // 是否禁用
onSelect: (dialog: DialogContext) => void // 选择回调
}
命令分类
| 分类 | 命令 |
|---|---|
| Session | sessions, new, rename, share, timeline, fork, compact, export |
| Agent | agents, models, mcps |
| Provider | connect |
| System | themes, help, status, exit |
| Prompt | clear, stash, editor |
命令面板交互
模糊搜索
命令面板支持模糊搜索:
function filterCommands(query: string, commands: Command[]): Command[] {
if (!query) return commands
const tokens = query.toLowerCase().split(/\s+/)
return commands.filter((cmd) => {
const text = `${cmd.title} ${cmd.value} ${cmd.category ?? ""}`.toLowerCase()
return tokens.every((token) => text.includes(token))
})
}
搜索示例:
| 输入 | 匹配 |
|---|---|
ses | sessions, session.new, session.rename |
new | session.new |
share | session.share |
theme | theme.switch |
斜杠命令
概述
斜杠命令(Slash Commands)允许直接在 Prompt 中输入命令,无需打开命令面板。
触发方式
在 Prompt 中输入 / 后自动触发补全:
# 输入
/sess
# 补全结果
sessions # 切换会话
支持的斜杠命令
| 命令 | 别名 | 说明 |
|---|---|---|
/sessions | /resume, /continue | 切换会话 |
/new | /clear | 新建会话 |
/models | - | 选择模型 |
/agents | - | 选择 Agent |
/mcps | - | 切换 MCP |
/connect | - | 连接提供商 |
/themes | - | 切换主题 |
/timeline | - | 跳转到消息 |
/fork | - | 从消息分支 |
/compact | `/summarize`` | 压缩会话 |
/export | - | 导出会话 |
/help | - | 显示帮助 |
/status | - | 查看状态 |
/editor | - | 打开编辑器 |
/copy | - | 复制会话 |
/exit | /quit, /q | 退出应用 |
命令执行
// 在 Prompt 组件中
if (inputText.startsWith("/") && sync.data.command.some((x) => x.name === command)) {
const [command, ...firstLineArgs] = inputText.split(" ")
const args = firstLineArgs.join(" ")
sdk.client.session.command({
sessionID,
command: command.slice(1),
arguments: args,
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
})
}
对话框系统
对话框堆栈
对话框使用堆栈管理,支持多层嵌套。
export type DialogContext = {
replace(component: () => JSX.Element): void
clear(): void
push(component: () => JSX.Element): void
pop(): void
}
操作示例:
const dialog = useDialog()
// 替换当前对话框
dialog.replace(() => <DialogModel />)
// 添加新对话框(推入堆栈)
dialog.push(() => <DialogAgent />)
// 关闭当前对话框(弹出堆栈)
dialog.pop()
// 关闭所有对话框
dialog.clear()
对话框类型
| 对话框 | 用途 | 快捷键 |
|---|---|---|
| DialogCommand | 命令面板 | Ctrl+X |
| DialogModel | 模型选择 | Ctrl+M |
| DialogAgent | Agent 选择 | Ctrl+A |
| DialogSessionList | 会话列表 | Ctrl+S (在 home 中) |
| DialogStatus | 状态查看 | - |
| DialogThemeList | 主题选择 | Ctrl+T |
| DialogHelp | 帮助信息 | - |
| DialogConfirm | 确认对话框 | - |
| DialogAlert | 警告对话框 | - |
| DialogPrompt | 输入对话框 | - |
DialogSessionList
会话列表对话框,用于选择和切换会话。
export function DialogSessionList() {
const sessions = useMemo(() => sync.data.session.toSorted((a, b) => b.time.updated - a.time.updated))
return (
<dialog>
<header>Sessions</header>
<For each={sessions()}>
{(session) => (
<item onClick={() => selectSession(session.id)}>
<text>{session.title}</text>
<text fg={theme.textMuted}>{new Date(session.time.updated).toLocaleString()}</text>
</item>
)}
</For>
<footer>
<item onClick={() => createNewSession()}>
<text>+ New session</text>
</item>
</footer>
</dialog>
)
}
DialogConfirm
确认对话框,用于危险操作前的确认。
export async function showConfirm(
dialog: DialogContext,
title: string,
message: string
): Promise<boolean> {
return new Promise((resolve) => {
dialog.replace(() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => {
dialog.clear()
resolve(true)
}}
onCancel={() => {
dialog.clear()
resolve(false)
}}
/>
))
})
}
使用示例:
const confirmed = await DialogConfirm.show(dialog, "Delete Session", "Are you sure you want to delete this session?")
if (confirmed) {
sdk.client.session.delete({ sessionID })
}
DialogAlert
警告对话框,用于显示重要信息。
export async function showAlert(
dialog: DialogContext,
title: string,
message: string
): Promise<void> {
return new Promise((resolve) => {
dialog.replace(() => (
<DialogAlert
title={title}
message={message}
onDismiss={() => {
dialog.clear()
resolve()
}}
/>
))
})
}
提示系统
Toast 组件
Toast 组件用于显示临时提示信息。
export type ToastProps = {
title?: string
message: string
variant?: "success" | "error" | "warning" | "info"
duration?: number
}
显示 Toast
const toast = useToast()
// 基本用法
toast.show({
message: "操作成功",
variant: "success",
duration: 3000,
})
// 带标题
toast.show({
title: "Success",
message: "文件已保存",
variant: "success",
})
// 快捷方法
toast.success("成功")
toast.error("错误")
toast.warning("警告")
toast.info("信息")
Toast 生命周期
Toast 队列
Toast 组件支持多个消息队列显示:
const [toasts, setToasts] = createSignal<ToastItem[]>([])
function show(props: ToastProps) {
const id = Date.now()
setToasts((prev) => [...prev, { ...props, id }])
// 自动移除
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, props.duration ?? 3000)
}
自动补全
Prompt 自动补全
Prompt 组件提供智能自动补全功能。
补全类型
| 类型 | 触发 | 内容 |
|---|---|---|
| 文件路径 | / | 文件系统路径 |
| 命令 | / | 斜杠命令 |
| Agent | @ | Agent 名称 |
| 链接 | http | URL |
文件路径补全
// 输入 `/hom` -> 补全为 `/home/user/...`
// 支持相对路径和绝对路径
// 支持 Tab 键切换候选
function autocompleteFile(input: string): string[] {
const parts = input.split("/")
const path = parts.slice(0, -1).join("/") || "."
const prefix = parts[parts.length - 1] || ""
try {
const files = readdirSync(path)
return files.filter((f) => f.startsWith(prefix)).map((f) => [...parts.slice(0, -1), f].join("/"))
} catch {
return []
}
}
命令补全
function autocompleteCommand(input: string): Command[] {
const command = input.slice(1).trim() // 去掉 /
return sync.data.command.filter(
(cmd) => cmd.name.startsWith(command) || cmd.aliases?.some((a) => a.startsWith(command)),
)
}
Agent 补全
function autocompleteAgent(input: string): Agent[] {
const name = input.slice(1).trim() // 去掉 @
return sync.data.agent.filter((agent) => agent.name.startsWith(name))
}
补全交互
剪贴板集成
文本粘贴
TUI 支持系统剪贴板的文本粘贴:
onPaste={async (event) => {
const text = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
// 长文本自动摘要
if (text.length > 150) {
pasteText(text, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(text)
}}
文件粘贴
粘贴文件路径时自动读取文件内容:
onPaste={async (event) => {
const filepath = event.text.trim()
const file = Bun.file(filepath)
// 图像文件
if (file.type.startsWith("image/")) {
const content = await file.arrayBuffer()
await pasteImage({
filename: file.name,
mime: file.type,
content: Buffer.from(content).toString("base64"),
})
}
// 文本文件
if (file.type.startsWith("text/")) {
const text = await file.text()
pasteText(text, `[File: ${file.name}]`)
}
}}
图像粘贴
支持直接粘贴剪贴板中的图像:
// Windows 快捷键处理
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
}
}
选区复制
支持选区文本复制到剪贴板:
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({
message: "Copied to clipboard",
variant: "info"
}))
renderer.clearSelection()
}
}}
历史记录
Prompt 历史
保存用户输入的历史记录,支持上下键导航。
export function usePromptHistory() {
const [history, setHistory] = createSignal<PromptInfo[]>([])
return {
append(item: PromptInfo) {
setHistory((prev) => [...prev, item])
},
move(direction: number, current: string): PromptInfo | undefined {
const index = this.index()
const next = Math.max(0, Math.min(index + direction, history.length - 1))
this.index = next
return history()[next]
},
get index(): number {
return this.currentIndex
},
}
}
历史导航
// 上键导航
if (keybind.match("history_previous", e) && input.cursorOffset === 0) {
const item = history.move(-1, input.plainText)
if (item) {
input.setText(item.input)
input.cursorOffset = 0
}
}
// 下键导航
if (keybind.match("history_next", e) && input.cursorOffset === input.plainText.length) {
const item = history.move(1, input.plainText)
if (item) {
input.setText(item.input)
input.cursorOffset = input.plainText.length
}
}
历史持久化
历史记录通过 KV Context 持久化:
const STORAGE_KEY = "prompt_history"
const MAX_HISTORY = 100
function append(item: PromptInfo) {
const current = kv.get<PromptInfo[]>(STORAGE_KEY, [])
const updated = [...current.slice(-MAX_HISTORY + 1), item]
kv.set(STORAGE_KEY, updated)
}
暂存功能
Prompt Stash
暂存功能允许保存多个输入草稿,快速切换。
export function usePromptStash() {
const [stash, setStash] = createSignal<PromptInfo[]>([])
return {
push(item: PromptInfo) {
setStash((prev) => [...prev, item])
},
pop(): PromptInfo | undefined {
const item = stash().at(-1)
setStash((prev) => prev.slice(0, -1))
return item
},
list(): PromptInfo[] {
return stash()
},
}
}
暂存操作
| 命令 | 快捷键 | 说明 |
|---|---|---|
| Stash prompt | - | 保存当前输入到暂存 |
| Stash pop | - | 从暂存恢复 |
| Stash list | - | 显示暂存列表 |
使用示例
// 保存当前输入
const promptInfo = {
input: "help me write tests",
parts: [],
}
stash.push(promptInfo)
input.clear()
// 恢复暂存
const item = stash.pop()
if (item) {
input.setText(item.input)
restoreExtmarksFromParts(item.parts)
}
滚动交互
消息列表滚动
// 页面上滚
scroll.scrollBy(-scroll.height / 2)
// 页面下滚
scroll.scrollBy(scroll.height / 2)
// 滚动到顶部
scroll.scrollTo(0)
// 滚动到底部
scroll.scrollTo(scroll.scrollHeight)
// 滚动到指定消息
const child = scroll.getChildren().find((c) => c.id === messageID)
if (child) scroll.scrollBy(child.y - scroll.y - 1)
消息导航
跳转到下一个/上一个可见消息:
function findNextVisibleMessage(direction: "next" | "prev"): string | null {
const visibleMessages = scroll
.getChildren()
.filter((c) => isValidMessage(c))
.sort((a, b) => a.y - b.y)
if (direction === "next") {
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
}
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
}
自动滚动
新消息到达时自动滚动到底部:
const stickyScroll = true
const stickyStart = "bottom"
// 提交后自动滚动
onSubmit={() => {
setTimeout(() => {
scroll.scrollTo(scroll.scrollHeight)
}, 50)
}}
权限交互
Permission Prompt
当 AI 需要执行危险操作时,显示权限提示。
export function PermissionPrompt(props: { request: PermissionRequest }) {
return (
<box>
<text>{request.tool.name}</text>
<text>{JSON.stringify(request.tool.input)}</text>
<box flexDirection="row">
<item onClick={() => approve()}>✓ Approve</item>
<item onClick={() => reject()}>✗ Reject</item>
<item onClick={() => alwaysApprove()}>✓ Always approve for this tool</item>
</box>
</box>
)
}
权限响应
async function approve() {
await sdk.client.permission.reply({
requestID: request.id,
sessionID: request.sessionID,
approved: true,
})
}
async function alwaysApprove() {
await sdk.client.permission.always({
requestID: request.id,
sessionID: request.sessionID,
})
}
主题切换
主题选择
const { mode, setMode } = useTheme()
// 切换主题
setMode(mode() === "dark" ? "light" : "dark")
主题列表对话框
export function DialogThemeList() {
const themes = ["dark", "light", "dracula", "nord"]
return (
<dialog>
<header>Themes</header>
<For each={themes}>
{(theme) => (
<item onClick={() => setTheme(theme)}>
<text>{theme}</text>
</item>
)}
</For>
</dialog>
)
}
键盘导航
组件内导航
// 使用 Tab 键在组件间切换
useKeyboard((evt) => {
if (evt.name === "tab") {
focusNextComponent()
}
})
列表导航
// 上下键导航列表
useKeyboard((evt) => {
if (evt.name === "down") {
setSelectedIndex((i) => Math.min(i + 1, items.length - 1))
} else if (evt.name === "up") {
setSelectedIndex((i) => Math.max(i - 1, 0))
}
})
辅助功能
终端标题
动态更新终端窗口标题:
createEffect(() => {
if (route.data.type === "home") {
renderer.setTerminalTitle("OpenCode")
return
}
if (route.data.type === "session") {
const session = sync.session.get(route.data.sessionID)
const title = session.title.slice(0, 40)
renderer.setTerminalTitle(`OC | ${title}`)
}
})
终端挂起
支持 Ctrl+Z 挂起终端:
useKeyboard((evt) => {
if (keybind.match("terminal_suspend", evt)) {
process.once("SIGCONT", () => {
renderer.resume()
})
renderer.suspend()
process.kill(0, "SIGTSTP")
}
})
调试面板
// 切换调试覆盖层
command.register(() => [
{
title: "Toggle debug panel",
onSelect: () => {
renderer.toggleDebugOverlay()
},
},
])
// 切换控制台
command.register(() => [
{
title: "Toggle console",
onSelect: () => {
renderer.console.toggle()
},
},
])