Skip to content

Latest commit

 

History

History
772 lines (636 loc) · 34.4 KB

ARCHITECTURE.md

File metadata and controls

772 lines (636 loc) · 34.4 KB

Sema Code CLI — 架构设计文档

概述

Sema Code CLI 是一个基于 sema-core(事件驱动引擎)和 Ink(终端 React)构建的命令行 AI 编程助手,提供 Claude Code 风格的 REPL 交互体验。

技术栈: TypeScript + Ink 7 (React 19) + sema-core + pnpm


应用生命周期

启动流程

flowchart TD
    CLI["$ sema-code"] --> IDX["index.ts<br/>render(App, { incrementalRendering })"]
    IDX --> APP["App.tsx"]
    APP --> BOOT["useBootstrap()"]
    BOOT --> LOADING["setState('loading')<br/>显示 '正在初始化...'"]
    LOADING --> CORE["initCore(workingDir)<br/>new SemaCore({ stream, thinking, systemPrompt, ... })"]
    CORE --> CHK["getModelList()<br/>检查 ~/.sema/model.conf"]

    CHK -->|有模型| SESS["core.createSession()"]
    CHK -->|无模型| NO_MODEL["setState('ready')<br/>渲染: Welcome + /model 提示"]

    NO_MODEL --> USER_MODEL["用户输入 /model"]
    USER_MODEL --> MODEL_SCR["setScreen('models')<br/>ModelScreen 配置并保存"]
    MODEL_SCR --> BACK["handleBackFromModels()"]
    BACK --> SESS

    SESS --> POOL["SessionPool.createSession()"]
    POOL --> SESS_CREATE["SemaSession.create()"]
    SESS_CREATE --> ENGINE_INIT["SemaEngine.createSession()"]
    ENGINE_INIT --> HIST["加载历史消息<br/>~/.sema/history/"]
    HIST --> PROMPT["构建 system prompt"]
    PROMPT --> MODEL_CHK["检查模型配置"]
    MODEL_CHK --> READY["emit('session:ready')<br/>{ usage, sessionId, ... }"]
    READY --> CHAT["setSession() + setState('ready')<br/>渲染 ChatScreen"]
Loading

一次完整对话的数据流

sequenceDiagram
    actor User
    participant IA as InputArea
    participant CS as ChatScreen
    participant SM as SemaEngine
    participant LLM as AI 模型
    participant UM as useMessages
    participant UStr as useStreaming
    participant UP as usePermissions
    participant UUse as useUsage

    User->>IA: 输入消息 按 Enter
    IA->>CS: onSubmit(input)

    alt /model 命令
        CS->>CS: setScreen('models')
    else /clear 命令
        CS->>SM: processUserInput('/clear')
    else 普通输入
        CS->>SM: processUserInput(input)
        SM->>SM: state='idle' → startQuery()

        loop ReAct 循环
            SM->>LLM: POST /chat (含 tools 定义)
            LLM-->>SM: thinking chunks
            SM-->>UStr: message:thinking:chunk
            UStr->>UStr: 32ms 节流缓冲

            LLM-->>SM: text chunks
            SM-->>UStr: message:text:chunk
            UStr->>UStr: 32ms 节流缓冲

            LLM-->>SM: tool_call
            SM-->>UM: tool:execution:start
            UM->>UM: 追加 ToolCallCard

            SM-->>UP: tool:permission:request
            UP->>CS: setState → PermissionConfirm
            CS-->>User: (y) agree (a) allow (n) refuse
            User->>CS: 按键 y
            CS->>SM: respondToToolPermission('agree')
            SM->>SM: 执行工具
            SM-->>UM: tool:execution:chunk
            SM-->>UM: tool:execution:complete
        end

        SM-->>UM: message:complete
        UM->>UM: 追加 AssistantMessage
        SM-->>UUse: conversation:usage
        UUse->>UUse: 更新 token 计数
        SM-->>CS: state:update (idle)
        CS->>IA: 显示 "> _"
    end
Loading

中断处理

sequenceDiagram
    actor User
    participant IA as InputArea
    participant CS as ChatScreen
    participant SM as SemaEngine
    participant LLM as AI 模型

    User->>IA: 按 Esc
    IA->>CS: onInterrupt()
    CS->>SM: session.interrupt()
    SM->>SM: abortController.abort()
    SM->>LLM: 中断当前 HTTP 请求
    LLM-->>SM: 请求终止
    SM-->>CS: emit('session:interrupted')
    SM-->>CS: emit('state:update', 'idle')
    CS->>IA: 显示 "> _"
Loading

退出流程

flowchart TD
    EXIT["/exit 或 Ctrl+C"] -->|/exit| PROC_EXIT["InputArea → process.exit(0)"]
    EXIT -->|Ctrl+C| INK["Ink exitOnCtrlC<br/>触发 unmount"]
    INK --> UNMOUNT["React 组件树卸载"]

    UNMOUNT --> CLEANUP["所有 hooks useEffect cleanup 执行"]
    CLEANUP --> OFF["摘除全部事件监听<br/>useMessages · useStreaming · useUsage<br/>usePermissions · useForms · useTodos · useEvents"]

    OFF --> RESTORE["alternateScreen 恢复<br/>终端回到进入前的状态"]
    RESTORE --> FINISH["waitUntilExit() resolve<br/>进程退出 (exit code 0)"]
Loading

注意: Ctrl+C 退出时,SemaCore.dispose() 不会被调用(process.exit 直接终止进程)。sema-core 引擎在每次会话更新时自动持久化历史到 ~/.sema/history/,所以退出方式不影响数据安全。


分层架构

graph TB
    subgraph Presentation["展示层 (Ink React 组件)"]
        App["App.tsx<br/>屏幕路由"]
        CS["ChatScreen.tsx<br/>REPL 布局"]
        MS["ModelScreen.tsx<br/>模型管理"]
        Comps["14 个组件<br/>(MessageList, InputArea 等)"]
    end

    subgraph State["状态层 (React Hooks)"]
        UB["useBootstrap<br/>初始化 + 会话生命周期"]
        US["useSession<br/>state:update"]
        UM["useMessages<br/>消息数组管理"]
        UStr["useStreaming<br/>文本/思考流节流 (32ms)"]
        UUse["useUsage<br/>Token 用量"]
        UP["usePermissions<br/>权限状态"]
        UF["useForms<br/>表单状态"]
        UMod["useModels<br/>模型 CRUD"]
        UT["useTodos<br/>待办列表"]
        UEvt["useEvents<br/>compact + topic 事件"]
    end

    subgraph Engine["引擎层 (sema-core)"]
        SC["SemaCore<br/>进程级 API"]
        SS["SemaSession<br/>会话级 API"]
        RL["ReAct 循环<br/>LLM ↔ 工具"]
        Evt["EventBus<br/>事件总线"]
    end

    Presentation -->|"useState / useRef<br/>触发重渲染"| State
    State -->|"session.on('event')<br/>事件 → 状态"| Engine
    Engine -->|"session.processUserInput()<br/>session.respond*()"| State
Loading

核心原则: 三层单向依赖 — 组件只消费 Hook 暴露的状态,Hook 只订阅引擎事件,引擎不感知 UI 存在。


数据流(一次用户输入的生命周期)

sequenceDiagram
    actor 用户
    participant InputArea as InputArea
    participant ChatScreen as ChatScreen
    participant Engine as SemaSession (引擎)
    participant EventBus as EventBus
    participant Hooks as React Hooks
    participant Components as Ink 组件

    用户->>InputArea: 输入消息,按 Enter
    InputArea->>ChatScreen: onSubmit(input)
    ChatScreen->>Engine: session.processUserInput(input)

    Note over Engine: ReAct 循环:<br/>LLM 查询 → 工具执行 →<br/>权限检查 → 循环

    Engine-->>EventBus: message:text:chunk
    EventBus-->>Hooks: useMessages 缓冲 (32ms 节流)
    Hooks-->>Components: streamingText 更新

    Engine-->>EventBus: tool:execution:start
    EventBus-->>Hooks: useMessages 追加工具卡片
    Hooks-->>Components: ToolCallCard 渲染

    Engine-->>EventBus: tool:permission:request
    EventBus-->>Hooks: usePermissions 设置 pending
    Hooks-->>Components: PermissionConfirm 内联渲染
    用户->>Components: 按 y/a/n
    Components->>Engine: respondToToolPermission()
    Note over Engine: 引擎继续执行

    Engine-->>EventBus: message:complete
    EventBus-->>Hooks: useMessages 消息定稿
    Hooks-->>Components: MessageList 重渲染

    Engine-->>EventBus: state:update (idle)
    EventBus-->>Hooks: useSession 设为 idle
    Hooks-->>Components: InputArea 显示 "> _" 提示
Loading

组件树

graph TD
    App["App<br/>屏幕路由 + 初始化"]
    App -->|"screen='chat'"| ChatScreen
    App -->|"screen='models'"| ModelScreen

    ChatScreen["ChatScreen<br/>组合所有 Hook"]
    ChatScreen --> Welcome["WelcomeScreen<br/>Logo + 版本"]
    ChatScreen --> ML["MessageList<br/>消息历史"]
    ChatScreen --> SB["StatusBar<br/>模型 · 模式 · tokens"]
    ChatScreen --> IA["InputArea<br/>REPL 输入 + 自动补全"]

    ChatScreen -->|"条件渲染"| PC["PermissionConfirm<br/>内联权限确认"]
    ChatScreen -->|"条件渲染"| FI["FormInline<br/>内联表单"]
    ChatScreen -->|"条件渲染"| PE["PlanExitInline<br/>Plan 退出确认"]

    ML --> UM["UserMessage"]
    ML --> AM["AssistantMessage"]
    ML --> AM --> MR["MarkdownRenderer<br/>marked → Ink Text"]
    ML --> TC["ToolCallCard"]
    ML --> AC["AgentTaskCard"]

    ModelScreen -->|"view='add'"| SMP["SimpleModelPicker<br/>预设选择 + API Key"]
    ModelScreen -->|"view='custom'"| MF["ModelForm<br/>完整 6 字段表单"]
Loading

事件 → UI 映射

事件 消费 Hook UI 效果
state:update useSession idle ↔ processing 状态切换
session:error useSession 错误信息横幅
message:text:chunk useMessages 流式文本追加(32ms 节流)
message:complete useMessages 消息定稿,清空流式缓冲
message:thinking:chunk useMessages 思考过程展示(灰色)
tool:permission:request usePermissions 内联权限确认
tool:execution:start useMessages 工具卡片 "执行中..."
tool:execution:chunk useMessages 流式工具输出
tool:execution:complete useMessages 工具卡片 "完成"
tool:execution:error useMessages 工具卡片 "错误"(红色)
task:agent:start/end useMessages 子 Agent 卡片
todos:update useTodos 待办列表渲染
pick:option:request useForms FormInline(单选/多选/文本)
plan:exit:request useForms PlanExitInline
conversation:usage useUsage StatusBar token 计数
compact:exec useEvents 压缩通知(token 前后对比)
topic:update useEvents 会话标题更新
session:ready useUsage 初始 token 用量
session:interrupted useSession / ChatScreen InputArea 显示 "> _"

模块依赖关系

下图展示了每个源文件之间的 import 引用关系、props 传递方向和事件流向。

graph TB
    subgraph Engine["sema-core (外部 npm 包)"]
        SC["SemaCore<br/>进程级: 模型/会话/MCP"]
        SS["SemaSession<br/>会话级: processUserInput / EventBus"]
    end

    subgraph Entry["入口层"]
        IDX["index.ts<br/>Ink render() 挂载 App"]
        CORE["core/initCore.ts<br/>SemaCore 工厂 / initCore()"]
    end

    subgraph AppLayer["App 层"]
        APP["app.tsx<br/>屏幕路由 · 初始化 · 创建会话"]
    end

    subgraph Screens["Screens 层"]
        CHAT["ChatScreen.tsx<br/>聊天主屏 · 组合 hooks + 组件"]
        MODEL["ModelScreen.tsx<br/>模型配置 · list/add/custom/edit"]
    end

    subgraph Hooks["Hooks 桥接层"]
        US["useSession<br/>state:update"]
        UM["useMessages<br/>message:*:chunk<br/>tool:execution:*"]
        UP["usePermissions<br/>tool:permission:request"]
        UF["useForms<br/>pick:option:request<br/>plan:exit:request"]
        UMod["useModels<br/>模型 CRUD API 调用"]
        UT["useTodos<br/>todos:update"]
    end

    subgraph ChatComps["ChatScreen 子组件"]
        WS["WelcomeScreen"]
        ML["MessageList"]
        SB["StatusBar"]
        IA["InputArea"]
        PC["PermissionConfirm"]
        FI["FormInline"]
        PE["PlanExitInline"]
    end

    subgraph MLComps["MessageList 子组件"]
        UserM["UserMessage"]
        AsstM["AssistantMessage"]
        TC["ToolCallCard"]
        AC["AgentTaskCard"]
        MD["render/markdown.tsx<br/>marked → Ink Text"]
    end

    subgraph ModelComps["ModelScreen 子组件"]
        SMP["SimpleModelPicker"]
        MF["ModelForm"]
    end

    CORE -->|"import SemaCore"| SC
    APP -->|"import { initCore }"| CORE
    IDX -->|"import App"| APP
    APP -->|"core.createSession()"| SS
    APP -->|"props: session"| CHAT
    APP -->|"props: core"| MODEL
    CHAT -->|"import hooks"| US
    CHAT -->|"import hooks"| UM
    CHAT -->|"import hooks"| UP
    CHAT -->|"import hooks"| UF
    CHAT -->|"import hooks"| UT
    MODEL -->|"import hook"| UMod
    US -.->|"session.on('state:update')"| SS
    UM -.->|"session.on('message:*')"| SS
    UP -.->|"session.on('tool:permission:')"| SS
    UF -.->|"session.on('pick:/plan:')"| SS
    UT -.->|"session.on('todos:update')"| SS
    UMod -->|"core.addModel() 等"| SC
    CHAT -->|"组合渲染"| WS
    CHAT -->|"组合渲染"| ML
    CHAT -->|"组合渲染"| SB
    CHAT -->|"组合渲染"| IA
    CHAT -->|"条件渲染"| PC
    CHAT -->|"条件渲染"| FI
    CHAT -->|"条件渲染"| PE
    ML -->|"import"| UserM
    ML -->|"import"| AsstM
    ML -->|"import"| TC
    ML -->|"import"| AC
    AsstM -->|"import renderMarkdown"| MD
    MODEL -->|"组合渲染"| SMP
    MODEL -->|"组合渲染"| MF
    SMP -.->|"onSave/onTest 回调"| UMod
    MF -.->|"onSave/onTest 回调"| UMod
Loading

图例:

  • 实线箭头 --> = import 引用 / 组合关系
  • 虚线箭头 -.-> = 事件订阅 / 回调传递

数据流(一次完整对话的生命周期)

sequenceDiagram
    actor User as 用户
    participant IA as InputArea
    participant CS as ChatScreen
    participant UM as useMessages
    participant US as useSession
    participant UP as usePermissions
    participant UF as useForms
    participant SS as SemaSession
    participant SC as SemaCore
    participant LLM as AI 模型

    User->>IA: 输入 "帮我修复 bug"
    IA->>CS: onSubmit(input)
    CS->>SS: session.processUserInput(input)

    SS->>SS: 加载上下文、systemPrompt、customRules

    loop ReAct 循环
        SS->>LLM: POST /chat (含 tools 定义)
        LLM-->>SS: thinking chunks
        SS-->>UM: message:thinking:chunk
        UM->>UM: 32ms 节流缓冲 → setState

        LLM-->>SS: text chunks
        SS-->>UM: message:text:chunk
        UM->>UM: 32ms 节流缓冲 → setState

        LLM-->>SS: tool_call
        SS-->>UM: tool:execution:start
        UM->>UM: 追加 ToolCallCard (running)

        SS-->>UP: tool:permission:request
        UP->>CS: setState → PermissionConfirm 渲染
        CS-->>User: 显示 "(y) agree (a) allow (n) refuse"
        User->>CS: 按键 y
        CS->>SS: respondToToolPermission('agree')

        SS->>SS: 执行工具
        SS-->>UM: tool:execution:chunk
        SS-->>UM: tool:execution:complete
        UM->>UM: 更新 ToolCallCard (complete)
    end

    SS-->>UM: message:complete
    UM->>UM: flush buffer → 定稿 AssistantMessage
    SS-->>US: state:update (idle)
    US->>CS: setState → InputArea 显示 "> _"
    SS-->>UM: conversation:usage
    UM->>CS: setState → StatusBar token 更新
Loading

屏幕路由

stateDiagram-v2
    [*] --> 初始化: 应用启动
    初始化 --> 加载中: SemaCore 已创建
    加载中 --> 就绪: 初始化完成

    就绪 --> 聊天: 有模型 → 创建会话
    就绪 --> 聊天无模型: 无模型 → 等待 /model

    聊天 --> 模型配置: /model 命令
    聊天无模型 --> 模型配置: /model 命令

    模型配置 --> 聊天: Esc (已有模型 → 创建会话)
    模型配置 --> 聊天无模型: Esc (仍无模型)

    聊天 --> [*]: /exit 或 Ctrl+C
    聊天无模型 --> [*]: /exit 或 Ctrl+C
Loading

核心规则: 应用绝不会自动跳转到模型配置页。无模型时,聊天界面正常渲染并提示输入 /model。用户必须主动输入 /model 来配置模型。


模型配置流程

flowchart TD
    A["按 a 添加模型"] --> B["SimpleModelPicker<br/>预设选择器"]
    B --> C{"选择预设?"}
    C -->|"DeepSeek V4"| D1["输入 API Key"]
    C -->|"GLM-4 Plus / Flash / Air"| D2["输入 API Key"]
    C -->|"自定义模型..."| E["ModelForm<br/>完整 6 字段手动配置"]
    D1 --> F["测试连接"]
    D2 --> F
    E --> F
    F -->|"成功"| G["保存 + 创建会话"]
    F -->|"失败"| H["显示错误,可重试"]
Loading

预设模型预填了所有技术字段(Base URL、最大输出、上下文长度、适配器),用户只需提供 API Key 即可。


状态管理原则

  1. Hook 持有所有状态 — 组件通过 props 接收状态,不自行管理
  2. 事件 → 状态是单向的 — session.on('event', data => setState(transform(data)))
  3. 用户操作 → 引擎是单向的 — 组件调用 session.processUserInput() / session.respond*(),引擎通过事件返回结果
  4. 流式输出用 ref + 节流 — useRef 缓冲累积 chunk,setTimeout(32ms) 批量 flush 到 state,避免数千次渲染
  5. 历史消息避免重渲染 — 已完成的消息用带 key 的 <Box>,仅最新的 streaming 消息高频更新

关键设计决策

关注点 决策 原因
终端输入 基于 useInput 自定义实现 需要斜杠命令着色 + 自动补全下拉
权限交互 内联(非弹窗遮罩) Claude Code 风格 — (y) agree (a) always allow (n) refuse 在对话流中渲染
表单交互 内联(非弹窗遮罩) pick:option:request 的 radio/checkbox/text 直接在对话流中渲染
流式节流 32ms ref+buffer 防止快速 token 生成时每秒数千次渲染
历史优化 spacer + flexGrow={1} 顶部弹性空白区吸收高度变化,消息自然排列
增量渲染 incrementalRendering: true Ink 7 特性,只重绘变化行而非整个终端
独立屏幕 alternateScreen: true Ink 7 alt screen buffer,退出时恢复原终端
状态栏 单行 model · mode · tokens · state 无水平分隔线,最小占用
品牌色 #CD4B1A(橙色) Claude Code 品牌参考
模型持久化 ~/.sema/model.conf 由 sema-core 管理 无需额外存储层
首次体验 聊天界面 + /model 提示 不强制跳转配置页;用户自然发现 /model 命令
斜杠命令 自动补全下拉 + 青色着色 /model、/clear、/exit 支持 ↑↓ 选择
Hook 拆分 useStreaming / useUsage 独立 从 useMessages 拆分,职责更清晰
视图输入 各视图独立 useInput({ isActive }) ModelScreen 消除巨型 switch

屏幕布局设计

ChatScreen — 聊天主界面

┌─────────────────────────────────────────────────┐
│  ░▒▓███████▓▒░  ... Sema Code CLI              │  ← WelcomeScreen (Logo + 版本 + 模型名 + 工作目录)
│  v0.1.0    Model: deepseek-v4-pro    Dir: /proj│
├─────────────────────────────────────────────────┤
│                                                 │
│  > 帮我修复 login.ts 中的 bug                   │  ← UserMessage (绿色)
│                                                 │
│  ● 我来分析一下这个bug...                        │  ← AssistantMessage
│    代码在 src/login.ts 中...                    │     (marked 渲染的 Markdown)
│                                                 │
│  ● ViewFile(src/login.ts) ✓                     │  ← ToolCallCard (完成态)
│─────────────────────────────────────────────────│
│  Thinking...                                    │  ← thinkingText (灰色斜体, 仅 streaming 时可见)
│    Let me check the code flow...                │
│                                                 │
│  ●                                              │  ← streamingText (仅 streaming 时可见)
│    I found the issue. The problem is that...█   │
│─────────────────────────────────────────────────│
│  > _                                            │  ← InputArea (绿色 >  提示符 + 光标)
│  deepseek-v4-pro · Agent · 3.2k/128k tokens     │  ← StatusBar
└─────────────────────────────────────────────────┘

布局要点:

  • <Box flexGrow={1} /> 弹性 spacer 将内容推到底部
  • 消息自然溢出时,最旧的内容从顶部滚出
  • incrementalRendering: true 确保只有变化行被重绘,无需 Static 辅助

ChatScreen — 权限确认(内联)

┌─────────────────────────────────────────────────┐
│  ... 消息历史 ...                               │
│─────────────────────────────────────────────────│
│  ● RunShell                                     │  ← PermissionConfirm
│    Command: rm -rf node_modules                 │     内联渲染,不弹窗
│    ▸ 是否继续?                                 │
│    (y) 同意本次 (a) 始终允许 (n) 拒绝 (Esc) 取消│
│    等待选择... _                                │  ← useInput 拦截键盘
│─────────────────────────────────────────────────│
│  > _                                            │
│  model · Agent · tokens · idle                  │
└─────────────────────────────────────────────────┘

触发条件: permissionPending !== null 时条件渲染,显示在消息和状态栏之间。


ChatScreen — 问答表单(内联)

┌─────────────────────────────────────────────────┐
│  ... 消息历史 ...                               │
│─────────────────────────────────────────────────│
│  需要你确认以下配置                              │  ← FormInline (intro)
│                                                 │
│  ● 1. 视觉风格 *                                │  ← 当前焦点问题 (黄色)
│    ○ 1. 极简高级                                │     radio / checkbox / select / text
│    ● 2. Apple 风格                              │     数字键选择,Tab 切换题目
│    ○ 3. Material 风格                           │
│                                                 │
│  ○ 2. 大概多少页面?                            │  ← 非焦点问题
│    ___                                          │     (text 输入)
│                                                 │
│  Tab 切换 · Enter 提交 · Esc 取消               │
│─────────────────────────────────────────────────│
│  > _                                            │
│  model · Agent · tokens · idle                  │
└─────────────────────────────────────────────────┘

触发条件: pendingPick !== null 时条件渲染。Tab 键切换焦点题目,Enter 提交全部答案。


ModelScreen — 列表视图

┌─────────────────────────────────────────────────┐
│  模型配置 (Esc 返回)                            │
│                                                 │
│  ✓ 模型已保存                                   │  ← status 消息 (绿色)
│                                                 │
│  已配置模型:                                     │
│  ▶ ● deepseek-v4-pro[deepseek] ← 主模型         │  ← 选中行 (青色)
│    ○ glm-4-plus[zhipu]                          │
│    ○ glm-4-flash[zhipu]                         │
│                                                 │
│  a 添加 e 编辑 d 删除 Enter 切换 ↑↓ 选择 Esc 返回│  ← 操作提示
└─────────────────────────────────────────────────┘

交互: useInput({ isActive: view === 'list' }) 独立处理 — ↑↓ 移动选择,Enter 切换模型,a/e/d 快捷键。


ModelScreen — 添加模型(SimpleModelPicker)

┌─────────────────────────────────────────────────┐
│  添加模型 (Esc 返回)                            │
│                                                 │
│  选择模型:                                       │  ← Step 1: pick
│  ▶ DeepSeek V4 (deepseek)                       │
│    GLM-4 Plus (zhipu)                           │
│    GLM-4 Flash (zhipu)                          │
│    GLM-4 Air (zhipu)                            │
│    自定义模型... (手动配置全部参数)              │
│                                                 │
│  ↑↓ 选择 Enter 确认 Esc 返回                    │
│─────────────────────────────────────────────────│
│  Enter 后 ↓                                     │
│─────────────────────────────────────────────────│
│  添加模型 (Esc 返回)                            │
│                                                 │
│  模型: DeepSeek V4 (deepseek)                   │  ← Step 2: apikey
│  API Key: sk-abc123█                            │
│                                                 │
│  Enter 保存并测试 Esc 返回选择                  │
└─────────────────────────────────────────────────┘

交互: SimpleModelPicker 内部有自己的 useInput,两步骤 — 选预设 → 输 API Key → 测试 → 保存。


ModelScreen — 自定义模型(ModelForm)

┌─────────────────────────────────────────────────┐
│  添加新模型 (Esc 返回)                          │
│                                                 │
│  ● 提供商:                                       │  ← 当前编辑字段 (黄色 ●)
│    ▶ 1. deepseek  2. anthropic                 │     数字键选择
│      3. openai     4. zhipu  5. custom          │
│                                                 │
│    模型名: deepseek-v4-pro                       │  ← 非焦点字段
│    Base URL: https://api.deepseek.com/anthropic │
│    API Key: sk-●●●●●●●●                        │  ← API Key 被遮罩
│    最大输出: 32000 tokens                        │
│    上下文长度: 1000000 tokens                    │
│                                                 │
│    适配器: anthropic                             │  ← 根据提供商自动推断
│                                                 │
│  Tab 切换 · Enter 下一步/保存 · Esc 上一步/返回  │
└─────────────────────────────────────────────────┘

交互: ModelForm 内部有独立的 useInput,6 个字段逐项填写,Tab/Enter 推进,提供商标配预填 Base URL 和适配器。


ModelScreen — 编辑 API Key

┌─────────────────────────────────────────────────┐
│  更新 API Key (Esc 返回)                        │
│                                                 │
│  模型: deepseek-v4-pro[deepseek]                │
│                                                 │
│  API Key: sk-new-key█                           │  ← 直接输入
│                                                 │
│  ✓ 连接成功,保存中...                          │  ← 测试结果
│                                                 │
│  Enter 保存并测试 Esc 返回                      │
└─────────────────────────────────────────────────┘

交互: useInput({ isActive: view === 'edit' }) 处理键盘,输入 API Key → Enter 测试 → 保存 → 自动返回列表。

bin/
└── sema-code                     # CLI 入口 hashbang 脚本,npm install -g 后暴露为 sema-code 命令

scripts/
├── install.sh                    # Linux/macOS 一行安装脚本(curl | bash)
└── install.cmd                   # Windows CMD 安装脚本

src/
├── index.ts                      # 应用入口:调用 Ink render() 挂载 <App>,等待进程退出
├── app.tsx                       # 根组件:管理 screen 路由(chat/models)、初始化 SemaCore、创建会话
│
├── core/
│   └── initCore.ts               # SemaCore 单例工厂:初始化引擎配置(workingDir、stream、systemPrompt 等),提供 getModelList 辅助函数
│
├── hooks/                        # 桥接层:订阅 sema-core 事件总线,转换为 React state
│   ├── useBootstrap.ts           # 初始化状态机:创建 SemaCore → 检查模型 → 创建会话,返回全部 App 层状态
│   ├── useSession.ts             # 监听 state:update / session:interrupted,暴露 { state, error }
│   ├── useMessages.ts            # 消息数组管理:监听 input:processing / message:complete / tool:execution:* / task:agent:*,维护 messages[]
│   ├── useStreaming.ts           # 流式节流:监听 message:text:chunk / message:thinking:chunk,32ms ref+buffer 批量刷新,返回 { streamingText, thinkingText }
│   ├── useUsage.ts               # Token 用量:监听 conversation:usage / session:ready,返回 { usage }
│   ├── usePermissions.ts         # 监听 tool:permission:request,暴露 pending 请求 + respond() 回调
│   ├── useForms.ts               # 监听 pick:option:request / plan:exit:request,暴露 pendingPick + pendingPlanExit + respond 回调
│   ├── useModels.ts              # 不监听事件,直接调用 SemaCore API 做模型 CRUD:add/del/switch/testConnection
│   ├── useTodos.ts               # 监听 todos:update,维护 todo 列表
│   └── useEvents.ts              # 监听 compact:exec / topic:update,展示压缩通知和会话标题
│
├── screens/
│   ├── ChatScreen.tsx            # 聊天主屏幕:组合 WelcomeScreen + MessageList + StatusBar + InputArea + 条件渲染 PermissionConfirm/FormInline/PlanExitInline
│   └── ModelScreen.tsx           # 模型配置屏幕:list / add / custom / edit 四个子视图,单 useInput 驱动全部交互
│
├── components/
│   ├── WelcomeScreen.tsx         # 启动画面:ASCII Art Logo(橙色 #CD4B1A)+ 版本号 + 模型名 + 工作目录
│   ├── MessageList.tsx           # 消息容器:顶部 spacer(flexGrow 自动收缩)+ thinking 区域 + streaming 区域 + 历史消息列表(column 从上往下排列)
│   ├── UserMessage.tsx           # 用户消息:绿色 > content
│   ├── AssistantMessage.tsx      # AI 回复:● 前缀 + marked 渲染的 Markdown 内容
│   ├── ToolCallCard.tsx          # 工具调用卡片:显示工具名、运行状态(⏳/✓/✗)、流式输出内容
│   ├── AgentTaskCard.tsx         # 子 Agent 任务卡片:显示 Agent 类型、状态、指令
│   ├── PermissionConfirm.tsx     # 内联权限确认:(y) 同意本次 (a) 始终允许 (n) 拒绝,Esc 取消
│   ├── FormInline.tsx            # 内联表单:支持 radio/checkbox/select/text/textarea,Tab 切换题目,Enter 提交
│   ├── PlanExitInline.tsx        # 内联 Plan 退出确认:显示 plan 摘要,↑↓ 选择选项
│   ├── StatusBar.tsx             # 底部状态栏单行:modelName · Agent · tokens · processing/idle
│   ├── InputArea.tsx             # REPL 输入区:>  绿色提示符 + 斜杠命令自动补全 + 输入历史 ↑↓ + Esc 中断
│   ├── ModelForm.tsx             # 完整模型表单(6 字段逐项填写):提供商 → 模型名 → Base URL → API Key → 最大输出 → 上下文长度
│   └── SimpleModelPicker.tsx     # 简化模型配置:选择预设 → 输入 API Key → 测试连接 → 保存
│
└── render/
    └── markdown.tsx              # marked.lexer() → Ink <Text>/<Box> 元素树,支持 heading/code/list/blockquote/hr

dist/                             # TypeScript 编译输出(tsc → dist/)
package.json                      # 项目元数据 + 依赖(ink/marked/chalk/sema-core)+ 脚本 + ESM 声明
tsconfig.json                     # TypeScript 配置:ES2022、NodeNext、strict、jsx react-jsx
pnpm-lock.yaml                    # pnpm 依赖锁定文件
CLAUDE.md                         # Claude Code 项目指引:架构、UI 规则、集成要点、Git 规范
ARCHITECTURE.md                   # 本文件:完整架构设计文档
README.md                         # 项目 README:安装方式、使用说明、快捷键
.prettierrc                       # Prettier 格式化配置
.prettierignore                   # Prettier 忽略规则

斜杠命令

命令 处理位置 行为
/model ChatScreen / App 切换到模型配置页
/clear ChatScreen → session.processUserInput() 清空会话(引擎处理)
/exit InputArea process.exit(0)

命令在 InputArea 或 ChatScreen 层面拦截。当用户输入 / 时,自动弹出补全下拉,支持 ↑↓ 选择、Tab 补全,命令文字以青色渲染。


构建与运行

pnpm install          # 安装依赖
pnpm dev              # 开发模式(tsx 热重载)
pnpm build            # TypeScript 编译到 dist/
pnpm start            # 运行编译产物
pnpm tsc --noEmit     # 仅类型检查