Code Reader
首页
帮助
设计文档
首页
帮助
设计文档
  • TUI 交互设计

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 键命令说明
Ssession_share分享会话
Nsession_new新建会话
Mmodel_list选择模型
Aagent_cycle切换 Agent
Bsidebar_toggle切换侧边栏
Ttheme_list切换主题
Capp_exit退出应用

快捷键冲突处理

TUI 通过以下方式避免快捷键冲突:

  1. 上下文感知: 不同页面/组件有不同的快捷键
  2. Leader 模式: 使用两键序列
  3. 优先级: 组件级快捷键优先于全局
  4. 可配置: 用户可自定义快捷键

命令面板

功能概述

命令面板(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 // 选择回调
}

命令分类

分类命令
Sessionsessions, new, rename, share, timeline, fork, compact, export
Agentagents, models, mcps
Providerconnect
Systemthemes, help, status, exit
Promptclear, 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))
  })
}

搜索示例:

输入匹配
sessessions, session.new, session.rename
newsession.new
sharesession.share
themetheme.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
DialogAgentAgent 选择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 名称
链接httpURL

文件路径补全

// 输入 `/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()
    },
  },
])