diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46550a93..0cd0f9f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,10 +38,11 @@ jobs: run: go test ./... -covermode=atomic -coverprofile=coverage.out - name: Upload coverage to Codecov + continue-on-error: true uses: codecov/codecov-action@v5 with: files: ./coverage.out flags: unittests - fail_ci_if_error: true + fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index ce2d9e9e..df09beca 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ config.local.yaml # Local data data/ +.cache/ # Editor/IDE .idea/ diff --git a/README.md b/README.md index 037ee9e3..4398146a 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,63 @@ # NeoCode -NeoCode 是一个基于 Go 和 Bubble Tea 构建的本地 Coding Agent MVP。它把 Provider 防腐层、工具调用闭环、本地会话持久化和沉浸式瀑布流 TUI 组合在一起,用于日常代码理解、修改和终端内协作。 -## 功能特性 -- 基于防腐层的 Provider 架构,当前支持 OpenAI 兼容流式接口,并为 Anthropic、Gemini 预留了扩展位置。 -- ReAct 风格的 runtime 主循环,能够读取模型输出、执行工具调用、回灌结果并继续推理。 -- 瀑布流终端界面,支持流式 transcript、内联工具事件、Slash Command 和会话侧边栏。 -- 本地 YAML 配置、`.env` 加载与并发安全的热更新能力。 -- 本地 JSON 会话持久化,不依赖远端服务即可恢复历史上下文。 -- 文件系统高级工具:读取、写入、搜索、Glob 探测和精准替换。 +NeoCode 是一个基于 Go 和 Bubble Tea 的本地编码 Agent。它在终端中运行 ReAct 闭环,能够对话、调用工具、持久化会话,并以流式方式展示模型输出。 -## 环境要求 -- Go 1.21 及以上 -- 至少一个可用的模型 Provider API Key,例如 `OPENAI_API_KEY` +## 当前 Provider 策略 -## 快速开始 -1. 克隆仓库。 -2. 准备 Provider 的 API Key,可以通过环境变量或 NeoCode 托管的 `.env` 文件提供。 -3. 运行应用: +- 内建 provider 定义随代码版本发布。 +- `config.yaml` 不再持久化完整 `providers` 列表。 +- `config.yaml` 只保存当前选择状态和通用运行配置。 +- 运行时的 `providers` 完全来自代码内建定义。 +- API Key 只从环境变量读取,不写入 YAML。 +- 当前内建 provider 包括 `openai` 和 `gemini`。 +- `gemini` 复用 OpenAI-compatible driver,请求地址指向 Gemini 的兼容接口。 +- provider 实例自己定义 `base_url`、默认模型、可选模型列表和 `api_key_env`。 +- `base_url` 不在 TUI 中展示给用户。 +- driver 只负责协议构造与响应解析,不决定 `models`、`base_url` 或 `api_key_env`。 -```bash -go run ./cmd/neocode +这意味着: + +- 新用户启动后会自动拿到当前版本最新的内建 provider。 +- 未来代码新增 provider 时,新用户不需要修改 YAML。 +- 老配置文件中的 `providers` / `provider_overrides` 会在加载时被清理为新的最小状态格式。 + +## 配置文件 + +默认路径: +[`~/.neocode/config.yaml`](~/.neocode/config.yaml) + +当前落盘结构示例: + +```yaml +selected_provider: openai +current_model: gpt-5.4 +workdir: . +shell: powershell +max_loops: 8 +tool_timeout_sec: 20 ``` -首次启动时,NeoCode 会在当前用户主目录下创建自己的托管目录、默认配置和会话存储结构。 +其中: + +- `selected_provider` 和 `current_model` 是用户当前选择。 +- provider 的 `base_url`、`models`、`api_key_env` 和 `driver` 都由开发者在代码中预设。 +- `openai` 默认读取 `OPENAI_API_KEY`,`gemini` 默认读取 `GEMINI_API_KEY`。 +- 完整 provider 列表不落盘,用户不需要在 YAML 中维护供应商元数据。 + +## Slash Commands -## Slash Command -- `/set url `:更新当前选中 Provider 的 Base URL。 -- `/set key `:将当前 Provider 的密钥写入托管 `.env` 并立即重新加载。 -- `/model`:打开交互式模型选择列表。 +- `/provider`:打开 provider 选择器。 +- `/model`:打开当前 provider 的模型选择器。 -## 开发与验证 -在提交 Pull Request 前建议至少执行以下命令: +## 运行 ```bash -gofmt -w ./cmd ./internal -go test ./... +go run ./cmd/neocode ``` -## 架构概览 -- `internal/config`:负责 YAML 加载、`.env` 集成、默认值管理和并发安全更新。 -- `internal/provider`:将厂商特定的请求和流式响应抹平成统一领域模型。 -- `internal/runtime`:负责事件总线、ReAct loop、Provider 动态构建和会话持久化。 -- `internal/tools`:提供工具注册表以及各类具体工具实现。 -- `internal/tui`:负责终端交互体验,以及 runtime 事件到 Bubble Tea 消息的桥接。 - -## 目录结构 -```text -. -|-- cmd/neocode -|-- docs -|-- internal/app -|-- internal/config -|-- internal/provider -|-- internal/runtime -|-- internal/tools -`-- internal/tui -``` +## 开发 -## 当前状态 -NeoCode 目前聚焦于 MVP 闭环:本地对话、工具调用、Session 持久化和终端交互体验。当前版本正在继续向“高质量开源项目”标准收敛,重点补强文档、测试覆盖率和工具能力。 +```bash +gofmt -w ./cmd ./internal +go test ./... +``` diff --git a/agents.md b/agents.md new file mode 100644 index 00000000..1b227253 --- /dev/null +++ b/agents.md @@ -0,0 +1,109 @@ +# NeoCode Agent Rules + +本文件是本仓库的 AI 协作规则。任何 AI 在本项目中进行改写、续写、重构、修复、补测试或补文档时,都应优先遵守本文件。 + +## 1. 任务目标 +- 本仓库的目标是实现 `NeoCode Coding Agent MVP`。 +- 当前主链路必须始终围绕以下闭环保持可用: + `用户输入 -> Agent 推理 -> 调用工具 -> 获取结果 -> 继续推理 -> UI 展示` +- 做改动时,优先保证主链路可运行、模块边界清晰、实现可验证。 + +## 2. 最高优先级规则 +- 不要为了“可能兼容旧版本”破坏当前架构;若新设计已确定,优先直接切换到新实现。 +- 不要跨层直连;新功能默认沿 `TUI -> Runtime -> Provider / Tool Manager` 主链路设计。 +- 不要把模型厂商差异泄漏到 `runtime`、`tui` 或上层调用方。 +- 不要在 `runtime` 或 `tui` 里直接写工具执行逻辑;所有可被模型调用的能力必须进入 `internal/tools`。 +- 不要把会话状态、消息历史、工具调用记录散落到 UI;这些状态优先由 `runtime` 管理。 +- 不要把明文 API Key 写入 YAML、样例配置、测试快照或提交内容。 +- 修改 `config`、`provider`、`runtime`、`tools` 时,默认应同时评估并补充测试。 + +## 3. 代码结构与职责边界 + +### 3.1 关键目录 +- `cmd/neocode`:CLI 入口。 +- `internal/app`:应用装配与 bootstrap,负责连接 config、provider、tools、runtime、tui。 +- `internal/config`:配置模型、YAML 加载、环境变量管理、配置校验和并发安全访问。 +- `internal/provider`:provider 抽象、领域模型和各厂商适配器。 +- `internal/runtime`:ReAct 主循环、事件流、Prompt 编排、Session 持久化。 +- `internal/tools`:工具契约、注册表、参数校验和具体工具实现。 +- `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接。 +- `docs`:架构、配置、事件流、会话持久化等说明文档。 + +### 3.2 模块职责 +- `app` 只负责装配和依赖注入,不承载业务规则。 +- `config` 负责 provider 列表、当前 provider、当前 model、workdir、shell 的管理和校验。 +- `provider` 只处理模型协议差异、请求组装、响应解析、流式输出、超时与重试。 +- `runtime` 负责会话、消息上下文、tool schema 传递、tool call 识别、tool result 回灌和停止条件。 +- `tools` 负责统一的 `schema + execute + result` 协议,以及参数校验、错误包装和输出格式收敛。 +- `tui` 只消费 runtime 事件并负责展示,不直接调用 provider,不直接执行 tools。 + +## 4. AI 修改代码时的执行流程 +- 先定位改动所属模块,再检查是否会破坏职责边界。 +- 优先做最小闭环改动,避免无关重构。 +- 如果新增能力会被模型调用,先设计 `tools` 抽象,再接入 `runtime`。 +- 如果改动涉及 provider 协议,差异收敛在 `internal/provider` 内,不向外扩散特定厂商字段。 +- 如果改动涉及配置,统一经由 `ConfigManager` 或现有配置管理入口处理。 +- 如果新增目录、命令、配置项或模块职责,必须同步更新 `README.md`、`docs/` 或本文件。 + +## 5. 测试规则 +- 测试文件命名为 `*_test.go`,测试函数命名为 `TestXxx`。 +- 优先覆盖以下边界: + - 配置校验 + - provider 请求/响应转换 + - tool 参数校验 + - runtime loop 停止条件 + - 事件派发 +- 修改 `runtime` 时,重点覆盖: + - 最大轮数停止 + - tool result 回灌 + - 最终响应输出 + - 错误事件派发 +- 修改 `tools` 时,重点覆盖: + - schema 校验 + - 超时控制 + - 错误包装 + - 工作目录限制 +- 修改 `provider` 时,重点覆盖: + - 请求组装 + - tool call 解析 + - 异常响应处理 + - 认证或限流错误映射 + +## 6. 编码与实现规则 +- 遵循 Go 惯用风格。 +- 包名使用短小、全小写名词。 +- 导出标识符使用 `PascalCase`,未导出使用 `camelCase`。 +- 使用制表符缩进,尽量将单行控制在约 120 字符内。 +- 核心抽象上的导出类型、函数、接口应补充清晰注释,尤其是 `provider`、`runtime`、`tools`。 +- 避免硬编码路径、URL、模型名、超时、输出长度限制和环境差异项,优先通过配置、参数或具名常量注入。 +- 优先写清晰、可替换的实现,不要为了“未来可能需要”提前引入复杂泛化。 + +## 7. 配置与安全规则 +- 配置文件默认路径为 `~/.neocode/config.yaml`。 +- 配置中只保存环境变量名,不保存明文 API Key。 +- `selected_provider`、`current_model`、`workdir`、`shell` 必须在启动阶段完成校验,非法配置应尽早失败。 +- `filesystem` 工具默认限制在工作目录内,禁止越界访问。 +- `bash` 工具必须限制超时、输出长度,并避免交互式阻塞命令。 +- `webfetch` 工具必须限制协议范围和响应大小,防止拉取不受控内容。 +- 本地运行数据、会话数据和真实密钥不得默认入库;相关目录和配置文件应加入 `.gitignore`。 + +## 8. 文档规则 +- 修改说明性文档时,沿用目标文档当前已有的主语言。 +- 当前文档如果以中文为主,则继续使用中文;若以英文为主,则继续使用英文。 +- 仅在代码标识符、命令、路径、协议名、库名等不可避免的场景下保留英文原词。 +- 文档必须反映真实实现;如果实现和文档冲突,修代码或修文档时至少修正其中一个,不要放任失真。 + +## 9. 提交前最低检查 +- 确认改动没有破坏 `TUI / Runtime / Provider / Tools / Config` 的职责分工。 +- 确认新增能力已经接到正确层级,而不是临时跨层实现。 +- 运行必要的格式化和测试。 +- 检查 `git status`,确保没有无关文件、密钥、本地配置或临时数据混入。 + +## 10. 常用命令 +- 启动应用:`go run ./cmd/neocode` +- 编译全部包:`go build ./...` +- 运行测试:`go test ./...` +- 格式化代码:`gofmt -w ./cmd ./internal` + +## 11. 一句话原则 +- 任何 AI 在此仓库中工作时,都应优先保持主链路可用、边界清晰、配置安全、测试可验证,而不是追求无关的大改或过度设计。 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 7f105e46..68d741d3 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/dust/neo-code/internal/app" + "neo-code/internal/app" ) func main() { diff --git a/docs/config-management-detail-design.md b/docs/config-management-detail-design.md index 7d3d9701..8975eb90 100644 --- a/docs/config-management-detail-design.md +++ b/docs/config-management-detail-design.md @@ -7,25 +7,26 @@ - 向运行中的系统提供并发安全的配置读写能力 ## 核心类型 -- `Config`:顶层应用配置,包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 -- `ProviderConfig`:单个 Provider 的配置项,包括 Base URL、默认模型和 API Key 环境变量名 +- `Config`:顶层应用配置,运行时包含 Provider 列表、当前选中 Provider、当前模型、工作目录、Shell 和循环限制等信息 +- `ProviderConfig`:单个 Provider 的内建定义,包括 Base URL、默认模型、实例级模型列表和 API Key 环境变量名 - `Manager`:使用 `sync.RWMutex` 保护的配置访问器与修改器 - `Loader`:对 YAML 文件和托管 `.env` 文件的文件系统封装 ## 环境变量策略 -- YAML 只保存 `api_key_env`,不保存真实密钥。 +- YAML 不保存 `api_key_env`,只保存 `selected_provider`、`current_model` 和通用运行配置。 - `Loader.LoadEnvironment` 会尝试加载当前工作目录下的 `.env` 和 NeoCode 托管目录中的 `.env`。 - `ProviderConfig.ResolveAPIKey` 在真正发起请求前通过 `os.Getenv` 读取密钥。 ## 运行时更新 - TUI 只能通过 `ConfigManager.Update` 修改配置。 -- 修改 Base URL 时只更新当前选中 Provider,并立即持久化。 -- 修改 API Key 时写入托管 `.env`,然后重新加载环境变量并刷新配置快照。 -- 修改模型时,同时更新 `current_model` 和当前 Provider 的 `model` 字段,保持状态一致。 +- TUI 只负责切换当前 Provider 和当前模型,不直接修改 Provider 元数据。 +- `base_url`、`api_key_env`、`driver` 和 `models` 由代码内建定义提供,不从 YAML 读写。 +- 修改模型时,只更新 `current_model`;当前 Provider 的 `model` 仍表示默认模型,`models` 负责描述该实例可选模型列表。 ## 默认值治理 -- 默认 Provider 名称、URL、模型和环境变量名统一定义在 `internal/config/model.go` 中。 -- 内建模型目录也收口在 `config` 包中,避免 TUI 自己维护一套零散的临时常量。 +- 默认 Provider 名称、URL、默认模型、模型列表和环境变量名统一由内建 Provider 定义提供。 +- `Loader` 在加载旧配置时会丢弃 `providers` / `provider_overrides`,重新回到“YAML 只保存选择状态”的最小结构。 +- Provider 的可选模型目录属于实例配置,进入运行时 `Config` 后再提供给 TUI 和 runtime,避免 TUI 或 driver 自己维护一套零散常量。 ## 安全约束 - 读操作统一走 `Get`,并返回拷贝后的配置快照。 diff --git a/go.mod b/go.mod index 563ad14d..8241d472 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,37 @@ -module github.com/dust/neo-code +module neo-code -go 1.24.2 +go 1.25.0 require ( github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/joho/godotenv v1.5.1 - golang.org/x/net v0.50.0 + golang.org/x/net v0.52.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 8adf72f8..9a787c6c 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -24,10 +26,14 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -42,6 +48,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -58,6 +66,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index d7999b7b..cc4a9e49 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -6,17 +6,19 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/dust/neo-code/internal/config" - agentruntime "github.com/dust/neo-code/internal/runtime" - "github.com/dust/neo-code/internal/tools" - "github.com/dust/neo-code/internal/tools/bash" - "github.com/dust/neo-code/internal/tools/filesystem" - "github.com/dust/neo-code/internal/tools/webfetch" - "github.com/dust/neo-code/internal/tui" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" + "neo-code/internal/tools/bash" + "neo-code/internal/tools/filesystem" + "neo-code/internal/tools/webfetch" + "neo-code/internal/tui" ) func NewProgram(ctx context.Context) (*tea.Program, error) { - loader := config.NewLoader("") + loader := config.NewLoader("", builtin.DefaultConfig()) manager := config.NewManager(loader) cfg, err := manager.Load(ctx) if err != nil { @@ -25,10 +27,16 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { toolRegistry := buildToolRegistry(cfg) + providerRegistry, err := builtin.NewRegistry() + if err != nil { + return nil, err + } + providerService := provider.NewService(manager, providerRegistry) + sessionStore := agentruntime.NewSessionStore(loader.BaseDir()) - runtimeSvc := agentruntime.New(manager, toolRegistry, sessionStore) + runtimeSvc := agentruntime.NewWithFactory(manager, toolRegistry, sessionStore, providerRegistry) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc) + tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerService) if err != nil { return nil, err } diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index 5974d9ac..51dc688a 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -9,8 +9,8 @@ import ( "path/filepath" "testing" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/tools" ) func TestNewProgram(t *testing.T) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 578d824f..8f8c82d5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,6 +9,33 @@ import ( "testing" ) +const ( + testProviderName = "openai" + testBaseURL = "https://api.openai.com/v1" + testModel = "gpt-4.1" + testAPIKeyEnv = "OPENAI_API_KEY" +) + +func testDefaultProviderConfig() ProviderConfig { + return ProviderConfig{ + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, + Models: []string{testModel, "gpt-4o", "gpt-5.4", "gpt-5.3-codex"}, + APIKeyEnv: testAPIKeyEnv, + } +} + +func testDefaultConfig() *Config { + cfg := Default() + defaultProvider := testDefaultProviderConfig() + cfg.Providers = []ProviderConfig{defaultProvider} + cfg.SelectedProvider = defaultProvider.Name + cfg.CurrentModel = defaultProvider.Model + return cfg +} + func TestParseConfigFormats(t *testing.T) { t.Parallel() @@ -18,12 +45,14 @@ func TestParseConfigFormats(t *testing.T) { assert func(t *testing.T, cfg *Config) }{ { - name: "current format", + name: "current format ignores persisted provider metadata", data: ` selected_provider: openai current_model: gpt-5.4 workdir: . shell: powershell + +provider_overrides: tools: webfetch: max_response_bytes: 4096 @@ -32,7 +61,6 @@ tools: - text/plain providers: - name: openai - type: openai base_url: https://example.com/v1 model: gpt-5.4 api_key_env: OPENAI_API_KEY @@ -46,8 +74,11 @@ providers: if err != nil { t.Fatalf("selected provider: %v", err) } - if provider.BaseURL != "https://example.com/v1" { - t.Fatalf("expected custom base url, got %q", provider.BaseURL) + if provider.BaseURL != testBaseURL { + t.Fatalf("expected builtin base url %q, got %q", testBaseURL, provider.BaseURL) + } + if provider.Model != testModel { + t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) } if cfg.Tools.WebFetch.MaxResponseBytes != 4096 { t.Fatalf("expected custom max_response_bytes 4096, got %d", cfg.Tools.WebFetch.MaxResponseBytes) @@ -57,6 +88,37 @@ providers: } }, }, + { + name: "legacy persisted providers list keeps selection only", + data: ` +selected_provider: openai +current_model: gpt-5.4 +workdir: . +shell: powershell +providers: + - name: openai + type: openai + base_url: https://example.com/v1 + model: gpt-5.4 + api_key_env: OPENAI_API_KEY +`, + assert: func(t *testing.T, cfg *Config) { + t.Helper() + provider, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("selected provider: %v", err) + } + if provider.BaseURL != testBaseURL { + t.Fatalf("expected builtin base url %q, got %q", testBaseURL, provider.BaseURL) + } + if provider.Model != testModel { + t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) + } + if cfg.CurrentModel != "gpt-5.4" { + t.Fatalf("expected selected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel) + } + }, + }, { name: "legacy format", data: ` @@ -82,8 +144,14 @@ providers: if err != nil { t.Fatalf("selected provider: %v", err) } - if provider.Model != "gpt-4o" { - t.Fatalf("expected provider model gpt-4o, got %q", provider.Model) + if provider.Model != testModel { + t.Fatalf("expected builtin default model %q, got %q", testModel, provider.Model) + } + if cfg.CurrentModel != "gpt-4o" { + t.Fatalf("expected current model %q, got %q", "gpt-4o", cfg.CurrentModel) + } + if len(provider.Models) != len(testDefaultProviderConfig().Models) { + t.Fatalf("expected builtin provider models, got %+v", provider.Models) } if cfg.Tools.WebFetch.MaxResponseBytes != DefaultWebFetchMaxResponseBytes { t.Fatalf("expected default max_response_bytes %d, got %d", DefaultWebFetchMaxResponseBytes, cfg.Tools.WebFetch.MaxResponseBytes) @@ -99,11 +167,11 @@ providers: tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - cfg, err := parseConfig([]byte(tt.data)) + cfg, err := parseConfig([]byte(tt.data), *testDefaultConfig()) if err != nil { t.Fatalf("parseConfig() error = %v", err) } - cfg.ApplyDefaults() + cfg.ApplyDefaultsFrom(*testDefaultConfig()) tt.assert(t, cfg) }) } @@ -139,10 +207,10 @@ func TestProviderConfigResolveAPIKey(t *testing.T) { } provider := ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, APIKeyEnv: tt.envKey, } @@ -189,7 +257,7 @@ func TestConfigMethodErrorPaths(t *testing.T) { _, err := (ProviderConfig{ Name: "custom", - Type: "custom", + Driver: "custom", BaseURL: "https://example.com", Model: "custom-model", APIKeyEnv: "MISSING_PROVIDER_KEY", @@ -223,8 +291,8 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() - restoreEnv(t, DefaultOpenAIAPIKeyEnv) - _ = os.Unsetenv(DefaultOpenAIAPIKeyEnv) + restoreEnv(t, testAPIKeyEnv) + _ = os.Unsetenv(testAPIKeyEnv) previousWD, err := os.Getwd() if err != nil { @@ -243,7 +311,7 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { } } - loader := NewLoader(filepath.Join(tempDir, ".neocode")) + loader := NewLoader(filepath.Join(tempDir, ".neocode"), testDefaultConfig()) if tt.managedDotEnv != "" { if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { t.Fatalf("mkdir managed dir: %v", err) @@ -256,11 +324,11 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { loader.LoadEnvironment() provider := ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, - APIKeyEnv: DefaultOpenAIAPIKeyEnv, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, + APIKeyEnv: testAPIKeyEnv, } key, err := provider.ResolveAPIKey() @@ -274,36 +342,9 @@ func TestLoaderLoadEnvironmentSources(t *testing.T) { } } -func TestManagerUpsertEnvAndReload(t *testing.T) { - tempDir := t.TempDir() - loader := NewLoader(tempDir) - manager := NewManager(loader) - - if err := manager.UpsertEnv("OPENAI_API_KEY", "first"); err != nil { - t.Fatalf("UpsertEnv() error = %v", err) - } - if err := manager.UpsertEnv("OPENAI_API_KEY", "second"); err != nil { - t.Fatalf("UpsertEnv() overwrite error = %v", err) - } - if err := loader.OverloadManagedEnvironment(); err != nil { - t.Fatalf("OverloadManagedEnvironment() error = %v", err) - } - - data, err := os.ReadFile(loader.EnvPath()) - if err != nil { - t.Fatalf("ReadFile() error = %v", err) - } - if strings.Count(string(data), "OPENAI_API_KEY=") != 1 { - t.Fatalf("expected single env assignment, got %q", string(data)) - } - if got := strings.TrimSpace(os.Getenv("OPENAI_API_KEY")); got != "second" { - t.Fatalf("expected env value second, got %q", got) - } -} - func TestManagerConcurrentAccess(t *testing.T) { tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir)) + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) } @@ -329,6 +370,7 @@ func TestManagerConcurrentAccess(t *testing.T) { for k := range next.Providers { if next.Providers[k].Name == next.SelectedProvider { next.Providers[k].Model = model + next.Providers[k].Models = append([]string(nil), models...) } } return nil @@ -342,7 +384,7 @@ func TestManagerConcurrentAccess(t *testing.T) { wg.Wait() finalConfig := manager.Get() - finalConfig.ApplyDefaults() + finalConfig.ApplyDefaultsFrom(*testDefaultConfig()) if err := finalConfig.Validate(); err != nil { t.Fatalf("final config should validate, got %v", err) } @@ -354,28 +396,28 @@ func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ { - Name: ProviderOpenAI, + Name: testProviderName, }, }, - SelectedProvider: ProviderOpenAI, + SelectedProvider: testProviderName, CurrentModel: "", Workdir: ".", } - cfg.ApplyDefaults() + cfg.ApplyDefaultsFrom(*testDefaultConfig()) provider, err := cfg.SelectedProviderConfig() if err != nil { t.Fatalf("SelectedProviderConfig() error = %v", err) } - if provider.BaseURL != DefaultOpenAIBaseURL { - t.Fatalf("expected default base url %q, got %q", DefaultOpenAIBaseURL, provider.BaseURL) + if provider.BaseURL != testBaseURL { + t.Fatalf("expected default base url %q, got %q", testBaseURL, provider.BaseURL) } - if provider.APIKeyEnv != DefaultOpenAIAPIKeyEnv { - t.Fatalf("expected default api key env %q, got %q", DefaultOpenAIAPIKeyEnv, provider.APIKeyEnv) + if provider.APIKeyEnv != testAPIKeyEnv { + t.Fatalf("expected default api key env %q, got %q", testAPIKeyEnv, provider.APIKeyEnv) } - if cfg.CurrentModel != DefaultOpenAIModel { - t.Fatalf("expected current model %q, got %q", DefaultOpenAIModel, cfg.CurrentModel) + if cfg.CurrentModel != testModel { + t.Fatalf("expected current model %q, got %q", testModel, cfg.CurrentModel) } if !filepath.IsAbs(cfg.Workdir) { t.Fatalf("expected absolute workdir, got %q", cfg.Workdir) @@ -391,8 +433,8 @@ func TestConfigApplyDefaultsFillsMissingFields(t *testing.T) { func TestConfigValidateFailures(t *testing.T) { t.Parallel() - validConfig := Default().Clone() - validConfig.ApplyDefaults() + validConfig := testDefaultConfig().Clone() + validConfig.ApplyDefaultsFrom(*testDefaultConfig()) tests := []struct { name string @@ -407,8 +449,8 @@ func TestConfigValidateFailures(t *testing.T) { { name: "no providers", config: &Config{ - SelectedProvider: ProviderOpenAI, - CurrentModel: DefaultOpenAIModel, + SelectedProvider: testProviderName, + CurrentModel: testModel, Workdir: filepath.Clean(t.TempDir()), }, expectErr: "providers is empty", @@ -417,11 +459,11 @@ func TestConfigValidateFailures(t *testing.T) { name: "duplicate providers", config: &Config{ Providers: []ProviderConfig{ - {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, - {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, + testDefaultProviderConfig(), + testDefaultProviderConfig(), }, - SelectedProvider: ProviderOpenAI, - CurrentModel: DefaultOpenAIModel, + SelectedProvider: testProviderName, + CurrentModel: testModel, Workdir: filepath.Clean(t.TempDir()), }, expectErr: "duplicate provider name", @@ -430,10 +472,10 @@ func TestConfigValidateFailures(t *testing.T) { name: "relative workdir", config: &Config{ Providers: []ProviderConfig{ - {Name: ProviderOpenAI, Type: ProviderOpenAI, BaseURL: DefaultOpenAIBaseURL, Model: DefaultOpenAIModel, APIKeyEnv: DefaultOpenAIAPIKeyEnv}, + testDefaultProviderConfig(), }, - SelectedProvider: ProviderOpenAI, - CurrentModel: DefaultOpenAIModel, + SelectedProvider: testProviderName, + CurrentModel: testModel, Workdir: ".", }, expectErr: "workdir must be absolute", @@ -465,6 +507,15 @@ func TestConfigValidateFailures(t *testing.T) { }(), expectErr: "supported_content_types[0] is empty", }, + { + name: "current model unsupported by selected provider", + config: func() *Config { + cfg := validConfig.Clone() + cfg.CurrentModel = "unsupported-model" + return &cfg + }(), + expectErr: "current_model \"unsupported-model\" is not supported", + }, } for _, tt := range tests { @@ -493,39 +544,51 @@ func TestProviderConfigValidateFailures(t *testing.T) { expectErr: "provider name is empty", }, { - name: "missing type", + name: "missing driver", provider: ProviderConfig{ - Name: ProviderOpenAI, + Name: testProviderName, }, - expectErr: "type is empty", + expectErr: "driver is empty", }, { name: "missing base url", provider: ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, + Name: testProviderName, + Driver: testProviderName, }, expectErr: "base_url is empty", }, { name: "missing model", provider: ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, }, expectErr: "model is empty", }, { name: "missing api key env", provider: ProviderConfig{ - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, }, expectErr: "api_key_env is empty", }, + { + name: "default model missing from models", + provider: ProviderConfig{ + Name: testProviderName, + Driver: testProviderName, + BaseURL: testBaseURL, + Model: testModel, + Models: []string{"gpt-4o"}, + APIKeyEnv: testAPIKeyEnv, + }, + expectErr: "default model \"gpt-4.1\" is not in models", + }, } for _, tt := range tests { @@ -541,9 +604,9 @@ func TestProviderConfigValidateFailures(t *testing.T) { } func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { - t.Setenv(DefaultOpenAIAPIKeyEnv, "lookup-key") + t.Setenv(testAPIKeyEnv, "lookup-key") - manager := NewManager(NewLoader(t.TempDir())) + manager := NewManager(NewLoader(t.TempDir(), testDefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) } @@ -553,8 +616,8 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { if err != nil { t.Fatalf("ProviderByName() error = %v", err) } - if provider.Name != ProviderOpenAI { - t.Fatalf("expected provider %q, got %q", ProviderOpenAI, provider.Name) + if provider.Name != testProviderName { + t.Fatalf("expected provider %q, got %q", testProviderName, provider.Name) } resolved, err := manager.ResolvedSelectedProvider() @@ -568,7 +631,7 @@ func TestProviderLookupAndResolveSelectedProvider(t *testing.T) { func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { tempDir := t.TempDir() - loader := NewLoader(tempDir) + loader := NewLoader(tempDir, testDefaultConfig()) cfg, err := loader.Load(context.Background()) if err != nil { @@ -579,17 +642,29 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } cfg.CurrentModel = "gpt-5.4" - for i := range cfg.Providers { - if cfg.Providers[i].Name == cfg.SelectedProvider { - cfg.Providers[i].Model = "gpt-5.4" - } - } + cfg.Providers[0].Models = []string{"gpt-4.1", "gpt-5.4"} + cfg.Providers[0].BaseURL = "https://ignored.example/v1" cfg.Tools.WebFetch.MaxResponseBytes = 1024 cfg.Tools.WebFetch.SupportedContentTypes = []string{"text/html", "application/json"} if err := loader.Save(context.Background(), cfg); err != nil { t.Fatalf("Save() error = %v", err) } + data, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read config file: %v", err) + } + text := string(data) + if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { + t.Fatalf("expected persisted config to omit providers, got:\n%s", text) + } + if strings.Contains(text, "provider_overrides:") { + t.Fatalf("expected persisted config to omit provider overrides, got:\n%s", text) + } + if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { + t.Fatalf("expected persisted config to keep only selection state and common runtime settings, got:\n%s", text) + } + reloaded, err := loader.Load(context.Background()) if err != nil { t.Fatalf("Load() reload error = %v", err) @@ -597,6 +672,19 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { if reloaded.CurrentModel != "gpt-5.4" { t.Fatalf("expected current model %q, got %q", "gpt-5.4", reloaded.CurrentModel) } + provider, err := reloaded.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() reload error = %v", err) + } + if provider.Model != testModel { + t.Fatalf("expected provider default model to stay %q, got %q", testModel, provider.Model) + } + if provider.BaseURL != testBaseURL { + t.Fatalf("expected provider base url to come from builtin definition, got %q", provider.BaseURL) + } + if len(provider.Models) != len(testDefaultProviderConfig().Models) { + t.Fatalf("expected provider models to come from builtin definition, got %+v", provider.Models) + } if reloaded.Tools.WebFetch.MaxResponseBytes != 1024 { t.Fatalf("expected max_response_bytes %d, got %d", 1024, reloaded.Tools.WebFetch.MaxResponseBytes) } @@ -605,6 +693,59 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) { } } +func TestLoaderUsesUpdatedBuiltinProviderWhenUserHasNoOverride(t *testing.T) { + tempDir := t.TempDir() + + initialDefaults := testDefaultConfig() + initialDefaults.Providers[0].BaseURL = "https://old.example/v1" + initialDefaults.CurrentModel = initialDefaults.Providers[0].Model + + initialLoader := NewLoader(tempDir, initialDefaults) + if _, err := initialLoader.Load(context.Background()); err != nil { + t.Fatalf("initial Load() error = %v", err) + } + + updatedDefaults := testDefaultConfig() + updatedDefaults.Providers[0].BaseURL = "https://new.example/v1" + updatedDefaults.CurrentModel = updatedDefaults.Providers[0].Model + + updatedLoader := NewLoader(tempDir, updatedDefaults) + reloaded, err := updatedLoader.Load(context.Background()) + if err != nil { + t.Fatalf("updated Load() error = %v", err) + } + + provider, err := reloaded.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if provider.BaseURL != "https://new.example/v1" { + t.Fatalf("expected latest builtin base url, got %q", provider.BaseURL) + } +} + +func TestApplyProviderDefaultsMatchesByNameOnly(t *testing.T) { + defaults := []ProviderConfig{testDefaultProviderConfig()} + current := Config{ + Providers: []ProviderConfig{ + { + Name: "openai-alt", + Driver: testProviderName, + }, + }, + } + + current.ApplyDefaultsFrom(Config{Providers: defaults}) + + provider, err := current.ProviderByName("openai-alt") + if err != nil { + t.Fatalf("ProviderByName(openai-alt) error = %v", err) + } + if provider.BaseURL != "" || provider.Model != "" || provider.APIKeyEnv != "" || len(provider.Models) != 0 { + t.Fatalf("expected provider metadata to stay empty when only driver matches, got %+v", provider) + } +} + func TestNormalizeWorkdirAndClone(t *testing.T) { t.Parallel() @@ -663,18 +804,22 @@ func TestNormalizeWorkdirAndClone(t *testing.T) { var nilConfig *Config clonedNil := nilConfig.Clone() - clonedNil.ApplyDefaults() + clonedNil.ApplyDefaultsFrom(*testDefaultConfig()) if err := clonedNil.Validate(); err != nil { t.Fatalf("cloned nil config should validate, got %v", err) } - cfg := Default() + cfg := testDefaultConfig() cloned := cfg.Clone() cloned.CurrentModel = "modified" + cloned.Providers[0].Models[0] = "modified-model" cloned.Tools.WebFetch.SupportedContentTypes[0] = "application/json" if cfg.CurrentModel == cloned.CurrentModel { t.Fatalf("expected clone to be independent from source") } + if cfg.Providers[0].Models[0] == cloned.Providers[0].Models[0] { + t.Fatalf("expected provider models to be cloned") + } if cfg.Tools.WebFetch.SupportedContentTypes[0] == cloned.Tools.WebFetch.SupportedContentTypes[0] { t.Fatalf("expected webfetch supported content types to be cloned") } @@ -682,7 +827,7 @@ func TestNormalizeWorkdirAndClone(t *testing.T) { func TestManagerHelperMethodsAndReloads(t *testing.T) { tempDir := t.TempDir() - manager := NewManager(NewLoader(tempDir)) + manager := NewManager(NewLoader(tempDir, testDefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("Load() error = %v", err) @@ -696,26 +841,12 @@ func TestManagerHelperMethodsAndReloads(t *testing.T) { if got := manager.ConfigPath(); got != filepath.Join(tempDir, configName) { t.Fatalf("expected config path %q, got %q", filepath.Join(tempDir, configName), got) } - if got := manager.EnvPath(); got != filepath.Join(tempDir, envName) { - t.Fatalf("expected env path %q, got %q", filepath.Join(tempDir, envName), got) - } - - if err := manager.UpsertEnv(DefaultOpenAIAPIKeyEnv, "manager-key"); err != nil { - t.Fatalf("UpsertEnv() error = %v", err) - } - manager.ReloadEnvironment() - if err := manager.OverloadManagedEnvironment(); err != nil { - t.Fatalf("OverloadManagedEnvironment() error = %v", err) - } - if got := os.Getenv(DefaultOpenAIAPIKeyEnv); got != "manager-key" { - t.Fatalf("expected env value %q, got %q", "manager-key", got) - } } -func TestLoaderDefaultsAndBuiltinCatalog(t *testing.T) { +func TestLoaderDefaultsAndProviderDefaults(t *testing.T) { t.Parallel() - loader := NewLoader("") + loader := NewLoader("", testDefaultConfig()) if loader.BaseDir() == "" { t.Fatalf("expected default base dir to be set") } @@ -726,15 +857,35 @@ func TestLoaderDefaultsAndBuiltinCatalog(t *testing.T) { t.Fatalf("expected defaultBaseDir() to return a value") } - catalog := BuiltinModelCatalog() - if len(catalog) == 0 { - t.Fatalf("expected builtin model catalog to be non-empty") + defaultCfg := testDefaultConfig() + if len(defaultCfg.Providers) != 1 { + t.Fatalf("expected 1 default provider, got %d", len(defaultCfg.Providers)) } - if catalog[0].Name == "" || catalog[0].Description == "" { - t.Fatalf("expected model catalog entries to contain name and description") + if defaultCfg.Providers[0].Name != testProviderName { + t.Fatalf("expected default provider %q, got %q", testProviderName, defaultCfg.Providers[0].Name) } } +func TestConstructorsRejectMissingDependencies(t *testing.T) { + t.Run("NewLoader panics on nil defaults", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatalf("expected NewLoader to panic when defaults are nil") + } + }() + _ = NewLoader(t.TempDir(), nil) + }) + + t.Run("NewManager panics on nil loader", func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatalf("expected NewManager to panic when loader is nil") + } + }() + _ = NewManager(nil) + }) +} + func restoreEnv(t *testing.T, key string) { t.Helper() value, ok := os.LookupEnv(key) diff --git a/internal/config/loader.go b/internal/config/loader.go index adcee01d..0c40c051 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -19,14 +19,39 @@ const ( ) type Loader struct { - baseDir string + baseDir string + defaults Config } -func NewLoader(baseDir string) *Loader { +type persistedConfig struct { + SelectedProvider string `yaml:"selected_provider"` + CurrentModel string `yaml:"current_model"` + Workdir string `yaml:"workdir"` + Shell string `yaml:"shell"` + MaxLoops int `yaml:"max_loops,omitempty"` + ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Tools ToolsConfig `yaml:"tools,omitempty"` +} + +func NewLoader(baseDir string, defaults *Config) *Loader { + if defaults == nil { + panic("config: loader defaults are nil") + } + if strings.TrimSpace(baseDir) == "" { baseDir = defaultBaseDir() } - return &Loader{baseDir: baseDir} + + snapshot := defaults.Clone() + snapshot.ApplyDefaultsFrom(*Default()) + if err := snapshot.Validate(); err != nil { + panic(fmt.Sprintf("config: invalid loader defaults: %v", err)) + } + + return &Loader{ + baseDir: baseDir, + defaults: snapshot, + } } func (l *Loader) BaseDir() string { @@ -41,6 +66,10 @@ func (l *Loader) EnvPath() string { return filepath.Join(l.baseDir, envName) } +func (l *Loader) DefaultConfig() Config { + return l.defaults.Clone() +} + func (l *Loader) Load(ctx context.Context) (*Config, error) { if err := ctx.Err(); err != nil { return nil, err @@ -52,7 +81,8 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { return nil, fmt.Errorf("config: create config dir: %w", err) } if _, err := os.Stat(l.ConfigPath()); os.IsNotExist(err) { - if err := l.Save(ctx, Default()); err != nil { + defaultCfg := l.DefaultConfig() + if err := l.Save(ctx, &defaultCfg); err != nil { return nil, err } } @@ -62,14 +92,19 @@ func (l *Loader) Load(ctx context.Context) (*Config, error) { return nil, fmt.Errorf("config: read config file: %w", err) } - cfg, err := parseConfig(data) + cfg, err := parseConfig(data, l.defaults) if err != nil { return nil, fmt.Errorf("config: parse config file: %w", err) } - cfg.ApplyDefaults() + cfg.ApplyDefaultsFrom(l.defaults) if err := cfg.Validate(); err != nil { return nil, err } + if requiresConfigRewrite(data) { + if err := l.Save(ctx, cfg); err != nil { + return nil, err + } + } return cfg, nil } @@ -83,12 +118,22 @@ func (l *Loader) Save(ctx context.Context, cfg *Config) error { } snapshot := cfg.Clone() - snapshot.ApplyDefaults() + snapshot.ApplyDefaultsFrom(l.defaults) if err := snapshot.Validate(); err != nil { return err } - data, err := yaml.Marshal(&snapshot) + file := persistedConfig{ + SelectedProvider: snapshot.SelectedProvider, + CurrentModel: snapshot.CurrentModel, + Workdir: snapshot.Workdir, + Shell: snapshot.Shell, + MaxLoops: snapshot.MaxLoops, + ToolTimeoutSec: snapshot.ToolTimeoutSec, + Tools: snapshot.Tools, + } + + data, err := yaml.Marshal(&file) if err != nil { return fmt.Errorf("config: marshal config: %w", err) } @@ -109,10 +154,6 @@ func (l *Loader) LoadEnvironment() { _ = godotenv.Load(l.EnvPath()) } -func (l *Loader) OverloadManagedEnvironment() error { - return godotenv.Overload(l.EnvPath()) -} - func defaultBaseDir() string { home, err := os.UserHomeDir() if err != nil { @@ -121,22 +162,12 @@ func defaultBaseDir() string { return filepath.Join(home, dirName) } -func parseConfig(data []byte) (*Config, error) { +func parseConfig(data []byte, defaults Config) (*Config, error) { if len(bytes.TrimSpace(data)) == 0 { - return Default(), nil - } - - cfg, currentErr := parseCurrentConfig(data) - if currentErr == nil { - return cfg, nil + return &Config{}, nil } - legacy, legacyErr := parseLegacyConfig(data) - if legacyErr == nil { - return legacy, nil - } - - return nil, currentErr + return parseCurrentConfig(data, defaults) } type aliasConfig struct { @@ -144,84 +175,47 @@ type aliasConfig struct { WorkspaceRoot string `yaml:"workspace_root"` } -type legacyConfig struct { - SelectedProvider string `yaml:"selected_provider"` - CurrentModel string `yaml:"current_model"` - MaxLoop int `yaml:"max_loop"` - ToolTimeoutSec int `yaml:"tool_timeout_sec"` - WorkspaceRoot string `yaml:"workspace_root"` - Shell string `yaml:"shell"` - Providers map[string]legacyProviderConfig `yaml:"providers"` -} - -type legacyProviderConfig struct { - Type string `yaml:"type"` - BaseURL string `yaml:"base_url"` - APIKeyEnv string `yaml:"api_key_env"` - Models []string `yaml:"models"` -} - -func parseCurrentConfig(data []byte) (*Config, error) { - cfg := &Config{} - if err := yaml.Unmarshal(data, cfg); err != nil { +func parseCurrentConfig(data []byte, _ Config) (*Config, error) { + var file persistedConfig + if err := yaml.Unmarshal(data, &file); err != nil { return nil, err } var aliases aliasConfig if err := yaml.Unmarshal(data, &aliases); err == nil { - if cfg.MaxLoops == 0 && aliases.MaxLoop > 0 { - cfg.MaxLoops = aliases.MaxLoop + if file.MaxLoops == 0 && aliases.MaxLoop > 0 { + file.MaxLoops = aliases.MaxLoop } - if strings.TrimSpace(cfg.Workdir) == "" && strings.TrimSpace(aliases.WorkspaceRoot) != "" { - cfg.Workdir = aliases.WorkspaceRoot + if strings.TrimSpace(file.Workdir) == "" && strings.TrimSpace(aliases.WorkspaceRoot) != "" { + file.Workdir = aliases.WorkspaceRoot } } - return cfg, nil -} - -func parseLegacyConfig(data []byte) (*Config, error) { - var legacy legacyConfig - if err := yaml.Unmarshal(data, &legacy); err != nil { - return nil, err - } - - return convertLegacyConfig(legacy), nil -} - -func convertLegacyConfig(in legacyConfig) *Config { - out := &Config{ - SelectedProvider: strings.TrimSpace(in.SelectedProvider), - CurrentModel: strings.TrimSpace(in.CurrentModel), - Workdir: strings.TrimSpace(in.WorkspaceRoot), - Shell: strings.TrimSpace(in.Shell), - MaxLoops: in.MaxLoop, - ToolTimeoutSec: in.ToolTimeoutSec, - } - - for name, provider := range in.Providers { - model := firstNonEmpty(provider.Models...) - if strings.EqualFold(name, in.SelectedProvider) && strings.TrimSpace(in.CurrentModel) != "" { - model = strings.TrimSpace(in.CurrentModel) - } - - out.Providers = append(out.Providers, ProviderConfig{ - Name: strings.TrimSpace(name), - Type: strings.TrimSpace(provider.Type), - BaseURL: strings.TrimSpace(provider.BaseURL), - Model: strings.TrimSpace(model), - APIKeyEnv: strings.TrimSpace(provider.APIKeyEnv), - }) + cfg := &Config{ + SelectedProvider: strings.TrimSpace(file.SelectedProvider), + CurrentModel: strings.TrimSpace(file.CurrentModel), + Workdir: strings.TrimSpace(file.Workdir), + Shell: strings.TrimSpace(file.Shell), + MaxLoops: file.MaxLoops, + ToolTimeoutSec: file.ToolTimeoutSec, + Tools: file.Tools, } - return out + return cfg, nil } -func firstNonEmpty(items ...string) string { - for _, item := range items { - if strings.TrimSpace(item) != "" { - return strings.TrimSpace(item) - } +func requiresConfigRewrite(data []byte) bool { + text := strings.TrimSpace(string(data)) + switch { + case text == "": + return false + case strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:"): + return true + case strings.Contains(text, "provider_overrides:"): + return true + case strings.Contains(text, "workspace_root:") || strings.Contains(text, "max_loop:"): + return true + default: + return false } - return "" } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index af85806e..fc20a2d1 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -11,7 +11,7 @@ import ( func TestLoaderLoadMissingConfigCreatesDefault(t *testing.T) { t.Parallel() - loader := NewLoader(t.TempDir()) + loader := NewLoader(t.TempDir(), testDefaultConfig()) if _, err := os.Stat(loader.ConfigPath()); !os.IsNotExist(err) { t.Fatalf("expected config file to be missing before load, got %v", err) } @@ -31,7 +31,7 @@ func TestLoaderLoadMissingConfigCreatesDefault(t *testing.T) { func TestLoaderLoadMalformedYAML(t *testing.T) { t.Parallel() - loader := NewLoader(t.TempDir()) + loader := NewLoader(t.TempDir(), testDefaultConfig()) if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { t.Fatalf("mkdir base dir: %v", err) } @@ -47,8 +47,8 @@ func TestLoaderLoadMalformedYAML(t *testing.T) { func TestLoaderLoadEnvironmentSilentlyIgnoresDotEnvFailures(t *testing.T) { tempDir := t.TempDir() - restoreEnv(t, DefaultOpenAIAPIKeyEnv) - _ = os.Unsetenv(DefaultOpenAIAPIKeyEnv) + restoreEnv(t, testAPIKeyEnv) + _ = os.Unsetenv(testAPIKeyEnv) previousWD, err := os.Getwd() if err != nil { @@ -65,14 +65,14 @@ func TestLoaderLoadEnvironmentSilentlyIgnoresDotEnvFailures(t *testing.T) { t.Fatalf("mkdir cwd .env dir: %v", err) } - loader := NewLoader(filepath.Join(tempDir, ".neocode")) + loader := NewLoader(filepath.Join(tempDir, ".neocode"), testDefaultConfig()) if err := os.MkdirAll(loader.EnvPath(), 0o755); err != nil { t.Fatalf("mkdir managed .env dir: %v", err) } loader.LoadEnvironment() - if got := os.Getenv(DefaultOpenAIAPIKeyEnv); got != "" { + if got := os.Getenv(testAPIKeyEnv); got != "" { t.Fatalf("expected env to stay empty when dotenv loading fails, got %q", got) } } @@ -86,9 +86,70 @@ func TestLoaderLoadInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - loader := NewLoader(baseFile) + loader := NewLoader(baseFile, testDefaultConfig()) _, err := loader.Load(context.Background()) if err == nil || !strings.Contains(err.Error(), "create config dir") { t.Fatalf("expected invalid base dir error, got %v", err) } } + +func TestLoaderRewritesLegacyProvidersFormatOnLoad(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + if err := os.MkdirAll(loader.BaseDir(), 0o755); err != nil { + t.Fatalf("mkdir base dir: %v", err) + } + + legacy := ` +selected_provider: openai +current_model: gpt-5.4 +workdir: . +shell: powershell +providers: + - name: openai + type: openai + base_url: https://example.com/v1 + model: gpt-5.4 + models: + - gpt-4.1 + - gpt-5.4 + api_key_env: OPENAI_API_KEY +` + if err := os.WriteFile(loader.ConfigPath(), []byte(strings.TrimSpace(legacy)+"\n"), 0o644); err != nil { + t.Fatalf("write legacy config: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + provider, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if provider.BaseURL != testBaseURL { + t.Fatalf("expected builtin provider base url %q, got %q", testBaseURL, provider.BaseURL) + } + if cfg.CurrentModel != "gpt-5.4" { + t.Fatalf("expected current model to stay %q, got %q", "gpt-5.4", cfg.CurrentModel) + } + if len(provider.Models) != len(testDefaultProviderConfig().Models) { + t.Fatalf("expected builtin provider models, got %+v", provider.Models) + } + + rewritten, err := os.ReadFile(loader.ConfigPath()) + if err != nil { + t.Fatalf("read rewritten config: %v", err) + } + text := string(rewritten) + if strings.Contains(text, "provider_overrides:") { + t.Fatalf("expected rewritten config to drop provider overrides, got:\n%s", text) + } + if strings.Contains(text, "\nproviders:") || strings.HasPrefix(text, "providers:") { + t.Fatalf("expected rewritten config to omit providers list, got:\n%s", text) + } + if strings.Contains(text, "models:") || strings.Contains(text, "base_url:") || strings.Contains(text, "api_key_env:") { + t.Fatalf("expected rewritten config to omit provider metadata, got:\n%s", text) + } +} diff --git a/internal/config/manager.go b/internal/config/manager.go index bfb7beb0..743a9758 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -1,11 +1,8 @@ package config import ( - "bufio" "context" "errors" - "os" - "strings" "sync" ) @@ -17,12 +14,15 @@ type Manager struct { func NewManager(loader *Loader) *Manager { if loader == nil { - loader = NewLoader("") + panic("config: loader is nil") } return &Manager{ loader: loader, - config: Default(), + config: func() *Config { + cfg := loader.DefaultConfig() + return &cfg + }(), } } @@ -73,7 +73,7 @@ func (m *Manager) Update(ctx context.Context, mutate func(*Config) error) error return err } - next.ApplyDefaults() + next.ApplyDefaultsFrom(m.loader.DefaultConfig()) if err := next.Validate(); err != nil { return err } @@ -106,55 +106,3 @@ func (m *Manager) BaseDir() string { func (m *Manager) ConfigPath() string { return m.loader.ConfigPath() } - -func (m *Manager) EnvPath() string { - return m.loader.EnvPath() -} - -func (m *Manager) ReloadEnvironment() { - m.loader.LoadEnvironment() -} - -func (m *Manager) OverloadManagedEnvironment() error { - return m.loader.OverloadManagedEnvironment() -} - -func (m *Manager) UpsertEnv(key string, value string) error { - key = strings.TrimSpace(key) - if key == "" { - return errors.New("config: env key is empty") - } - - lines := []string{} - if data, err := os.ReadFile(m.EnvPath()); err == nil { - scanner := bufio.NewScanner(strings.NewReader(string(data))) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - } - - replaced := false - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - if strings.HasPrefix(trimmed, key+"=") { - lines[i] = key + "=" + value - replaced = true - } - } - if !replaced { - lines = append(lines, key+"="+value) - } - - content := strings.Join(lines, "\n") - if !strings.HasSuffix(content, "\n") { - content += "\n" - } - - if err := os.MkdirAll(m.BaseDir(), 0o755); err != nil { - return err - } - return os.WriteFile(m.EnvPath(), []byte(content), 0o644) -} diff --git a/internal/config/model.go b/internal/config/model.go index d05e398f..6f10dccd 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -11,27 +11,9 @@ import ( ) const ( - ProviderOpenAI = "openai" - ProviderAnthropic = "anthropic" - ProviderGemini = "gemini" - - DefaultOpenAIBaseURL = "https://api.openai.com/v1" - DefaultAnthropicBaseURL = "https://api.anthropic.com" - DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com" - - DefaultOpenAIModel = "gpt-4.1" - DefaultAnthropicModel = "claude-3-7-sonnet-latest" - DefaultGeminiModel = "gemini-2.5-pro" - - DefaultOpenAIAPIKeyEnv = "OPENAI_API_KEY" - DefaultAnthropicAPIKeyEnv = "ANTHROPIC_API_KEY" - DefaultGeminiAPIKeyEnv = "GEMINI_API_KEY" - - DefaultSelectedProvider = ProviderOpenAI - DefaultWorkdir = "." - DefaultMaxLoops = 8 - DefaultToolTimeoutSec = 20 - // DefaultWebFetchMaxResponseBytes bounds the amount of web content returned to the model. + DefaultWorkdir = "." + DefaultMaxLoops = 8 + DefaultToolTimeoutSec = 20 DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 ) @@ -45,7 +27,7 @@ var defaultWebFetchSupportedContentTypes = []string{ } type Config struct { - Providers []ProviderConfig `yaml:"providers"` + Providers []ProviderConfig `yaml:"-"` SelectedProvider string `yaml:"selected_provider"` CurrentModel string `yaml:"current_model"` Workdir string `yaml:"workdir"` @@ -56,11 +38,12 @@ type Config struct { } type ProviderConfig struct { - Name string `yaml:"name"` - Type string `yaml:"type"` - BaseURL string `yaml:"base_url"` - Model string `yaml:"model"` - APIKeyEnv string `yaml:"api_key_env"` + Name string `yaml:"name"` + Driver string `yaml:"type"` + BaseURL string `yaml:"base_url"` + Model string `yaml:"model"` + Models []string `yaml:"models,omitempty"` + APIKeyEnv string `yaml:"api_key_env"` } type ResolvedProviderConfig struct { @@ -68,120 +51,79 @@ type ResolvedProviderConfig struct { APIKey string `yaml:"-"` } -type ModelOption struct { - Name string - Description string -} - -// ToolsConfig stores tool-specific configuration values. type ToolsConfig struct { WebFetch WebFetchConfig `yaml:"webfetch,omitempty"` } -// WebFetchConfig controls response filtering and limits for the webfetch tool. type WebFetchConfig struct { MaxResponseBytes int64 `yaml:"max_response_bytes,omitempty"` SupportedContentTypes []string `yaml:"supported_content_types,omitempty"` } -// DefaultWebFetchSupportedContentTypes returns the default media types accepted by webfetch. func DefaultWebFetchSupportedContentTypes() []string { return append([]string(nil), defaultWebFetchSupportedContentTypes...) } func Default() *Config { return &Config{ - Providers: []ProviderConfig{ - { - Name: ProviderOpenAI, - Type: ProviderOpenAI, - BaseURL: DefaultOpenAIBaseURL, - Model: DefaultOpenAIModel, - APIKeyEnv: DefaultOpenAIAPIKeyEnv, - }, - { - Name: ProviderAnthropic, - Type: ProviderAnthropic, - BaseURL: DefaultAnthropicBaseURL, - Model: DefaultAnthropicModel, - APIKeyEnv: DefaultAnthropicAPIKeyEnv, - }, - { - Name: ProviderGemini, - Type: ProviderGemini, - BaseURL: DefaultGeminiBaseURL, - Model: DefaultGeminiModel, - APIKeyEnv: DefaultGeminiAPIKeyEnv, - }, - }, - SelectedProvider: DefaultSelectedProvider, - CurrentModel: DefaultOpenAIModel, - Workdir: DefaultWorkdir, - Shell: defaultShell(), - MaxLoops: DefaultMaxLoops, - ToolTimeoutSec: DefaultToolTimeoutSec, + Workdir: DefaultWorkdir, + Shell: defaultShell(), + MaxLoops: DefaultMaxLoops, + ToolTimeoutSec: DefaultToolTimeoutSec, Tools: ToolsConfig{ WebFetch: defaultWebFetchConfig(), }, } } -func BuiltinModelCatalog() []ModelOption { - return append([]ModelOption(nil), - ModelOption{Name: DefaultOpenAIModel, Description: "Stable OpenAI default model"}, - ModelOption{Name: "gpt-4o", Description: "Fast general-purpose OpenAI model"}, - ModelOption{Name: "gpt-5.4", Description: "Frontier reasoning and coding model"}, - ModelOption{Name: "gpt-5.3-codex", Description: "Code-focused GPT-5.3 variant"}, - ModelOption{Name: DefaultAnthropicModel, Description: "Balanced Anthropic coding model"}, - ModelOption{Name: DefaultGeminiModel, Description: "Default Gemini reasoning model"}, - ) -} - func (c *Config) Clone() Config { if c == nil { return *Default() } clone := *c - clone.Providers = append([]ProviderConfig(nil), c.Providers...) + clone.Providers = cloneProviders(c.Providers) clone.Tools = c.Tools.Clone() return clone } -func (c *Config) ApplyDefaults() { +func (c *Config) ApplyDefaultsFrom(defaults Config) { if c == nil { return } - def := Default() - if len(c.Providers) == 0 { - c.Providers = append([]ProviderConfig(nil), def.Providers...) + c.Providers = cloneProviders(defaults.Providers) } else { - c.Providers = applyProviderDefaults(c.Providers, def.Providers) + c.Providers = applyProviderDefaults(c.Providers, defaults.Providers) } if strings.TrimSpace(c.SelectedProvider) == "" { - c.SelectedProvider = def.SelectedProvider + c.SelectedProvider = defaults.SelectedProvider } if strings.TrimSpace(c.CurrentModel) == "" { if selected, err := c.SelectedProviderConfig(); err == nil { c.CurrentModel = selected.Model + } else if strings.TrimSpace(defaults.CurrentModel) != "" { + c.CurrentModel = defaults.CurrentModel } } + if selected, err := c.SelectedProviderConfig(); err == nil && !containsModelID(selected.SupportedModels(), c.CurrentModel) { + c.CurrentModel = selected.Model + } if strings.TrimSpace(c.Workdir) == "" { - c.Workdir = def.Workdir + c.Workdir = defaults.Workdir } if strings.TrimSpace(c.Shell) == "" { - c.Shell = def.Shell + c.Shell = defaults.Shell } if c.MaxLoops <= 0 { - c.MaxLoops = def.MaxLoops + c.MaxLoops = defaults.MaxLoops } if c.ToolTimeoutSec <= 0 { - c.ToolTimeoutSec = def.ToolTimeoutSec + c.ToolTimeoutSec = defaults.ToolTimeoutSec } - c.Tools.ApplyDefaults(def.Tools) + c.Tools.ApplyDefaults(defaults.Tools) c.Workdir = normalizeWorkdir(c.Workdir) } @@ -226,6 +168,9 @@ func (c *Config) Validate() error { if strings.TrimSpace(selected.Model) == "" { return fmt.Errorf("config: selected provider %q has empty model", selected.Name) } + if !containsModelID(selected.SupportedModels(), c.CurrentModel) { + return fmt.Errorf("config: current_model %q is not supported by provider %q", c.CurrentModel, selected.Name) + } if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } @@ -259,8 +204,8 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.Name) == "" { return errors.New("provider name is empty") } - if strings.TrimSpace(p.Type) == "" { - return fmt.Errorf("provider %q type is empty", p.Name) + if strings.TrimSpace(p.Driver) == "" { + return fmt.Errorf("provider %q driver is empty", p.Name) } if strings.TrimSpace(p.BaseURL) == "" { return fmt.Errorf("provider %q base_url is empty", p.Name) @@ -271,9 +216,30 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.APIKeyEnv) == "" { return fmt.Errorf("provider %q api_key_env is empty", p.Name) } + if models := normalizeModelIDs(p.Models); len(p.Models) > 0 { + if len(models) == 0 { + return fmt.Errorf("provider %q models is empty", p.Name) + } + if !containsModelID(models, p.Model) { + return fmt.Errorf("provider %q default model %q is not in models", p.Name, strings.TrimSpace(p.Model)) + } + } return nil } +func (p ProviderConfig) SupportedModels() []string { + models := normalizeModelIDs(p.Models) + if len(models) > 0 { + return models + } + + model := strings.TrimSpace(p.Model) + if model == "" { + return nil + } + return []string{model} +} + func (p ProviderConfig) ResolveAPIKey() (string, error) { envName := strings.TrimSpace(p.APIKeyEnv) if envName == "" { @@ -317,8 +283,8 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P if strings.TrimSpace(provider.Name) == "" { provider.Name = base.Name } - if strings.TrimSpace(provider.Type) == "" { - provider.Type = base.Type + if strings.TrimSpace(provider.Driver) == "" { + provider.Driver = base.Driver } if strings.TrimSpace(provider.BaseURL) == "" { provider.BaseURL = base.BaseURL @@ -326,6 +292,9 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P if strings.TrimSpace(provider.Model) == "" { provider.Model = base.Model } + if len(provider.Models) == 0 { + provider.Models = cloneModelIDs(base.Models) + } if strings.TrimSpace(provider.APIKeyEnv) == "" { provider.APIKeyEnv = base.APIKeyEnv } @@ -335,15 +304,12 @@ func mergeProviderDefaults(provider ProviderConfig, defaults []ProviderConfig) P func matchDefaultProvider(provider ProviderConfig, defaults []ProviderConfig) (ProviderConfig, bool) { name := strings.ToLower(strings.TrimSpace(provider.Name)) - kind := strings.ToLower(strings.TrimSpace(provider.Type)) - - for _, candidate := range defaults { - if name != "" && strings.ToLower(candidate.Name) == name { - return candidate, true - } + if name == "" { + return ProviderConfig{}, false } + for _, candidate := range defaults { - if kind != "" && strings.ToLower(candidate.Type) == kind { + if strings.ToLower(candidate.Name) == name { return candidate, true } } @@ -395,12 +361,12 @@ func (c ToolsConfig) Clone() ToolsConfig { } } -func (c *ToolsConfig) ApplyDefaults(def ToolsConfig) { +func (c *ToolsConfig) ApplyDefaults(defaults ToolsConfig) { if c == nil { return } - c.WebFetch.ApplyDefaults(def.WebFetch) + c.WebFetch.ApplyDefaults(defaults.WebFetch) } func (c ToolsConfig) Validate() error { @@ -416,15 +382,15 @@ func (c WebFetchConfig) Clone() WebFetchConfig { return clone } -func (c *WebFetchConfig) ApplyDefaults(def WebFetchConfig) { +func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { if c == nil { return } if c.MaxResponseBytes <= 0 { - c.MaxResponseBytes = def.MaxResponseBytes + c.MaxResponseBytes = defaults.MaxResponseBytes } - c.SupportedContentTypes = normalizeContentTypes(c.SupportedContentTypes, def.SupportedContentTypes) + c.SupportedContentTypes = normalizeContentTypes(c.SupportedContentTypes, defaults.SupportedContentTypes) } func (c WebFetchConfig) Validate() error { @@ -481,3 +447,74 @@ func normalizeContentType(value string) string { } return trimmed } + +func cloneProviders(providers []ProviderConfig) []ProviderConfig { + if len(providers) == 0 { + return nil + } + + cloned := make([]ProviderConfig, 0, len(providers)) + for _, provider := range providers { + next := provider + next.Models = cloneModelIDs(provider.Models) + cloned = append(cloned, next) + } + return cloned +} + +func cloneModelIDs(models []string) []string { + normalized := normalizeModelIDs(models) + if len(normalized) == 0 { + return nil + } + return append([]string(nil), normalized...) +} + +func normalizeModelIDs(models []string) []string { + if len(models) == 0 { + return nil + } + + normalized := make([]string, 0, len(models)) + seen := make(map[string]struct{}, len(models)) + for _, model := range models { + id := strings.TrimSpace(model) + if id == "" { + continue + } + key := strings.ToLower(id) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, id) + } + return normalized +} + +func equalModelIDs(left []string, right []string) bool { + left = normalizeModelIDs(left) + right = normalizeModelIDs(right) + if len(left) != len(right) { + return false + } + for i := range left { + if !strings.EqualFold(left[i], right[i]) { + return false + } + } + return true +} + +func containsModelID(models []string, model string) bool { + target := strings.ToLower(strings.TrimSpace(model)) + if target == "" { + return false + } + for _, candidate := range normalizeModelIDs(models) { + if strings.EqualFold(candidate, target) { + return true + } + } + return false +} diff --git a/internal/provider/anthropic/provider.go b/internal/provider/anthropic/provider.go deleted file mode 100644 index d45e0f4e..00000000 --- a/internal/provider/anthropic/provider.go +++ /dev/null @@ -1,25 +0,0 @@ -package anthropic - -import ( - "context" - "errors" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -type Provider struct { - cfg config.ProviderConfig -} - -func New(cfg config.ProviderConfig) *Provider { - return &Provider{cfg: cfg} -} - -func (p *Provider) Name() string { - return p.cfg.Name -} - -func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { - return provider.ChatResponse{}, errors.New("anthropic provider is scaffolded but not implemented in this MVP") -} diff --git a/internal/provider/anthropic/provider_test.go b/internal/provider/anthropic/provider_test.go deleted file mode 100644 index cf5ca5ca..00000000 --- a/internal/provider/anthropic/provider_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package anthropic - -import ( - "context" - "strings" - "testing" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -func TestProviderScaffold(t *testing.T) { - t.Parallel() - - cfg := config.ProviderConfig{Name: config.ProviderAnthropic, Type: config.ProviderAnthropic} - p := New(cfg) - if p.Name() != config.ProviderAnthropic { - t.Fatalf("expected provider name %q, got %q", config.ProviderAnthropic, p.Name()) - } - - _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) - if err == nil || !strings.Contains(err.Error(), "not implemented") { - t.Fatalf("expected scaffold error, got %v", err) - } -} diff --git a/internal/provider/builtin/builtin.go b/internal/provider/builtin/builtin.go new file mode 100644 index 00000000..3d4fcb61 --- /dev/null +++ b/internal/provider/builtin/builtin.go @@ -0,0 +1,39 @@ +package builtin + +import ( + "errors" + + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/gemini" + "neo-code/internal/provider/openai" + "neo-code/internal/provider/openll" +) + +func DefaultConfig() *config.Config { + cfg := config.Default() + defaultProvider := openai.BuiltinConfig() + cfg.Providers = []config.ProviderConfig{ + defaultProvider, + gemini.BuiltinConfig(), + openll.BuiltinConfig(), + } + cfg.SelectedProvider = defaultProvider.Name + cfg.CurrentModel = defaultProvider.Model + return cfg +} + +func NewRegistry() (*provider.Registry, error) { + registry := provider.NewRegistry() + if err := Register(registry); err != nil { + return nil, err + } + return registry, nil +} + +func Register(registry *provider.Registry) error { + if registry == nil { + return errors.New("builtin provider registry is nil") + } + return registry.Register(openai.Driver()) +} diff --git a/internal/provider/builtin/builtin_test.go b/internal/provider/builtin/builtin_test.go new file mode 100644 index 00000000..c047c335 --- /dev/null +++ b/internal/provider/builtin/builtin_test.go @@ -0,0 +1,33 @@ +package builtin + +import ( + "testing" + + "neo-code/internal/provider/gemini" + "neo-code/internal/provider/openai" + "neo-code/internal/provider/openll" +) + +func TestDefaultConfigIncludesBuiltinProviders(t *testing.T) { + t.Parallel() + + cfg := DefaultConfig() + if len(cfg.Providers) != 3 { + t.Fatalf("expected 3 builtin providers, got %d", len(cfg.Providers)) + } + if cfg.Providers[0].Name != openai.Name { + t.Fatalf("expected first provider %q, got %q", openai.Name, cfg.Providers[0].Name) + } + if cfg.Providers[1].Name != gemini.Name { + t.Fatalf("expected second provider %q, got %q", gemini.Name, cfg.Providers[1].Name) + } + if cfg.Providers[2].Name != openll.Name { + t.Fatalf("expected third provider %q, got %q", openll.Name, cfg.Providers[2].Name) + } + if cfg.SelectedProvider != openai.Name { + t.Fatalf("expected selected provider %q, got %q", openai.Name, cfg.SelectedProvider) + } + if cfg.CurrentModel != openai.DefaultModel { + t.Fatalf("expected current model %q, got %q", openai.DefaultModel, cfg.CurrentModel) + } +} diff --git a/internal/provider/gemini/gemini.go b/internal/provider/gemini/gemini.go new file mode 100644 index 00000000..0133e3fc --- /dev/null +++ b/internal/provider/gemini/gemini.go @@ -0,0 +1,31 @@ +package gemini + +import ( + "neo-code/internal/config" + "neo-code/internal/provider/openai" +) + +const ( + Name = "gemini" + DriverName = openai.DriverName + DefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai" + DefaultModel = "gemini-2.5-flash" + DefaultAPIKeyEnv = "GEMINI_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "gemini-2.5-pro", + "gemini-2.0-flash", +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} diff --git a/internal/provider/gemini/gemini_test.go b/internal/provider/gemini/gemini_test.go new file mode 100644 index 00000000..afab85a0 --- /dev/null +++ b/internal/provider/gemini/gemini_test.go @@ -0,0 +1,31 @@ +package gemini + +import ( + "testing" + + "neo-code/internal/provider/openai" +) + +func TestBuiltinConfigUsesOpenAICompatibleDriver(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != Name { + t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) + } + if cfg.Driver != openai.DriverName { + t.Fatalf("expected driver %q, got %q", openai.DriverName, cfg.Driver) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if cfg.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, cfg.Model) + } + if cfg.APIKeyEnv != DefaultAPIKeyEnv { + t.Fatalf("expected API key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + } + if len(cfg.Models) < 2 { + t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) + } +} diff --git a/internal/provider/gemini/provider.go b/internal/provider/gemini/provider.go deleted file mode 100644 index 90d3349e..00000000 --- a/internal/provider/gemini/provider.go +++ /dev/null @@ -1,25 +0,0 @@ -package gemini - -import ( - "context" - "errors" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -type Provider struct { - cfg config.ProviderConfig -} - -func New(cfg config.ProviderConfig) *Provider { - return &Provider{cfg: cfg} -} - -func (p *Provider) Name() string { - return p.cfg.Name -} - -func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { - return provider.ChatResponse{}, errors.New("gemini provider is scaffolded but not implemented in this MVP") -} diff --git a/internal/provider/gemini/provider_test.go b/internal/provider/gemini/provider_test.go deleted file mode 100644 index 40ff8adf..00000000 --- a/internal/provider/gemini/provider_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package gemini - -import ( - "context" - "strings" - "testing" - - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" -) - -func TestProviderScaffold(t *testing.T) { - t.Parallel() - - cfg := config.ProviderConfig{Name: config.ProviderGemini, Type: config.ProviderGemini} - p := New(cfg) - if p.Name() != config.ProviderGemini { - t.Fatalf("expected provider name %q, got %q", config.ProviderGemini, p.Name()) - } - - _, err := p.Chat(context.Background(), provider.ChatRequest{}, make(chan provider.StreamEvent)) - if err == nil || !strings.Contains(err.Error(), "not implemented") { - t.Fatalf("expected scaffold error, got %v", err) - } -} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index 07b64b28..b4028d66 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -13,19 +13,57 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/config" - domain "github.com/dust/neo-code/internal/provider" + "neo-code/internal/config" + domain "neo-code/internal/provider" ) type Provider struct { - cfg config.ProviderConfig + cfg config.ResolvedProviderConfig client *http.Client } -func New(cfg config.ProviderConfig) (*Provider, error) { +const ( + Name = "openai" + DriverName = "openai" + DefaultBaseURL = "https://api.openai.com/v1" + DefaultModel = "gpt-4.1" + DefaultAPIKeyEnv = "OPENAI_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "gpt-4o", + "gpt-5.4", + "gpt-5.3-codex", +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} + +func Driver() domain.DriverDefinition { + return domain.DriverDefinition{ + Name: Name, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { + return New(cfg) + }, + } +} + +func New(cfg config.ResolvedProviderConfig) (*Provider, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("openai provider: %w", err) } + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, errors.New("openai provider: api key is empty") + } return &Provider{ cfg: cfg, @@ -35,16 +73,7 @@ func New(cfg config.ProviderConfig) (*Provider, error) { }, nil } -func (p *Provider) Name() string { - return p.cfg.Name -} - func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { - apiKey, err := p.cfg.ResolveAPIKey() - if err != nil { - return domain.ChatResponse{}, fmt.Errorf("openai provider: resolve api key: %w", err) - } - payload, err := p.buildRequest(req) if err != nil { return domain.ChatResponse{}, err @@ -60,7 +89,7 @@ func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan if err != nil { return domain.ChatResponse{}, fmt.Errorf("openai provider: build request: %w", err) } - httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "text/event-stream") diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 33377eb3..e8fa383b 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -8,10 +8,30 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/config" - domain "github.com/dust/neo-code/internal/provider" + "neo-code/internal/config" + domain "neo-code/internal/provider" ) +func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + if strings.TrimSpace(model) == "" { + model = DefaultModel + } + + return config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: DriverName, + Driver: DriverName, + BaseURL: baseURL, + Model: model, + APIKeyEnv: DefaultAPIKeyEnv, + }, + APIKey: "test-key", + } +} + func TestMergeToolCallDeltas(t *testing.T) { t.Parallel() @@ -71,8 +91,26 @@ func TestMergeToolCallDeltas(t *testing.T) { } } +func TestBuiltinConfigIncludesProviderModels(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != DriverName { + t.Fatalf("expected provider name %q, got %q", DriverName, cfg.Name) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if len(cfg.Models) < 3 { + t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) + } + if !containsString(cfg.Models, "gpt-5.4") { + t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) + } +} + func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { - t.Setenv(config.DefaultOpenAIAPIKeyEnv, "test-key") + t.Setenv(DefaultAPIKeyEnv, "test-key") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { @@ -169,13 +207,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { })) defer server.Close() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: server.URL, - Model: "gpt-5.4", - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(server.URL, "gpt-5.4")) if err != nil { t.Fatalf("New() error = %v", err) } @@ -248,7 +280,7 @@ func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { } func TestProviderChatHTTPErrorResponses(t *testing.T) { - t.Setenv(config.DefaultOpenAIAPIKeyEnv, "test-key") + t.Setenv(DefaultAPIKeyEnv, "test-key") tests := []struct { name string @@ -281,20 +313,14 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { })) defer server.Close() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: server.URL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(server.URL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } provider.client = server.Client() _, err = provider.Chat(context.Background(), domain.ChatRequest{ - Model: config.DefaultOpenAIModel, + Model: DefaultModel, }, make(chan domain.StreamEvent, 1)) if err == nil || !strings.Contains(err.Error(), tt.expectErr) { t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) @@ -306,13 +332,7 @@ func TestProviderChatHTTPErrorResponses(t *testing.T) { func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Parallel() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -347,8 +367,8 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { t.Fatalf("buildRequest() error = %v", err) } - if payload.Model != config.DefaultOpenAIModel { - t.Fatalf("expected default model %q, got %q", config.DefaultOpenAIModel, payload.Model) + if payload.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) } if !payload.Stream { t.Fatalf("expected stream=true") @@ -373,13 +393,7 @@ func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { func TestParseErrorAndEmitTextDelta(t *testing.T) { t.Parallel() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -437,13 +451,7 @@ func TestParseErrorAndEmitTextDelta(t *testing.T) { func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { t.Parallel() - provider, err := New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, - }) + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -488,3 +496,12 @@ type readCloser struct { func (r *readCloser) Close() error { return nil } + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} diff --git a/internal/provider/openll/openll.go b/internal/provider/openll/openll.go new file mode 100644 index 00000000..7b48ac08 --- /dev/null +++ b/internal/provider/openll/openll.go @@ -0,0 +1,428 @@ +package openll + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "neo-code/internal/config" + domain "neo-code/internal/provider" +) + +type Provider struct { + cfg config.ResolvedProviderConfig + client *http.Client +} + +const ( + Name = "openll" + DriverName = "openai" + DefaultBaseURL = "https://www.openll.top/v1" + DefaultModel = "gpt-4.1" + DefaultAPIKeyEnv = "AI_API_KEY" +) + +var builtinModels = []string{ + DefaultModel, + "gpt-4o", + "gpt-5.4", + "gpt-5.3-codex", +} + +func BuiltinConfig() config.ProviderConfig { + return config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: DefaultBaseURL, + Model: DefaultModel, + Models: append([]string(nil), builtinModels...), + APIKeyEnv: DefaultAPIKeyEnv, + } +} + +func Driver() domain.DriverDefinition { + return domain.DriverDefinition{ + Name: Name, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (domain.Provider, error) { + return New(cfg) + }, + } +} + +func New(cfg config.ResolvedProviderConfig) (*Provider, error) { + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("openai provider: %w", err) + } + if strings.TrimSpace(cfg.APIKey) == "" { + return nil, errors.New("openai provider: api key is empty") + } + + return &Provider{ + cfg: cfg, + client: &http.Client{ + Timeout: 90 * time.Second, + }, + }, nil +} + +func (p *Provider) Chat(ctx context.Context, req domain.ChatRequest, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { + payload, err := p.buildRequest(req) + if err != nil { + return domain.ChatResponse{}, err + } + + body, err := json.Marshal(payload) + if err != nil { + return domain.ChatResponse{}, fmt.Errorf("openai provider: marshal request: %w", err) + } + + endpoint := strings.TrimRight(p.cfg.BaseURL, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return domain.ChatResponse{}, fmt.Errorf("openai provider: build request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := p.client.Do(httpReq) + if err != nil { + return domain.ChatResponse{}, fmt.Errorf("openai provider: send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return domain.ChatResponse{}, p.parseError(resp) + } + + return p.consumeStream(ctx, resp.Body, events) +} + +func (p *Provider) buildRequest(req domain.ChatRequest) (chatCompletionRequest, error) { + model := strings.TrimSpace(req.Model) + if model == "" { + model = strings.TrimSpace(p.cfg.Model) + } + if model == "" { + return chatCompletionRequest{}, errors.New("openai provider: model is empty") + } + + payload := chatCompletionRequest{ + Model: model, + Stream: true, + Messages: make([]openAIMessage, 0, len(req.Messages)+1), + } + + if strings.TrimSpace(req.SystemPrompt) != "" { + payload.Messages = append(payload.Messages, openAIMessage{ + Role: "system", + Content: req.SystemPrompt, + }) + } + + for _, message := range req.Messages { + payload.Messages = append(payload.Messages, toOpenAIMessage(message)) + } + + if len(req.Tools) > 0 { + payload.ToolChoice = "auto" + payload.Tools = make([]openAIToolDefinition, 0, len(req.Tools)) + for _, spec := range req.Tools { + payload.Tools = append(payload.Tools, openAIToolDefinition{ + Type: "function", + Function: openAIFunctionDefinition{ + Name: spec.Name, + Description: spec.Description, + Parameters: spec.Schema, + }, + }) + } + } + + return payload, nil +} + +func (p *Provider) consumeStream(ctx context.Context, body io.Reader, events chan<- domain.StreamEvent) (domain.ChatResponse, error) { + reader := bufio.NewReader(body) + + var ( + contentBuilder strings.Builder + finishReason string + usage domain.Usage + done bool + ) + + toolCalls := make(map[int]*domain.ToolCall) + dataLines := make([]string, 0, 4) + + flushEvent := func() error { + if len(dataLines) == 0 { + return nil + } + + payload := strings.Join(dataLines, "\n") + dataLines = dataLines[:0] + + if strings.TrimSpace(payload) == "[DONE]" { + done = true + return nil + } + + var chunk chatCompletionChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil { + return fmt.Errorf("openai provider: decode stream chunk: %w", err) + } + + if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" { + return errors.New(chunk.Error.Message) + } + + if chunk.Usage != nil { + usage = domain.Usage{ + InputTokens: chunk.Usage.PromptTokens, + OutputTokens: chunk.Usage.CompletionTokens, + TotalTokens: chunk.Usage.TotalTokens, + } + } + + for _, choice := range chunk.Choices { + if strings.TrimSpace(choice.FinishReason) != "" { + finishReason = choice.FinishReason + } + + if text := choice.Delta.Content; text != "" { + contentBuilder.WriteString(text) + if err := emitTextDelta(ctx, events, text); err != nil { + return err + } + } + + mergeToolCallDeltas(toolCalls, choice.Delta.ToolCalls) + } + + return nil + } + + for { + line, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return domain.ChatResponse{}, fmt.Errorf("openai provider: read stream: %w", err) + } + + line = strings.TrimRight(line, "\r\n") + trimmed := strings.TrimSpace(line) + + switch { + case strings.HasPrefix(trimmed, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) + case trimmed == "": + if flushErr := flushEvent(); flushErr != nil { + return domain.ChatResponse{}, flushErr + } + if done { + return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil + } + case strings.HasPrefix(trimmed, ":"): + // SSE comment/heartbeat; ignore it. + } + + if errors.Is(err, io.EOF) { + if flushErr := flushEvent(); flushErr != nil { + return domain.ChatResponse{}, flushErr + } + return finalizeResponse(contentBuilder.String(), toolCalls, finishReason, usage), nil + } + } +} + +func (p *Provider) parseError(resp *http.Response) error { + data, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("openai provider: read error response: %w", readErr) + } + + var parsed openAIErrorResponse + if err := json.Unmarshal(data, &parsed); err == nil && strings.TrimSpace(parsed.Error.Message) != "" { + return errors.New(parsed.Error.Message) + } + + bodyText := strings.TrimSpace(string(data)) + if bodyText == "" { + return errors.New(resp.Status) + } + + return fmt.Errorf("openai provider: %s", bodyText) +} + +func toOpenAIMessage(message domain.Message) openAIMessage { + out := openAIMessage{ + Role: message.Role, + Content: message.Content, + ToolCallID: message.ToolCallID, + } + + if len(message.ToolCalls) > 0 { + out.ToolCalls = make([]openAIToolCall, 0, len(message.ToolCalls)) + for _, call := range message.ToolCalls { + out.ToolCalls = append(out.ToolCalls, openAIToolCall{ + ID: call.ID, + Type: "function", + Function: openAIFunctionCall{ + Name: call.Name, + Arguments: call.Arguments, + }, + }) + } + } + + return out +} + +func emitTextDelta(ctx context.Context, events chan<- domain.StreamEvent, text string) error { + if events == nil || text == "" { + return nil + } + + select { + case events <- domain.StreamEvent{ + Type: domain.StreamEventTextDelta, + Text: text, + }: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func mergeToolCallDeltas(target map[int]*domain.ToolCall, deltas []toolCallDelta) { + for _, delta := range deltas { + call, ok := target[delta.Index] + if !ok { + call = &domain.ToolCall{} + target[delta.Index] = call + } + + if strings.TrimSpace(delta.ID) != "" { + call.ID = delta.ID + } + if strings.TrimSpace(delta.Function.Name) != "" { + call.Name = delta.Function.Name + } + if delta.Function.Arguments != "" { + call.Arguments += delta.Function.Arguments + } + } +} + +func finalizeResponse(content string, toolCalls map[int]*domain.ToolCall, finishReason string, usage domain.Usage) domain.ChatResponse { + ordered := make([]int, 0, len(toolCalls)) + for index := range toolCalls { + ordered = append(ordered, index) + } + sort.Ints(ordered) + + message := domain.Message{ + Role: "assistant", + Content: content, + } + + for _, index := range ordered { + call := toolCalls[index] + if call == nil { + continue + } + message.ToolCalls = append(message.ToolCalls, *call) + } + + if finishReason == "" && len(message.ToolCalls) > 0 { + finishReason = "tool_calls" + } + + return domain.ChatResponse{ + Message: message, + FinishReason: finishReason, + Usage: usage, + } +} + +type chatCompletionRequest struct { + Model string `json:"model"` + Messages []openAIMessage `json:"messages"` + Tools []openAIToolDefinition `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Stream bool `json:"stream"` +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []openAIToolCall `json:"tool_calls,omitempty"` +} + +type openAIToolDefinition struct { + Type string `json:"type"` + Function openAIFunctionDefinition `json:"function"` +} + +type openAIFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type openAIToolCall struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIFunctionCall `json:"function"` +} + +type openAIFunctionCall struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type chatCompletionChunk struct { + Choices []struct { + Index int `json:"index"` + Delta chunkDelta `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *openAIUsage `json:"usage,omitempty"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type chunkDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []toolCallDelta `json:"tool_calls,omitempty"` +} + +type toolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function openAIFunctionCall `json:"function"` +} + +type openAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type openAIErrorResponse struct { + Error struct { + Message string `json:"message"` + } `json:"error"` +} diff --git a/internal/provider/openll/openll_test.go b/internal/provider/openll/openll_test.go new file mode 100644 index 00000000..ac77cfbb --- /dev/null +++ b/internal/provider/openll/openll_test.go @@ -0,0 +1,513 @@ +package openll + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "neo-code/internal/config" + domain "neo-code/internal/provider" +) + +func resolvedConfig(baseURL string, model string) config.ResolvedProviderConfig { + if strings.TrimSpace(baseURL) == "" { + baseURL = DefaultBaseURL + } + if strings.TrimSpace(model) == "" { + model = DefaultModel + } + + return config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: Name, + Driver: DriverName, + BaseURL: baseURL, + Model: model, + APIKeyEnv: DefaultAPIKeyEnv, + }, + APIKey: "test-key", + } +} + +func TestMergeToolCallDeltas(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + deltas []toolCallDelta + assert func(t *testing.T, calls map[int]*domain.ToolCall) + }{ + { + name: "single tool call fragments are merged by index", + deltas: []toolCallDelta{ + {Index: 0, ID: "call_1", Function: openAIFunctionCall{Name: "filesystem_edit", Arguments: `{"path":"a.go",`}}, + {Index: 0, Function: openAIFunctionCall{Arguments: `"search_string":"old",`}}, + {Index: 0, Function: openAIFunctionCall{Arguments: `"replace_string":"new"}`}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + call := calls[0] + if call == nil { + t.Fatalf("expected call at index 0") + } + if call.Name != "filesystem_edit" { + t.Fatalf("expected name filesystem_edit, got %q", call.Name) + } + if call.Arguments != `{"path":"a.go","search_string":"old","replace_string":"new"}` { + t.Fatalf("unexpected arguments: %q", call.Arguments) + } + }, + }, + { + name: "multiple indices stay isolated", + deltas: []toolCallDelta{ + {Index: 0, ID: "call_1", Function: openAIFunctionCall{Name: "filesystem_read_file", Arguments: `{"path":"a.go"}`}}, + {Index: 1, ID: "call_2", Function: openAIFunctionCall{Name: "filesystem_write_file", Arguments: `{"path":"b.go"`}}, + {Index: 1, Function: openAIFunctionCall{Arguments: `,"content":"ok"}`}}, + }, + assert: func(t *testing.T, calls map[int]*domain.ToolCall) { + t.Helper() + if len(calls) != 2 { + t.Fatalf("expected 2 calls, got %d", len(calls)) + } + if calls[1].Arguments != `{"path":"b.go","content":"ok"}` { + t.Fatalf("unexpected second arguments: %q", calls[1].Arguments) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + calls := map[int]*domain.ToolCall{} + mergeToolCallDeltas(calls, tt.deltas) + tt.assert(t, calls) + }) + } +} + +func TestBuiltinConfigIncludesProviderModels(t *testing.T) { + t.Parallel() + + cfg := BuiltinConfig() + if cfg.Name != Name { + t.Fatalf("expected provider name %q, got %q", Name, cfg.Name) + } + if cfg.Driver != DriverName { + t.Fatalf("expected driver %q, got %q", DriverName, cfg.Driver) + } + if cfg.BaseURL != DefaultBaseURL { + t.Fatalf("expected base URL %q, got %q", DefaultBaseURL, cfg.BaseURL) + } + if cfg.APIKeyEnv != DefaultAPIKeyEnv { + t.Fatalf("expected api key env %q, got %q", DefaultAPIKeyEnv, cfg.APIKeyEnv) + } + if len(cfg.Models) < 3 { + t.Fatalf("expected builtin models to be predeclared, got %+v", cfg.Models) + } + if !containsString(cfg.Models, "gpt-5.4") { + t.Fatalf("expected builtin models to include gpt-5.4, got %+v", cfg.Models) + } +} + +func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { + t.Setenv(DefaultAPIKeyEnv, "test-key") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("unexpected auth header: %s", got) + } + + var payload chatCompletionRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload.Model != "gpt-5.4" { + t.Fatalf("expected model gpt-5.4, got %q", payload.Model) + } + if !containsToolRoleMessage(payload.Messages, "call_1", "tool finished") { + t.Fatalf("expected tool role message with tool_call_id in payload: %+v", payload.Messages) + } + + w.Header().Set("Content-Type", "text/event-stream") + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "content": "Hello ", + }, + }, + }, + }) + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "tool_calls": []map[string]any{ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "filesystem_edit", + "arguments": `{"path":"main.go",`, + }, + }, + }, + }, + }, + }, + }) + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "content": "world", + "tool_calls": []map[string]any{ + { + "index": 0, + "function": map[string]any{ + "arguments": `"search_string":"old",`, + }, + }, + }, + }, + }, + }, + }) + writeSSEChunk(t, w, map[string]any{ + "choices": []map[string]any{ + { + "index": 0, + "finish_reason": "tool_calls", + "delta": map[string]any{ + "tool_calls": []map[string]any{ + { + "index": 0, + "function": map[string]any{ + "arguments": `"replace_string":"new"}`, + }, + }, + }, + }, + }, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + }) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + + provider, err := New(resolvedConfig(server.URL, "gpt-5.4")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + provider.client = server.Client() + + events := make(chan domain.StreamEvent, 8) + response, err := provider.Chat(context.Background(), domain.ChatRequest{ + Model: "gpt-5.4", + Messages: []domain.Message{ + {Role: "user", Content: "please edit the file"}, + { + Role: "assistant", + ToolCalls: []domain.ToolCall{ + { + ID: "call_1", + Name: "filesystem_edit", + Arguments: `{"path":"main.go","search_string":"old","replace_string":"new"}`, + }, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, + }, + Tools: []domain.ToolSpec{ + { + Name: "filesystem_edit", + Description: "Edit one matching block in a file", + Schema: map[string]any{ + "type": "object", + }, + }, + }, + }, events) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if response.Message.Content != "Hello world" { + t.Fatalf("expected content %q, got %q", "Hello world", response.Message.Content) + } + if response.FinishReason != "tool_calls" { + t.Fatalf("expected finish reason tool_calls, got %q", response.FinishReason) + } + if response.Usage.TotalTokens != 15 { + t.Fatalf("expected total tokens 15, got %d", response.Usage.TotalTokens) + } + if len(response.Message.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(response.Message.ToolCalls)) + } + + call := response.Message.ToolCalls[0] + if call.ID != "call_1" || call.Name != "filesystem_edit" { + t.Fatalf("unexpected tool call: %+v", call) + } + if call.Arguments != `{"path":"main.go","search_string":"old","replace_string":"new"}` { + t.Fatalf("unexpected merged arguments: %q", call.Arguments) + } + + var chunks []string + for { + select { + case event := <-events: + chunks = append(chunks, event.Text) + default: + if strings.Join(chunks, "") != "Hello world" { + t.Fatalf("expected streamed chunks to form %q, got %q", "Hello world", strings.Join(chunks, "")) + } + return + } + } +} + +func TestProviderChatHTTPErrorResponses(t *testing.T) { + t.Setenv(DefaultAPIKeyEnv, "test-key") + + tests := []struct { + name string + status int + body string + expectErr string + }{ + { + name: "http 401 json error", + status: http.StatusUnauthorized, + body: `{"error":{"message":"invalid api key"}}`, + expectErr: "invalid api key", + }, + { + name: "http 500 empty body falls back to status", + status: http.StatusInternalServerError, + body: ``, + expectErr: "500 Internal Server Error", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + if tt.body != "" { + _, _ = w.Write([]byte(tt.body)) + } + })) + defer server.Close() + + provider, err := New(resolvedConfig(server.URL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + provider.client = server.Client() + + _, err = provider.Chat(context.Background(), domain.ChatRequest{ + Model: DefaultModel, + }, make(chan domain.StreamEvent, 1)) + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } +} + +func TestBuildRequestIncludesSystemPromptToolsAndToolMessages(t *testing.T) { + t.Parallel() + + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + payload, err := provider.buildRequest(domain.ChatRequest{ + SystemPrompt: "system prompt", + Messages: []domain.Message{ + {Role: "user", Content: "hello"}, + { + Role: "assistant", + ToolCalls: []domain.ToolCall{ + { + ID: "call_1", + Name: "filesystem_edit", + Arguments: `{"path":"main.go"}`, + }, + }, + }, + {Role: "tool", ToolCallID: "call_1", Content: "tool finished"}, + }, + Tools: []domain.ToolSpec{ + { + Name: "filesystem_edit", + Description: "Edit file content", + Schema: map[string]any{ + "type": "object", + }, + }, + }, + }) + if err != nil { + t.Fatalf("buildRequest() error = %v", err) + } + + if payload.Model != DefaultModel { + t.Fatalf("expected default model %q, got %q", DefaultModel, payload.Model) + } + if !payload.Stream { + t.Fatalf("expected stream=true") + } + if payload.ToolChoice != "auto" { + t.Fatalf("expected tool choice auto, got %q", payload.ToolChoice) + } + if len(payload.Tools) != 1 || payload.Tools[0].Function.Name != "filesystem_edit" { + t.Fatalf("unexpected tool payload: %+v", payload.Tools) + } + if payload.Messages[0].Role != "system" || payload.Messages[0].Content != "system prompt" { + t.Fatalf("unexpected system message: %+v", payload.Messages[0]) + } + if !containsToolRoleMessage(payload.Messages, "call_1", "tool finished") { + t.Fatalf("expected tool role message, got %+v", payload.Messages) + } + if len(payload.Messages[2].ToolCalls) != 1 || payload.Messages[2].ToolCalls[0].Function.Name != "filesystem_edit" { + t.Fatalf("expected assistant tool call payload, got %+v", payload.Messages[2]) + } +} + +func TestParseErrorAndEmitTextDelta(t *testing.T) { + t.Parallel() + + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + tests := []struct { + name string + status string + body string + expectErr string + }{ + { + name: "json error payload", + status: "400 Bad Request", + body: `{"error":{"message":"invalid request"}}`, + expectErr: "invalid request", + }, + { + name: "plain text fallback", + status: "502 Bad Gateway", + body: `gateway timeout`, + expectErr: "gateway timeout", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + resp := &http.Response{ + Status: tt.status, + Body: ioNopCloser(tt.body), + } + err := provider.parseError(resp) + if err == nil || !strings.Contains(err.Error(), tt.expectErr) { + t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) + } + }) + } + + eventCh := make(chan domain.StreamEvent, 1) + if err := emitTextDelta(context.Background(), eventCh, "chunk"); err != nil { + t.Fatalf("emitTextDelta() error = %v", err) + } + if got := <-eventCh; got.Text != "chunk" || got.Type != domain.StreamEventTextDelta { + t.Fatalf("unexpected stream event: %+v", got) + } + + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := emitTextDelta(cancelledCtx, make(chan domain.StreamEvent), "chunk"); err == nil { + t.Fatalf("expected cancellation error") + } +} + +func TestProviderConsumeStreamRejectsDirtyJSON(t *testing.T) { + t.Parallel() + + provider, err := New(resolvedConfig(DefaultBaseURL, DefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + _, err = provider.consumeStream(context.Background(), strings.NewReader("data: {not-json}\n\n"), make(chan domain.StreamEvent, 1)) + if err == nil || !strings.Contains(err.Error(), "decode stream chunk") { + t.Fatalf("expected dirty JSON decode error, got %v", err) + } +} + +func containsToolRoleMessage(messages []openAIMessage, toolCallID string, content string) bool { + for _, message := range messages { + if message.Role == "tool" && message.ToolCallID == toolCallID && message.Content == content { + return true + } + } + return false +} + +func writeSSEChunk(t *testing.T, w http.ResponseWriter, payload any) { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal SSE payload: %v", err) + } + if _, err := w.Write([]byte("data: " + string(data) + "\n\n")); err != nil { + t.Fatalf("write SSE payload: %v", err) + } + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func ioNopCloser(body string) *readCloser { + return &readCloser{Reader: strings.NewReader(body)} +} + +type readCloser struct { + *strings.Reader +} + +func (r *readCloser) Close() error { + return nil +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 55561217..775ae374 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,7 +3,6 @@ package provider import "context" type Provider interface { - Name() string Chat(ctx context.Context, req ChatRequest, events chan<- StreamEvent) (ChatResponse, error) } diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 5a1f6aec..c017b04c 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -1,29 +1,83 @@ package provider import ( + "context" "errors" + "fmt" "strings" + + "neo-code/internal/config" ) +type Builder func(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) + +type DriverDefinition struct { + Name string + Build Builder +} + type Registry struct { - items map[string]Provider + drivers map[string]DriverDefinition } +var errDriverAlreadyRegistered = errors.New("provider: driver already registered") + func NewRegistry() *Registry { - return &Registry{items: map[string]Provider{}} + return &Registry{drivers: map[string]DriverDefinition{}} } -func (r *Registry) Register(provider Provider) { - if provider == nil { - return +func (r *Registry) Register(driver DriverDefinition) error { + if r == nil { + return errors.New("provider: registry is nil") } - r.items[strings.ToLower(provider.Name())] = provider + + r.ensureDrivers() + + driver.Name = strings.TrimSpace(driver.Name) + driverType := normalizeKey(driver.Name) + if driverType == "" { + return errors.New("provider: driver name is empty") + } + if driver.Build == nil { + return fmt.Errorf("provider: driver %q build func is nil", driver.Name) + } + if _, exists := r.drivers[driverType]; exists { + return fmt.Errorf("%w: %s", errDriverAlreadyRegistered, driver.Name) + } + r.drivers[driverType] = driver + return nil } -func (r *Registry) Get(name string) (Provider, error) { - item, ok := r.items[strings.ToLower(name)] +func (r *Registry) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (Provider, error) { + driver, err := r.driver(cfg.Driver) + if err != nil { + return nil, err + } + return driver.Build(ctx, cfg) +} + +func (r *Registry) Supports(driverType string) bool { + _, err := r.driver(driverType) + return err == nil +} + +func (r *Registry) driver(driverType string) (DriverDefinition, error) { + if r == nil { + return DriverDefinition{}, ErrDriverNotFound + } + driver, ok := r.drivers[normalizeKey(driverType)] if !ok { - return nil, errors.New("provider: not found") + return DriverDefinition{}, fmt.Errorf("%w: %s", ErrDriverNotFound, strings.TrimSpace(driverType)) + } + return driver, nil +} + +func normalizeKey(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func (r *Registry) ensureDrivers() { + if r.drivers == nil { + r.drivers = map[string]DriverDefinition{} } - return item, nil } diff --git a/internal/provider/registry_test.go b/internal/provider/registry_test.go index 9a76c9d8..3bda125d 100644 --- a/internal/provider/registry_test.go +++ b/internal/provider/registry_test.go @@ -1,80 +1,284 @@ package provider_test import ( - "strings" + "context" + "errors" "testing" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/anthropic" - "github.com/dust/neo-code/internal/provider/openai" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + "neo-code/internal/provider/gemini" + "neo-code/internal/provider/openai" + "neo-code/internal/provider/openll" ) -func TestRegistryRegisterAndGet(t *testing.T) { +type stubProvider struct{} + +func (stubProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + return provider.ChatResponse{}, nil +} + +func stubDriver(driverType string) provider.DriverDefinition { + return provider.DriverDefinition{ + Name: driverType, + Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { + return stubProvider{}, nil + }, + } +} + +func newTestRegistry(t *testing.T) *provider.Registry { + t.Helper() + + registry := provider.NewRegistry() + if err := registry.Register(openai.Driver()); err != nil { + t.Fatalf("register openai driver: %v", err) + } + return registry +} + +func newTestManager(t *testing.T) *config.Manager { + t.Helper() + + return newTestManagerWithDefaults(t, builtin.DefaultConfig()) +} + +func newTestManagerWithDefaults(t *testing.T, defaults *config.Config) *config.Manager { + t.Helper() + + manager := config.NewManager(config.NewLoader(t.TempDir(), defaults)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("load config: %v", err) + } + return manager +} + +func TestRegistryBuildsRegisteredDriverCaseInsensitively(t *testing.T) { t.Parallel() - openAIProvider, err := openai.New(config.ProviderConfig{ - Name: config.ProviderOpenAI, - Type: config.ProviderOpenAI, - BaseURL: config.DefaultOpenAIBaseURL, - Model: config.DefaultOpenAIModel, - APIKeyEnv: config.DefaultOpenAIAPIKeyEnv, + registry := newTestRegistry(t) + got, err := registry.Build(context.Background(), config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{ + Name: "openai-main", + Driver: "OPENAI", + BaseURL: openai.DefaultBaseURL, + Model: openai.DefaultModel, + APIKeyEnv: openai.DefaultAPIKeyEnv, + }, + APIKey: "test-key", }) if err != nil { - t.Fatalf("openai.New() error = %v", err) - } - anthropicProvider := anthropic.New(config.ProviderConfig{ - Name: config.ProviderAnthropic, - Type: config.ProviderAnthropic, - BaseURL: config.DefaultAnthropicBaseURL, - Model: config.DefaultAnthropicModel, - APIKeyEnv: config.DefaultAnthropicAPIKeyEnv, + t.Fatalf("Build() error = %v", err) + } + if _, ok := got.(*openai.Provider); !ok { + t.Fatalf("expected openai.Provider, got %T", got) + } +} + +func TestRegistryUnknownDriverReturnsTypedError(t *testing.T) { + t.Parallel() + + registry := provider.NewRegistry() + _, err := registry.Build(context.Background(), config.ResolvedProviderConfig{ + ProviderConfig: config.ProviderConfig{Driver: "missing"}, }) + if !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected ErrDriverNotFound, got %v", err) + } +} + +func TestRegistryRejectsDuplicateDriverRegistration(t *testing.T) { + t.Parallel() registry := provider.NewRegistry() - registry.Register(nil) - registry.Register(openAIProvider) - registry.Register(anthropicProvider) - - tests := []struct { - name string - lookup string - expectName string - }{ - { - name: "gets openai provider case insensitively", - lookup: "OPENAI", - expectName: config.ProviderOpenAI, - }, - { - name: "gets anthropic provider case insensitively", - lookup: "Anthropic", - expectName: config.ProviderAnthropic, - }, + if err := registry.Register(stubDriver("custom")); err != nil { + t.Fatalf("initial Register() error = %v", err) + } + if err := registry.Register(stubDriver("CUSTOM")); err == nil { + t.Fatalf("expected duplicate driver registration to fail") } +} - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() +func TestServiceListProvidersUsesConfiguredMetadata(t *testing.T) { + t.Parallel() - got, err := registry.Get(tt.lookup) - if err != nil { - t.Fatalf("Get(%q) error = %v", tt.lookup, err) - } - if got == nil || !strings.EqualFold(got.Name(), tt.expectName) { - t.Fatalf("expected provider %q, got %+v", tt.expectName, got) - } + manager := newTestManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "unsupported", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + Models: []string{"custom-model"}, + APIKeyEnv: "CUSTOM_API_KEY", }) + return nil + }); err != nil { + t.Fatalf("append provider: %v", err) + } + + service := provider.NewService(manager, newTestRegistry(t)) + items, err := service.ListProviders(context.Background()) + if err != nil { + t.Fatalf("ListProviders() error = %v", err) + } + expectedModels := map[string]int{ + openai.Name: len(openai.BuiltinConfig().Models), + gemini.Name: len(gemini.BuiltinConfig().Models), + openll.Name: len(openll.BuiltinConfig().Models), + } + if len(items) != len(expectedModels) { + t.Fatalf("expected only supported providers, got %d", len(items)) + } + + for _, item := range items { + wantModels, ok := expectedModels[item.ID] + if !ok { + t.Fatalf("unexpected supported provider %q", item.ID) + } + if item.Description != "" { + t.Fatalf("expected provider description to stay empty for hidden metadata, got %q", item.Description) + } + if len(item.Models) != wantModels { + t.Fatalf("expected provider models to come from config, got %+v", item.Models) + } + delete(expectedModels, item.ID) + } + if len(expectedModels) != 0 { + t.Fatalf("missing supported providers from catalog: %+v", expectedModels) } } -func TestRegistryGetMissingProvider(t *testing.T) { +func TestServiceSelectProviderAndSetCurrentModel(t *testing.T) { + defaults := builtin.DefaultConfig() + defaults.Providers = append(defaults.Providers, config.ProviderConfig{ + Name: "custom-main", + Driver: "custom", + BaseURL: "https://example.com", + Model: "custom-model", + Models: []string{"custom-model", "custom-alt"}, + APIKeyEnv: "CUSTOM_API_KEY", + }) + manager := newTestManagerWithDefaults(t, defaults) + registry := newTestRegistry(t) + if err := registry.Register(stubDriver("custom")); err != nil { + t.Fatalf("register stub driver: %v", err) + } + + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.CurrentModel = "gpt-5.4" + return nil + }); err != nil { + t.Fatalf("seed current model: %v", err) + } + + service := provider.NewService(manager, registry) + + selection, err := service.SelectProvider(context.Background(), "custom-main") + if err != nil { + t.Fatalf("SelectProvider() error = %v", err) + } + if selection.ProviderID != "custom-main" || selection.ModelID != "custom-model" { + t.Fatalf("unexpected selection after switch: %+v", selection) + } + + if _, err := service.SetCurrentModel(context.Background(), "missing-model"); !errors.Is(err, provider.ErrModelNotFound) { + t.Fatalf("expected ErrModelNotFound, got %v", err) + } + + selection, err = service.SetCurrentModel(context.Background(), "custom-alt") + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != "custom-alt" { + t.Fatalf("expected selected model %q, got %+v", "custom-alt", selection) + } + + cfg := manager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() error = %v", err) + } + if selected.Name != "custom-main" { + t.Fatalf("expected selected provider %q, got %+v", "custom-main", selected) + } + if selected.Model != "custom-model" || cfg.CurrentModel != "custom-alt" { + t.Fatalf("expected provider default and current model to diverge safely, got provider=%q current=%q", selected.Model, cfg.CurrentModel) + } + + reloaded, err := manager.Reload(context.Background()) + if err != nil { + t.Fatalf("Reload() error = %v", err) + } + selected, err = reloaded.SelectedProviderConfig() + if err != nil { + t.Fatalf("SelectedProviderConfig() after reload error = %v", err) + } + if selected.Model != "custom-model" || reloaded.CurrentModel != "custom-alt" { + t.Fatalf("expected current model persistence without overriding provider default, got provider=%q current=%q", selected.Model, reloaded.CurrentModel) + } +} + +func TestServiceModelOperationsUseProviderConfigEvenWithoutDriver(t *testing.T) { t.Parallel() - registry := provider.NewRegistry() - _, err := registry.Get("missing") - if err == nil || !strings.Contains(err.Error(), "not found") { - t.Fatalf("expected not found error, got %v", err) + manager := newTestManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "broken-provider", + Driver: "missing-driver", + BaseURL: "https://example.com", + Model: "broken-model", + Models: []string{"broken-model", "broken-alt"}, + APIKeyEnv: "BROKEN_API_KEY", + }) + cfg.SelectedProvider = "broken-provider" + cfg.CurrentModel = "broken-model" + return nil + }); err != nil { + t.Fatalf("append broken provider: %v", err) + } + + service := provider.NewService(manager, newTestRegistry(t)) + + models, err := service.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(models) != 2 || models[1].ID != "broken-alt" { + t.Fatalf("expected models from provider config, got %+v", models) + } + + selection, err := service.SetCurrentModel(context.Background(), "broken-alt") + if err != nil { + t.Fatalf("SetCurrentModel() error = %v", err) + } + if selection.ModelID != "broken-alt" { + t.Fatalf("expected current model to update from provider config, got %+v", selection) + } +} + +func TestServiceSelectProviderRejectsUnsupportedDriver(t *testing.T) { + t.Parallel() + + manager := newTestManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "broken-provider", + Driver: "missing-driver", + BaseURL: "https://example.com", + Model: "broken-model", + Models: []string{"broken-model"}, + APIKeyEnv: "BROKEN_API_KEY", + }) + return nil + }); err != nil { + t.Fatalf("append broken provider: %v", err) + } + + service := provider.NewService(manager, newTestRegistry(t)) + if _, err := service.SelectProvider(context.Background(), "broken-provider"); !errors.Is(err, provider.ErrDriverNotFound) { + t.Fatalf("expected SelectProvider() to preserve ErrDriverNotFound, got %v", err) } } diff --git a/internal/provider/service.go b/internal/provider/service.go new file mode 100644 index 00000000..d0ae3ae6 --- /dev/null +++ b/internal/provider/service.go @@ -0,0 +1,187 @@ +package provider + +import ( + "context" + "errors" + "strings" + + "neo-code/internal/config" +) + +type Service struct { + manager *config.Manager + registry *Registry +} + +var ( + errServiceManagerNil = errors.New("provider: config manager is nil") + errServiceRegistryNil = errors.New("provider: registry is nil") +) + +func NewService(manager *config.Manager, registry *Registry) *Service { + return &Service{ + manager: manager, + registry: registry, + } +} + +func (s *Service) ListProviders(ctx context.Context) ([]ProviderCatalogItem, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + cfg := s.manager.Get() + items := make([]ProviderCatalogItem, 0, len(cfg.Providers)) + for _, providerCfg := range cfg.Providers { + if err := ctx.Err(); err != nil { + return nil, err + } + if !s.registry.Supports(providerCfg.Driver) { + continue + } + items = append(items, catalogItemFromConfig(providerCfg)) + } + + return items, nil +} + +func (s *Service) SelectProvider(ctx context.Context, providerName string) (ProviderSelection, error) { + if err := s.validate(); err != nil { + return ProviderSelection{}, err + } + + var selection ProviderSelection + + err := s.manager.Update(ctx, func(cfg *config.Config) error { + providerCfg, err := cfg.ProviderByName(providerName) + if err != nil { + return ErrProviderNotFound + } + if !s.registry.Supports(providerCfg.Driver) { + return ErrDriverNotFound + } + + cfg.SelectedProvider = providerCfg.Name + cfg.CurrentModel = selectModel(cfg.CurrentModel, providerCfg.SupportedModels(), providerCfg.Model) + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return ProviderSelection{}, err + } + + return selection, nil +} + +func (s *Service) ListModels(ctx context.Context) ([]ModelDescriptor, error) { + if err := s.validate(); err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, err + } + + cfg := s.manager.Get() + selected, err := cfg.SelectedProviderConfig() + if err != nil { + return nil, err + } + + return modelDescriptors(selected.SupportedModels()), nil +} + +func (s *Service) SetCurrentModel(ctx context.Context, modelID string) (ProviderSelection, error) { + if err := s.validate(); err != nil { + return ProviderSelection{}, err + } + + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return ProviderSelection{}, ErrModelNotFound + } + + var selection ProviderSelection + err := s.manager.Update(ctx, func(cfg *config.Config) error { + selected, err := cfg.SelectedProviderConfig() + if err != nil { + return err + } + + if !containsModel(selected.SupportedModels(), modelID) { + return ErrModelNotFound + } + + cfg.CurrentModel = modelID + selection = selectionFromConfig(*cfg) + return nil + }) + if err != nil { + return ProviderSelection{}, err + } + + return selection, nil +} + +func containsModel(models []string, modelID string) bool { + target := normalizeKey(modelID) + for _, model := range models { + if normalizeKey(model) == target { + return true + } + } + return false +} + +func selectionFromConfig(cfg config.Config) ProviderSelection { + return ProviderSelection{ + ProviderID: cfg.SelectedProvider, + ModelID: cfg.CurrentModel, + } +} + +func selectModel(currentModel string, models []string, fallback string) string { + if containsModel(models, currentModel) { + return strings.TrimSpace(currentModel) + } + return strings.TrimSpace(fallback) +} + +func catalogItemFromConfig(cfg config.ProviderConfig) ProviderCatalogItem { + return ProviderCatalogItem{ + ID: strings.TrimSpace(cfg.Name), + Name: strings.TrimSpace(cfg.Name), + Models: modelDescriptors(cfg.SupportedModels()), + } +} + +func modelDescriptors(models []string) []ModelDescriptor { + if len(models) == 0 { + return nil + } + + descriptors := make([]ModelDescriptor, 0, len(models)) + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + descriptors = append(descriptors, ModelDescriptor{ + ID: modelID, + Name: modelID, + }) + } + return descriptors +} + +func (s *Service) validate() error { + if s == nil || s.manager == nil { + return errServiceManagerNil + } + if s.registry == nil { + return errServiceRegistryNil + } + return nil +} diff --git a/internal/provider/types.go b/internal/provider/types.go index 23cc3a58..d03ff5d0 100644 --- a/internal/provider/types.go +++ b/internal/provider/types.go @@ -1,5 +1,9 @@ package provider +import ( + "errors" +) + type Message struct { Role string `json:"role"` Content string `json:"content"` @@ -38,3 +42,27 @@ type Usage struct { OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` } + +var ( + ErrProviderNotFound = errors.New("provider not found") + ErrModelNotFound = errors.New("model not found") + ErrDriverNotFound = errors.New("provider driver not found") +) + +type ModelDescriptor struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +type ProviderCatalogItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Models []ModelDescriptor `json:"models,omitempty"` +} + +type ProviderSelection struct { + ProviderID string `json:"provider_id"` + ModelID string `json:"model_id"` +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 96b9da93..44a6738c 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -3,16 +3,13 @@ package runtime import ( "context" "errors" - "fmt" "strings" + "sync" "time" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/provider/anthropic" - "github.com/dust/neo-code/internal/provider/gemini" - "github.com/dust/neo-code/internal/provider/openai" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/tools" ) const maxContextTurns = 10 @@ -20,6 +17,7 @@ const maxContextTurns = 10 // Runtime coordinates agent execution, session persistence, and event delivery. type Runtime interface { Run(ctx context.Context, input UserInput) error + CancelActiveRun() bool Events() <-chan RuntimeEvent ListSessions(ctx context.Context) ([]SessionSummary, error) LoadSession(ctx context.Context, id string) (Session, error) @@ -34,26 +32,24 @@ type UserInput struct { } type ProviderFactory interface { - Build(cfg config.Config) (provider.Provider, error) + Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) } -type DefaultProviderFactory struct{} - type Service struct { configManager *config.Manager sessionStore Store toolRegistry *tools.Registry providerFactory ProviderFactory events chan RuntimeEvent -} - -func New(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store) *Service { - return NewWithFactory(configManager, toolRegistry, sessionStore, DefaultProviderFactory{}) + runMu sync.Mutex + activeRunToken uint64 + nextRunToken uint64 + activeRunCancel context.CancelFunc } func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, sessionStore Store, providerFactory ProviderFactory) *Service { if providerFactory == nil { - providerFactory = DefaultProviderFactory{} + providerFactory = provider.NewRegistry() } return &Service{ @@ -66,6 +62,14 @@ func NewWithFactory(configManager *config.Manager, toolRegistry *tools.Registry, } func (s *Service) Run(ctx context.Context, input UserInput) error { + runCtx, cancel := context.WithCancel(ctx) + runToken := s.startRun(cancel) + defer func() { + cancel() + s.finishRun(runToken) + }() + ctx = runCtx + if strings.TrimSpace(input.Content) == "" { return errors.New("runtime: input content is empty") } @@ -102,7 +106,13 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return err } - modelProvider, err := s.providerFactory.Build(cfg) + resolvedProvider, err := s.configManager.ResolvedSelectedProvider() + if err != nil { + s.emit(EventError, input.RunID, session.ID, err.Error()) + return err + } + + modelProvider, err := s.providerFactory.Build(ctx, resolvedProvider) if err != nil { s.emit(EventError, input.RunID, session.ID, err.Error()) return err @@ -209,6 +219,18 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } } +func (s *Service) CancelActiveRun() bool { + s.runMu.Lock() + cancel := s.activeRunCancel + s.runMu.Unlock() + if cancel == nil { + return false + } + + cancel() + return true +} + func (s *Service) Events() <-chan RuntimeEvent { return s.events } @@ -251,6 +273,29 @@ func (s *Service) forwardProviderEvents(runID string, sessionID string, input <- } } +func (s *Service) startRun(cancel context.CancelFunc) uint64 { + s.runMu.Lock() + defer s.runMu.Unlock() + + s.nextRunToken++ + token := s.nextRunToken + s.activeRunToken = token + s.activeRunCancel = cancel + return token +} + +func (s *Service) finishRun(token uint64) { + s.runMu.Lock() + defer s.runMu.Unlock() + + if s.activeRunToken != token { + return + } + + s.activeRunToken = 0 + s.activeRunCancel = nil +} + func (s *Service) handleRunError(runID string, sessionID string, err error) error { if s.isRunCanceled(err) { s.emit(EventRunCanceled, runID, sessionID, nil) @@ -306,21 +351,3 @@ Use tools when necessary. When a tool fails, inspect the error and continue safely. Stay within the workspace and avoid destructive behavior unless clearly requested.` } - -func (DefaultProviderFactory) Build(cfg config.Config) (provider.Provider, error) { - selected, err := cfg.SelectedProviderConfig() - if err != nil { - return nil, err - } - - switch strings.ToLower(strings.TrimSpace(selected.Type)) { - case "openai": - return openai.New(selected) - case "anthropic": - return anthropic.New(selected), nil - case "gemini": - return gemini.New(selected), nil - default: - return nil, fmt.Errorf("runtime: unsupported provider type %q", selected.Type) - } -} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 24154d96..a9f581fc 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4,12 +4,15 @@ import ( "context" "errors" "fmt" + "os" "strings" "testing" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + "neo-code/internal/provider/openai" + "neo-code/internal/tools" ) type memoryStore struct { @@ -91,10 +94,6 @@ type scriptedProvider struct { chatFn func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) } -func (p *scriptedProvider) Name() string { - return p.name -} - func (p *scriptedProvider) Chat(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { p.requests = append(p.requests, cloneChatRequest(req)) @@ -127,7 +126,7 @@ type scriptedProviderFactory struct { err error } -func (f *scriptedProviderFactory) Build(cfg config.Config) (provider.Provider, error) { +func (f *scriptedProviderFactory) Build(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { f.calls++ if f.err != nil { return nil, f.err @@ -302,7 +301,6 @@ func TestServiceRun(t *testing.T) { } scripted := &scriptedProvider{ - name: "scripted", responses: tt.providerResponses, streams: tt.providerStreams, } @@ -345,7 +343,7 @@ func TestServiceTrimMessagesPreservesToolPairs(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{name: "noop"}}) + service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}) messages := make([]provider.Message, 0, maxContextTurns+4) for i := 0; i < 8; i++ { @@ -387,7 +385,7 @@ func TestServiceTrimMessagesBoundaries(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) - service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{name: "noop"}}) + service := NewWithFactory(manager, tools.NewRegistry(), newMemoryStore(), &scriptedProviderFactory{provider: &scriptedProvider{}}) tests := []struct { name string @@ -481,7 +479,6 @@ func TestServiceRunErrorPaths(t *testing.T) { input: UserInput{RunID: "run-max-loops", Content: "loop"}, maxLoops: 1, provider: &scriptedProvider{ - name: "looping", responses: []provider.ChatResponse{ { Message: provider.Message{ @@ -526,7 +523,6 @@ func TestServiceRunErrorPaths(t *testing.T) { Content: "continue", }, provider: &scriptedProvider{ - name: "resume", responses: []provider.ChatResponse{ { Message: provider.Message{ @@ -613,6 +609,49 @@ func TestServiceRunErrorPaths(t *testing.T) { } } +func TestServiceCancelActiveRun(t *testing.T) { + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + started := make(chan struct{}) + scripted := &scriptedProvider{ + name: "cancel-active-run-provider", + chatFn: func(ctx context.Context, req provider.ChatRequest, events chan<- provider.StreamEvent) (provider.ChatResponse, error) { + close(started) + <-ctx.Done() + return provider.ChatResponse{}, ctx.Err() + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}) + errCh := make(chan error, 1) + input := UserInput{RunID: "run-cancel-active", Content: "hello"} + + go func() { + errCh <- service.Run(context.Background(), input) + }() + + <-started + if !service.CancelActiveRun() { + t.Fatalf("expected active run cancel to return true") + } + + err := <-errCh + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } + if service.CancelActiveRun() { + t.Fatalf("expected no active run after cancellation") + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventUserMessage, EventRunCanceled}) + assertNoEventType(t, events, EventError) + assertEventsRunID(t, events, input.RunID) +} + func TestServiceRunCanceledByProvider(t *testing.T) { manager := newRuntimeConfigManager(t) store := newMemoryStore() @@ -929,7 +968,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { registry := tools.NewRegistry() registry.Register(&stubTool{name: "filesystem_read_file", content: "ok"}) - service := New(manager, registry, store) + service := NewWithFactory(manager, registry, store, nil) if service == nil { t.Fatalf("expected service") } @@ -962,82 +1001,13 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { } } -func TestDefaultProviderFactoryBuild(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mutate func(*config.Config) - expectErr string - expectName string - }{ - { - name: "openai provider", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = config.ProviderOpenAI - cfg.CurrentModel = config.DefaultOpenAIModel - }, - expectName: config.ProviderOpenAI, - }, - { - name: "anthropic provider", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = config.ProviderAnthropic - cfg.CurrentModel = config.DefaultAnthropicModel - }, - expectName: config.ProviderAnthropic, - }, - { - name: "gemini provider", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = config.ProviderGemini - cfg.CurrentModel = config.DefaultGeminiModel - }, - expectName: config.ProviderGemini, - }, - { - name: "unsupported provider type", - mutate: func(cfg *config.Config) { - cfg.SelectedProvider = "custom" - cfg.CurrentModel = "custom-model" - cfg.Providers = append(cfg.Providers, config.ProviderConfig{ - Name: "custom", - Type: "custom", - BaseURL: "https://example.com", - Model: "custom-model", - APIKeyEnv: "CUSTOM_API_KEY", - }) - }, - expectErr: `unsupported provider type "custom"`, - }, - } - - factory := DefaultProviderFactory{} - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - cfg := config.Default() - tt.mutate(cfg) - got, err := factory.Build(cfg.Clone()) - if tt.expectErr != "" { - if err == nil || !containsError(err, tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got == nil || got.Name() != tt.expectName { - t.Fatalf("expected provider %q, got %+v", tt.expectName, got) - } - }) - } -} - func newRuntimeConfigManager(t *testing.T) *config.Manager { t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir())) + restoreRuntimeEnv(t, openai.DefaultAPIKeyEnv) + if err := os.Setenv(openai.DefaultAPIKeyEnv, "test-key"); err != nil { + t.Fatalf("set env: %v", err) + } + manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } @@ -1052,6 +1022,18 @@ func newRuntimeConfigManager(t *testing.T) *config.Manager { return manager } +func restoreRuntimeEnv(t *testing.T, key string) { + t.Helper() + value, ok := os.LookupEnv(key) + t.Cleanup(func() { + if !ok { + _ = os.Unsetenv(key) + return + } + _ = os.Setenv(key, value) + }) +} + func onlySession(t *testing.T, store *memoryStore) Session { t.Helper() if len(store.sessions) != 1 { diff --git a/internal/runtime/session.go b/internal/runtime/session.go index e77177b4..0e8b41a3 100644 --- a/internal/runtime/session.go +++ b/internal/runtime/session.go @@ -12,7 +12,7 @@ import ( "sync" "time" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) const sessionsDirName = "sessions" diff --git a/internal/runtime/session_test.go b/internal/runtime/session_test.go index 41574f93..1981719c 100644 --- a/internal/runtime/session_test.go +++ b/internal/runtime/session_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) func TestJSONSessionStoreSaveLoadAndListSummaries(t *testing.T) { diff --git a/internal/tools/bash/tool.go b/internal/tools/bash/tool.go index 76a12fd0..f04ea611 100644 --- a/internal/tools/bash/tool.go +++ b/internal/tools/bash/tool.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type Tool struct { diff --git a/internal/tools/bash/tool_test.go b/internal/tools/bash/tool_test.go index 2002d3dc..90fd6297 100644 --- a/internal/tools/bash/tool_test.go +++ b/internal/tools/bash/tool_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/edit.go b/internal/tools/filesystem/edit.go index d16e89a5..7d831ada 100644 --- a/internal/tools/filesystem/edit.go +++ b/internal/tools/filesystem/edit.go @@ -8,7 +8,7 @@ import ( "os" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type EditTool struct { diff --git a/internal/tools/filesystem/edit_test.go b/internal/tools/filesystem/edit_test.go index 55101312..dac4507d 100644 --- a/internal/tools/filesystem/edit_test.go +++ b/internal/tools/filesystem/edit_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestEditToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/glob.go b/internal/tools/filesystem/glob.go index 5d5fec57..41bac66a 100644 --- a/internal/tools/filesystem/glob.go +++ b/internal/tools/filesystem/glob.go @@ -10,7 +10,7 @@ import ( "sort" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type GlobTool struct { diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go index 41dd7826..c4615ea9 100644 --- a/internal/tools/filesystem/glob_test.go +++ b/internal/tools/filesystem/glob_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestGlobToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/grep.go b/internal/tools/filesystem/grep.go index 13e4754e..a152dfe0 100644 --- a/internal/tools/filesystem/grep.go +++ b/internal/tools/filesystem/grep.go @@ -10,7 +10,7 @@ import ( "regexp" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) const defaultGrepResultLimit = 200 diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go index 0bec1178..45763037 100644 --- a/internal/tools/filesystem/grep_test.go +++ b/internal/tools/filesystem/grep_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestGrepToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index 401f310c..f7902282 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) const emitChunkSize = 4 * 1024 diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index 97c7dda0..b325ebbf 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestReadFileToolExecute(t *testing.T) { diff --git a/internal/tools/filesystem/write_file.go b/internal/tools/filesystem/write_file.go index d07d0207..f5220315 100644 --- a/internal/tools/filesystem/write_file.go +++ b/internal/tools/filesystem/write_file.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) type WriteFileTool struct { diff --git a/internal/tools/filesystem/write_file_test.go b/internal/tools/filesystem/write_file_test.go index 05f27c8b..775062a4 100644 --- a/internal/tools/filesystem/write_file_test.go +++ b/internal/tools/filesystem/write_file_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/tools" ) func TestWriteFileToolMetadataAndExecute(t *testing.T) { diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 46f8ec9b..ed1906ce 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -6,7 +6,7 @@ import ( "sort" "strings" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type Registry struct { diff --git a/internal/tools/types.go b/internal/tools/types.go index 11b07e70..b09726f2 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -3,7 +3,7 @@ package tools import ( "context" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type Tool interface { diff --git a/internal/tools/webfetch/tool.go b/internal/tools/webfetch/tool.go index c87b97f2..dfc889bf 100644 --- a/internal/tools/webfetch/tool.go +++ b/internal/tools/webfetch/tool.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/tools" ) const ( diff --git a/internal/tools/webfetch/tool_test.go b/internal/tools/webfetch/tool_test.go index a655d592..f4c1087b 100644 --- a/internal/tools/webfetch/tool_test.go +++ b/internal/tools/webfetch/tool_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/tools" "golang.org/x/net/html" + "neo-code/internal/config" + "neo-code/internal/tools" ) func TestToolExecute(t *testing.T) { diff --git a/internal/tui/app.go b/internal/tui/app.go index 3f8a7538..fbfcd4bd 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,27 +6,29 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - agentruntime "github.com/dust/neo-code/internal/runtime" + "neo-code/internal/config" + "neo-code/internal/provider" + agentruntime "neo-code/internal/runtime" ) type App struct { state UIState configManager *config.Manager + providerSvc ProviderController runtime agentruntime.Runtime keys keyMap help help.Model spinner spinner.Model sessions list.Model + providerPicker list.Model modelPicker list.Model transcript viewport.Model - input textarea.Model + input textinput.Model activeMessages []provider.Message focus panel width int @@ -34,33 +36,42 @@ type App struct { styles styles } -func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime) (App, error) { - if cfg == nil { - defaultCfg := config.Default() - cfg = defaultCfg - } +func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { if configManager == nil { return App{}, fmt.Errorf("tui: config manager is nil") } + if providerSvc == nil { + return App{}, fmt.Errorf("tui: provider service is nil") + } + if cfg == nil { + snapshot := configManager.Get() + cfg = &snapshot + } uiStyles := newStyles() keys := newKeyMap() delegate := sessionDelegate{styles: uiStyles} sessionList := list.New([]list.Item{}, delegate, 0, 0) sessionList.Title = "" + sessionList.SetShowTitle(false) sessionList.SetShowHelp(false) sessionList.SetShowStatusBar(false) + sessionList.SetShowFilter(false) + sessionList.SetShowPagination(false) sessionList.SetFilteringEnabled(true) sessionList.DisableQuitKeybindings() + sessionList.FilterInput.Prompt = "Filter: " + sessionList.FilterInput.Placeholder = "Type to search sessions" - input := textarea.New() + input := textinput.New() input.Placeholder = "Ask NeoCode to inspect, edit, or build. Type / to browse commands." - input.Prompt = "> " + input.Prompt = "" input.CharLimit = 24000 - input.ShowLineNumbers = false - input.SetHeight(1) + input.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) + input.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)) + input.PlaceholderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorSubtle)) + input.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorUser)) input.Focus() - input.KeyMap.InsertNewline.SetEnabled(false) spin := spinner.New() spin.Spinner = spinner.Line @@ -78,19 +89,21 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime ActiveSessionTitle: draftSessionTitle, Focus: panelInput, }, - configManager: configManager, - runtime: runtime, - keys: keys, - help: h, - spinner: spin, - sessions: sessionList, - modelPicker: newModelPicker(), - transcript: viewport.New(0, 0), - input: input, - focus: panelInput, - width: 128, - height: 40, - styles: uiStyles, + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + keys: keys, + help: h, + spinner: spin, + sessions: sessionList, + providerPicker: newProviderPicker(nil), + modelPicker: newModelPicker(nil), + transcript: viewport.New(0, 0), + input: input, + focus: panelInput, + width: 128, + height: 40, + styles: uiStyles, } if err := app.refreshSessions(); err != nil { @@ -104,11 +117,18 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } app.syncActiveSessionTitle() app.syncConfigState(configManager.Get()) + if err := app.refreshProviderPicker(); err != nil { + return App{}, err + } + if err := app.refreshModelPicker(); err != nil { + return App{}, err + } + app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.resizeComponents() return app, nil } func (a App) Init() tea.Cmd { - return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textarea.Blink, a.spinner.Tick) + return tea.Batch(ListenForRuntimeEvent(a.runtime.Events()), textinput.Blink, a.spinner.Tick) } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index c7f176f1..4aacf6a1 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -3,47 +3,49 @@ package tui import ( "context" "fmt" - "net/url" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/dust/neo-code/internal/config" + "neo-code/internal/provider" ) const ( - slashPrefix = "/" - slashCommandSet = "/set" - slashCommandModelPicker = "/model" + slashPrefix = "/" + slashCommandProviderPick = "/provider" + slashCommandModelPicker = "/model" - slashUsageSetURL = "/set url " - slashUsageSetKey = "/set key " - slashUsageModel = "/model" + slashUsageProvider = "/provider" + slashUsageModel = "/model" - commandMenuTitle = "Commands" - composerHintText = "Enter send | /set url | /set key | /model | Tab switch panels" - modelPickerTitle = "Select Model" - modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + commandMenuTitle = "Commands" + providerPickerTitle = "Select Provider" + providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + modelPickerTitle = "Select Model" + modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" - sidebarTitle = "Sessions" - sidebarSubtitle = "Use / to filter, Enter to open" + sidebarTitle = "Sessions" + sidebarFilterHint = "Type / to search" + sidebarOpenHint = "Enter to open" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type / to browse local commands." emptyMessageText = "(empty)" - statusReady = "Ready" - statusRuntimeClosed = "Runtime closed" - statusThinking = "Thinking" - statusRunningTool = "Running tool" - statusToolFinished = "Tool finished" - statusToolError = "Tool error" - statusError = "Error" - statusDraft = "New draft" - statusRunning = "Running" - statusApplyingCommand = "Applying local command" - statusChooseModel = "Choose a model" + statusReady = "Ready" + statusRuntimeClosed = "Runtime closed" + statusThinking = "Thinking" + statusCanceling = "Canceling" + statusCanceled = "Canceled" + statusRunningTool = "Running tool" + statusToolFinished = "Tool finished" + statusToolError = "Tool error" + statusError = "Error" + statusDraft = "New draft" + statusRunning = "Running" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -72,18 +74,11 @@ type commandSuggestion struct { } var builtinSlashCommands = []slashCommand{ - {Usage: slashUsageSetURL, Description: "Set the API Base URL"}, - {Usage: slashUsageSetKey, Description: "Update the API Key"}, + {Usage: slashUsageProvider, Description: "Open the interactive provider picker"}, {Usage: slashUsageModel, Description: "Open the interactive model picker"}, } -func newModelPicker() list.Model { - catalog := config.BuiltinModelCatalog() - items := make([]list.Item, 0, len(catalog)) - for _, option := range catalog { - items = append(items, modelItem{name: option.Name, description: option.Description}) - } - +func newSelectionPicker(items []list.Item) list.Model { delegate := list.NewDefaultDelegate() picker := list.New(items, delegate, 0, 0) picker.Title = "" @@ -94,24 +89,96 @@ func newModelPicker() list.Model { return picker } +func newProviderPicker(items []provider.ProviderCatalogItem) list.Model { + listItems := make([]list.Item, 0, len(items)) + for _, item := range items { + listItems = append(listItems, providerItem{ + id: item.ID, + name: item.Name, + description: item.Description, + }) + } + return newSelectionPicker(listItems) +} + +func newModelPicker(models []provider.ModelDescriptor) list.Model { + items := make([]list.Item, 0, len(models)) + for _, option := range models { + items = append(items, modelItem{ + id: option.ID, + name: option.Name, + description: option.Description, + }) + } + return newSelectionPicker(items) +} + +func replacePickerItems(current list.Model, next list.Model) list.Model { + next.SetSize(current.Width(), current.Height()) + return next +} + +func (a *App) refreshProviderPicker() error { + items, err := a.providerSvc.ListProviders(context.Background()) + if err != nil { + return err + } + + a.providerPicker = replacePickerItems(a.providerPicker, newProviderPicker(items)) + a.selectCurrentProvider(a.state.CurrentProvider) + return nil +} + +func (a *App) refreshModelPicker() error { + models, err := a.providerSvc.ListModels(context.Background()) + if err != nil { + return err + } + + a.modelPicker = replacePickerItems(a.modelPicker, newModelPicker(models)) + a.selectCurrentModel(a.state.CurrentModel) + return nil +} + +func (a *App) openProviderPicker() { + a.state.ActivePicker = pickerProvider + a.state.StatusText = statusChooseProvider + a.input.Blur() + a.selectCurrentProvider(a.state.CurrentProvider) +} + func (a *App) openModelPicker() { - a.state.ShowModelPicker = true + a.state.ActivePicker = pickerModel a.state.StatusText = statusChooseModel a.input.Blur() a.selectCurrentModel(a.state.CurrentModel) } -func (a *App) closeModelPicker() { - a.state.ShowModelPicker = false +func (a *App) closePicker() { + a.state.ActivePicker = pickerNone a.focus = panelInput a.applyFocus() } -func (a *App) selectCurrentModel(model string) { +func (a *App) selectCurrentProvider(providerID string) { + items := a.providerPicker.Items() + for idx, item := range items { + candidate, ok := item.(providerItem) + if ok && strings.EqualFold(candidate.id, providerID) { + a.providerPicker.Select(idx) + return + } + } + if len(items) > 0 { + a.providerPicker.Select(0) + } +} + +func (a *App) selectCurrentModel(modelID string) { items := a.modelPicker.Items() for idx, item := range items { candidate, ok := item.(modelItem) - if ok && strings.EqualFold(candidate.name, model) { + if ok && strings.EqualFold(candidate.id, modelID) { a.modelPicker.Select(idx) return } @@ -138,98 +205,26 @@ func (a App) matchingSlashCommands(input string) []commandSuggestion { return out } -func runLocalCommand(configManager *config.Manager, raw string) tea.Cmd { +func runProviderSelection(providerSvc ProviderController, providerName string) tea.Cmd { return func() tea.Msg { - notice, err := executeLocalCommand(context.Background(), configManager, raw) - return localCommandResultMsg{notice: notice, err: err} - } -} - -func runModelSelection(configManager *config.Manager, model string) tea.Cmd { - return func() tea.Msg { - notice, err := setCurrentModel(context.Background(), configManager, model) - return localCommandResultMsg{notice: notice, err: err} - } -} - -func executeLocalCommand(ctx context.Context, configManager *config.Manager, raw string) (string, error) { - fields := strings.Fields(strings.TrimSpace(raw)) - if len(fields) == 0 { - return "", fmt.Errorf("empty command") - } - - if !strings.EqualFold(fields[0], slashCommandSet) { - return "", fmt.Errorf("unknown command %q", fields[0]) - } - if len(fields) < 3 { - return "", fmt.Errorf("usage: %s | %s | %s", slashUsageSetURL, slashUsageSetKey, slashUsageModel) - } - - value := strings.TrimSpace(strings.Join(fields[2:], " ")) - if value == "" { - return "", fmt.Errorf("command value is empty") - } - - switch strings.ToLower(fields[1]) { - case "url": - if _, err := url.ParseRequestURI(value); err != nil { - return "", fmt.Errorf("invalid url: %w", err) - } - if err := configManager.Update(ctx, func(cfg *config.Config) error { - selectedName := strings.TrimSpace(cfg.SelectedProvider) - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), selectedName) { - cfg.Providers[i].BaseURL = value - return nil - } - } - return fmt.Errorf("selected provider %q not found", cfg.SelectedProvider) - }); err != nil { - return "", err - } - cfg := configManager.Get() - return fmt.Sprintf("[System] Base URL updated for %s -> %s", cfg.SelectedProvider, value), nil - case "key": - cfg := configManager.Get() - selected, err := cfg.SelectedProviderConfig() + selection, err := providerSvc.SelectProvider(context.Background(), providerName) if err != nil { - return "", err + return localCommandResultMsg{err: err} } - if err := configManager.UpsertEnv(selected.APIKeyEnv, value); err != nil { - return "", fmt.Errorf("persist api key: %w", err) + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), } - if err := configManager.OverloadManagedEnvironment(); err != nil { - return "", fmt.Errorf("reload managed env: %w", err) - } - if _, err := configManager.Reload(ctx); err != nil { - return "", fmt.Errorf("reload config: %w", err) - } - return fmt.Sprintf("[System] %s updated and loaded.", selected.APIKeyEnv), nil - case "model": - return setCurrentModel(ctx, configManager, value) - default: - return "", fmt.Errorf("unsupported /set field %q", fields[1]) } } -func setCurrentModel(ctx context.Context, configManager *config.Manager, model string) (string, error) { - if err := configManager.Update(ctx, func(cfg *config.Config) error { - cfg.CurrentModel = model - selectedName := strings.TrimSpace(cfg.SelectedProvider) - for i := range cfg.Providers { - if strings.EqualFold(strings.TrimSpace(cfg.Providers[i].Name), selectedName) { - cfg.Providers[i].Model = model - return nil - } +func runModelSelection(providerSvc ProviderController, modelID string) tea.Cmd { + return func() tea.Msg { + selection, err := providerSvc.SetCurrentModel(context.Background(), modelID) + if err != nil { + return localCommandResultMsg{err: err} + } + return localCommandResultMsg{ + notice: fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), } - return nil - }); err != nil { - return "", err - } - - if _, err := configManager.Reload(ctx); err != nil { - return "", fmt.Errorf("reload config: %w", err) } - - return fmt.Sprintf("[System] Current model switched to %s.", model), nil } diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index 2eaf306c..af472f74 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -1,125 +1,6 @@ package tui -import ( - "context" - "os" - "strings" - "testing" - - "github.com/dust/neo-code/internal/config" -) - -func TestExecuteLocalCommand(t *testing.T) { - tests := []struct { - name string - command string - expectErr string - assert func(t *testing.T, manager *config.Manager, notice string) - }{ - { - name: "set url updates selected provider", - command: "/set url https://test.example/v1", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.BaseURL != "https://test.example/v1" { - t.Fatalf("expected updated base url, got %q", selected.BaseURL) - } - if !strings.Contains(notice, "Base URL updated") { - t.Fatalf("expected update notice, got %q", notice) - } - }, - }, - { - name: "set model updates current model", - command: "/set model gpt-5.4", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - if cfg.CurrentModel != "gpt-5.4" { - t.Fatalf("expected current model gpt-5.4, got %q", cfg.CurrentModel) - } - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.Model != "gpt-5.4" { - t.Fatalf("expected selected provider model gpt-5.4, got %q", selected.Model) - } - if !strings.Contains(notice, "Current model switched") { - t.Fatalf("expected model switch notice, got %q", notice) - } - }, - }, - { - name: "set key writes managed env and reloads", - command: "/set key secret-key", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - if got := strings.TrimSpace(os.Getenv(config.DefaultOpenAIAPIKeyEnv)); got != "secret-key" { - t.Fatalf("expected env to be reloaded, got %q", got) - } - if !strings.Contains(notice, "updated and loaded") { - t.Fatalf("expected key reload notice, got %q", notice) - } - }, - }, - { - name: "unknown command is rejected", - command: "/unknown", - expectErr: `unknown command "/unknown"`, - }, - { - name: "unknown command with suffix is rejected", - command: "/unknown_cmd", - expectErr: `unknown command "/unknown_cmd"`, - }, - { - name: "set usage requires enough arguments", - command: "/set url", - expectErr: "usage:", - }, - { - name: "unsupported set field is rejected", - command: "/set nope value", - expectErr: `unsupported /set field "nope"`, - }, - { - name: "invalid url is rejected", - command: "/set url not-a-url", - expectErr: "invalid url", - }, - { - name: "empty command is rejected", - command: " ", - expectErr: "empty command", - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - manager := newTestConfigManager(t) - notice, err := executeLocalCommand(context.Background(), manager, tt.command) - if tt.expectErr != "" { - if err == nil || !strings.Contains(err.Error(), tt.expectErr) { - t.Fatalf("expected error containing %q, got %v", tt.expectErr, err) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if tt.assert != nil { - tt.assert(t, manager, notice) - } - }) - } -} +import "testing" func TestMatchingSlashCommands(t *testing.T) { t.Parallel() @@ -144,9 +25,9 @@ func TestMatchingSlashCommands(t *testing.T) { }, { name: "prefix narrows suggestions", - input: "/set u", + input: "/mo", expectCount: 1, - expectUsage: slashUsageSetURL, + expectUsage: slashUsageModel, }, } diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index afe04047..891be1a8 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -4,6 +4,7 @@ import "github.com/charmbracelet/bubbles/key" type keyMap struct { Send key.Binding + CancelAgent key.Binding NewSession key.Binding NextPanel key.Binding PrevPanel key.Binding @@ -23,72 +24,76 @@ func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( key.WithKeys("enter", "ctrl+s"), - key.WithHelp("enter", "send"), + key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), + ), + CancelAgent: key.NewBinding( + key.WithKeys("ctrl+w"), + key.WithHelp("Ctrl+w", "中止"), ), NewSession: key.NewBinding( key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new"), + key.WithHelp("Ctrl+N", "新会话"), ), NextPanel: key.NewBinding( key.WithKeys("tab"), - key.WithHelp("tab", "next panel"), + key.WithHelp("Tab", "下个面板"), ), PrevPanel: key.NewBinding( key.WithKeys("shift+tab"), - key.WithHelp("shift+tab", "prev panel"), + key.WithHelp("Shift+Tab", "上个面板"), ), FocusInput: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("esc", "focus input"), + key.WithHelp("Esc", "聚焦输入框"), ), OpenSession: key.NewBinding( key.WithKeys("enter"), - key.WithHelp("enter", "open session"), + key.WithHelp("Enter", "打开会话"), ), ToggleHelp: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "help"), + key.WithKeys("ctrl+q"), + key.WithHelp("Ctrl+Q", "帮助"), ), Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), + key.WithKeys("ctrl+u"), + key.WithHelp("Ctrl+U", "退出"), ), ScrollUp: key.NewBinding( key.WithKeys("up", "k"), - key.WithHelp("up/k", "up"), + key.WithHelp("Up/k", "向上滚动"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("down/j", "down"), + key.WithHelp("Down/j", "向下滚动"), ), PageUp: key.NewBinding( key.WithKeys("pgup", "b"), - key.WithHelp("pgup", "page up"), + key.WithHelp("PgUp/b", "向上翻页"), ), PageDown: key.NewBinding( key.WithKeys("pgdown", "f"), - key.WithHelp("pgdn", "page down"), + key.WithHelp("PgDn/f", "向下翻页"), ), Top: key.NewBinding( key.WithKeys("g", "home"), - key.WithHelp("g", "top"), + key.WithHelp("g/Home", "跳到顶部"), ), Bottom: key.NewBinding( key.WithKeys("G", "end"), - key.WithHelp("G", "bottom"), + key.WithHelp("G/End", "跳到底部"), ), } } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Send, k.NewSession, k.NextPanel, k.ToggleHelp, k.Quit} + return []key.Binding{k.Send, k.CancelAgent, k.NewSession, k.ToggleHelp, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Send, k.NewSession, k.OpenSession, k.FocusInput}, - {k.NextPanel, k.PrevPanel, k.ToggleHelp, k.Quit}, - {k.ScrollUp, k.ScrollDown, k.PageUp, k.PageDown}, - {k.Top, k.Bottom}, + {k.Send, k.CancelAgent, k.NewSession, k.OpenSession}, + {k.FocusInput, k.NextPanel, k.PrevPanel, k.ToggleHelp}, + {k.Quit, k.ScrollUp, k.ScrollDown, k.PageUp}, + {k.PageDown, k.Top, k.Bottom}, } } diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go new file mode 100644 index 00000000..c2a5ea1e --- /dev/null +++ b/internal/tui/provider_service.go @@ -0,0 +1,14 @@ +package tui + +import ( + "context" + + "neo-code/internal/provider" +) + +type ProviderController interface { + ListProviders(ctx context.Context) ([]provider.ProviderCatalogItem, error) + SelectProvider(ctx context.Context, providerID string) (provider.ProviderSelection, error) + ListModels(ctx context.Context) ([]provider.ModelDescriptor, error) + SetCurrentModel(ctx context.Context, modelID string) (provider.ProviderSelection, error) +} diff --git a/internal/tui/state.go b/internal/tui/state.go index bfa1a6e9..2bd56b35 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -10,7 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - agentruntime "github.com/dust/neo-code/internal/runtime" + agentruntime "neo-code/internal/runtime" ) type panel int @@ -22,6 +22,14 @@ const ( panelInput ) +type pickerMode int + +const ( + pickerNone pickerMode = iota + pickerProvider + pickerModel +) + type UIState struct { Sessions []agentruntime.SessionSummary ActiveSessionID string @@ -36,7 +44,7 @@ type UIState struct { CurrentModel string CurrentWorkdir string ShowHelp bool - ShowModelPicker bool + ActivePicker pickerMode Focus panel } @@ -58,6 +66,7 @@ func (s sessionItem) FilterValue() string { } type modelItem struct { + id string name string description string } @@ -71,7 +80,25 @@ func (m modelItem) Description() string { } func (m modelItem) FilterValue() string { - return strings.ToLower(m.name + " " + m.description) + return strings.ToLower(m.id + " " + m.name + " " + m.description) +} + +type providerItem struct { + id string + name string + description string +} + +func (p providerItem) Title() string { + return p.name +} + +func (p providerItem) Description() string { + return p.description +} + +func (p providerItem) FilterValue() string { + return strings.ToLower(p.id + " " + p.name + " " + p.description) } type sessionDelegate struct { @@ -108,19 +135,22 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. prefix = ">" } - content := lipgloss.JoinVertical( - lipgloss.Left, - fmt.Sprintf("%s %s", prefix, title), - d.styles.sessionMeta.Render(" "+meta), - ) - style := d.styles.sessionRow + metaStyle := d.styles.sessionMeta if session.Active { style = d.styles.sessionRowActive + metaStyle = d.styles.sessionMetaActive } if index == m.Index() { style = d.styles.sessionRowFocused + metaStyle = d.styles.sessionMetaFocus } + content := lipgloss.JoinVertical( + lipgloss.Left, + fmt.Sprintf("%s %s", prefix, title), + metaStyle.Render(" "+meta), + ) + fmt.Fprint(w, style.Width(width).Render(content)) } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ccbd99e1..a0326dcb 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -7,23 +7,27 @@ import ( ) const ( - colorPrimary = "#CBA6F7" - colorUser = "#89B4FA" - colorBorder = "#45475A" - colorError = "#F38BA8" - colorSuccess = "#A6E3A1" - colorText = "#CDD6F4" - colorSubtle = "#7F849C" - colorBg = "#11111B" - colorPanel = "#181825" - colorCode = "#1E1E2E" - colorInk = "#11111B" - colorWarning = "#F9E2AF" + colorPrimary = "#7AA2F7" + colorUser = "#4FD6BE" + colorBorder = "#2B3342" + colorError = "#F7768E" + colorSuccess = "#73DACA" + colorText = "#DCE3F0" + colorSubtle = "#7E8AA3" + colorBg = "#090C12" + colorPanel = "#11161F" + colorPanelAlt = "#151C27" + colorCode = "#0E131B" + colorInk = "#081018" + colorWarning = "#E0AF68" ) type styles struct { doc lipgloss.Style + headerBar lipgloss.Style headerBrand lipgloss.Style + headerLabel lipgloss.Style + headerPath lipgloss.Style headerSub lipgloss.Style headerMeta lipgloss.Style headerSpacer lipgloss.Style @@ -37,6 +41,8 @@ type styles struct { sessionRowActive lipgloss.Style sessionRowFocused lipgloss.Style sessionMeta lipgloss.Style + sessionMetaActive lipgloss.Style + sessionMetaFocus lipgloss.Style streamTitle lipgloss.Style streamMeta lipgloss.Style streamContent lipgloss.Style @@ -56,8 +62,10 @@ type styles struct { commandUsage lipgloss.Style commandUsageMatch lipgloss.Style commandDesc lipgloss.Style - inputMeta lipgloss.Style + inputPrefix lipgloss.Style inputLine lipgloss.Style + inputBox lipgloss.Style + inputBoxFocused lipgloss.Style footer lipgloss.Style badgeUser lipgloss.Style badgeAgent lipgloss.Style @@ -69,27 +77,35 @@ type styles struct { func newStyles() styles { panel := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). + Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color(colorBorder)). - Background(lipgloss.Color(colorPanel)). + UnsetBackground(). Padding(0, 1) return styles{ doc: lipgloss.NewStyle(). - Padding(1, 2). - Background(lipgloss.Color(colorBg)). + Padding(1, 2, 0, 2). + UnsetBackground(). + Foreground(lipgloss.Color(colorText)), + headerBar: lipgloss.NewStyle(). + UnsetBackground(). Foreground(lipgloss.Color(colorText)), headerBrand: lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color(colorInk)). - Background(lipgloss.Color(colorPrimary)). + Foreground(lipgloss.Color(colorPrimary)). + UnsetBackground(). Padding(0, 1), + headerLabel: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorSubtle)), + headerPath: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorText)), headerSub: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), headerMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(colorText)), headerSpacer: lipgloss.NewStyle(). - Width(2), + Width(1). + UnsetBackground(), panel: panel, panelFocused: panel.Copy(). BorderForeground(lipgloss.Color(colorPrimary)), @@ -99,62 +115,78 @@ func newStyles() styles { panelSubtitle: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), panelBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + UnsetBackground(), empty: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). Padding(1, 0), sessionRow: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + UnsetBackground(), sessionRowActive: lipgloss.NewStyle(). Padding(0, 1). Foreground(lipgloss.Color(colorText)). - Background(lipgloss.Color(colorCode)), + UnsetBackground(), sessionRowFocused: lipgloss.NewStyle(). Padding(0, 1). - Foreground(lipgloss.Color(colorInk)). - Background(lipgloss.Color(colorPrimary)). + Foreground(lipgloss.Color(colorText)). + UnsetBackground(). Bold(true), sessionMeta: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), + sessionMetaActive: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#94A0B8")), + sessionMetaFocus: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#AFC3FF")), streamTitle: lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(colorText)), streamMeta: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), streamContent: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), - messageUserTag: tagStyle(colorUser, colorInk), - messageAgentTag: tagStyle(colorPrimary, colorInk), - messageToolTag: tagStyle(colorSuccess, colorInk), + Foreground(lipgloss.Color(colorText)). + UnsetBackground(), + messageUserTag: tagStyle(colorUser), + messageAgentTag: tagStyle(colorPrimary), + messageToolTag: tagStyle(colorSuccess), messageBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - PaddingLeft(1), + Background(lipgloss.Color(colorPanel)). + Padding(0, 1), messageUserBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - PaddingLeft(1), + Background(lipgloss.Color("#12202D")). + Padding(0, 1), messageToolBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSuccess)). - PaddingLeft(1), + Background(lipgloss.Color("#0F1B1C")). + Padding(0, 1), inlineNotice: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorPanel)). + Padding(0, 1). Italic(true), inlineError: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorError)). + Background(lipgloss.Color("#21131A")). + Padding(0, 1). Bold(true), inlineSystem: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorPanel)). + Padding(0, 1), codeBlock: lipgloss.NewStyle(). MarginLeft(1). Padding(0, 1). Background(lipgloss.Color(colorCode)). BorderLeft(true). BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color(colorBorder)), + BorderForeground(lipgloss.Color(colorPrimary)), codeText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("#BAC2DE")), + Foreground(lipgloss.Color(colorText)), commandMenu: lipgloss.NewStyle(). - Background(lipgloss.Color(colorCode)). + UnsetBackground(). Padding(1, 1), commandMenuTitle: lipgloss.NewStyle(). Bold(true). @@ -166,34 +198,46 @@ func newStyles() styles { Foreground(lipgloss.Color(colorPrimary)), commandDesc: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), - inputMeta: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorSubtle)), + inputPrefix: lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorUser)). + Bold(true), inputLine: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)), + inputBox: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorBorder)). + UnsetBackground(). + Padding(0, 1), + inputBoxFocused: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorPrimary)). + UnsetBackground(). + Padding(0, 1), footer: lipgloss.NewStyle(). - PaddingTop(1). - Foreground(lipgloss.Color(colorSubtle)), - badgeUser: badge(colorUser, colorInk), - badgeAgent: badge(colorPrimary, colorInk), - badgeSuccess: badge(colorSuccess, colorInk), - badgeWarning: badge(colorWarning, colorInk), - badgeError: badge(colorError, colorInk), - badgeMuted: badge(colorBorder, colorText), + Foreground(lipgloss.Color(colorSubtle)). + UnsetBackground(), + badgeUser: badge("#12202D", colorUser), + badgeAgent: badge("#16233A", colorPrimary), + badgeSuccess: badge("#102018", colorSuccess), + badgeWarning: badge("#241B10", colorWarning), + badgeError: badge("#26131A", colorError), + badgeMuted: badge(colorPanelAlt, colorSubtle), } } -func tagStyle(bg string, fg string) lipgloss.Style { +func tagStyle(fg string) lipgloss.Style { return lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color(fg)). - Background(lipgloss.Color(bg)). + Background(lipgloss.Color(colorBg)). Padding(0, 1) } func badge(bg string, fg string) lipgloss.Style { return lipgloss.NewStyle(). + Bold(true). Foreground(lipgloss.Color(fg)). - Background(lipgloss.Color(bg)). + UnsetBackground(). Padding(0, 1) } diff --git a/internal/tui/update.go b/internal/tui/update.go index 3a4e0c51..37692d10 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -2,17 +2,20 @@ package tui import ( "context" + "errors" + "fmt" "strings" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - agentruntime "github.com/dust/neo-code/internal/runtime" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" ) type RuntimeMsg struct{ Event agentruntime.RuntimeEvent } @@ -56,9 +59,14 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.IsAgentRunning = false a.state.StreamingReply = false a.state.CurrentTool = "" - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendInlineMessage(roleError, typed.err.Error()) + if errors.Is(typed.err, context.Canceled) { + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + } else { + a.state.ExecutionError = typed.err.Error() + a.state.StatusText = typed.err.Error() + a.appendInlineMessage(roleError, typed.err.Error()) + } } _ = a.refreshSessions() a.syncActiveSessionTitle() @@ -74,7 +82,19 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StatusText = typed.notice cfg := a.configManager.Get() a.syncConfigState(cfg) - a.selectCurrentModel(cfg.CurrentModel) + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + } else { + a.selectCurrentProvider(cfg.SelectedProvider) + a.selectCurrentModel(cfg.CurrentModel) + } a.appendInlineMessage(roleSystem, typed.notice) } a.rebuildTranscript() @@ -89,8 +109,15 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.resizeComponents() return a, tea.Batch(cmds...) } - if a.state.ShowModelPicker { - return a.updateModelPicker(typed) + if a.state.IsAgentRunning && key.Matches(typed, a.keys.CancelAgent) { + if a.runtime.CancelActiveRun() { + a.state.StatusText = statusCanceling + } + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } + if a.state.ActivePicker != pickerNone { + return a.updatePicker(typed) } if key.Matches(typed, a.keys.NextPanel) { a.focusNext() @@ -122,7 +149,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch a.focus { case panelSessions: - if key.Matches(typed, a.keys.OpenSession) && !a.isFilteringSessions() { + if key.Matches(typed, a.keys.OpenSession) && !a.sessions.SettingFilter() { if err := a.activateSelectedSession(); err != nil { a.state.StatusText = err.Error() a.state.ExecutionError = err.Error() @@ -135,6 +162,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } var cmd tea.Cmd a.sessions, cmd = a.sessions.Update(msg) + a.sessions.SetShowFilter(a.sessions.FilterState() != list.Unfiltered) cmds = append(cmds, cmd) return a, tea.Batch(cmds...) case panelTranscript: @@ -159,14 +187,34 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.InputText = "" switch strings.ToLower(input) { + case slashCommandProviderPick: + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } + a.openProviderPicker() + return a, tea.Batch(cmds...) case slashCommandModelPicker: + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendInlineMessage(roleError, err.Error()) + a.rebuildTranscript() + return a, tea.Batch(cmds...) + } a.openModelPicker() return a, tea.Batch(cmds...) } if strings.HasPrefix(input, slashPrefix) { - a.state.StatusText = statusApplyingCommand - cmds = append(cmds, runLocalCommand(a.configManager, input)) + err := fmt.Sprintf("unknown command %q", input) + a.state.ExecutionError = err + a.state.StatusText = err + a.appendInlineMessage(roleError, err) + a.rebuildTranscript() return a, tea.Batch(cmds...) } @@ -189,22 +237,37 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } -func (a App) updateModelPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { +func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, a.keys.FocusInput): - a.closeModelPicker() + a.closePicker() return a, nil case msg.String() == "enter": - item, ok := a.modelPicker.SelectedItem().(modelItem) - a.closeModelPicker() - if !ok { - return a, nil + switch a.state.ActivePicker { + case pickerProvider: + item, ok := a.providerPicker.SelectedItem().(providerItem) + a.closePicker() + if !ok { + return a, nil + } + return a, runProviderSelection(a.providerSvc, item.name) + case pickerModel: + item, ok := a.modelPicker.SelectedItem().(modelItem) + a.closePicker() + if !ok { + return a, nil + } + return a, runModelSelection(a.providerSvc, item.id) } - return a, runModelSelection(a.configManager, item.name) } var cmd tea.Cmd - a.modelPicker, cmd = a.modelPicker.Update(msg) + switch a.state.ActivePicker { + case pickerProvider: + a.providerPicker, cmd = a.providerPicker.Update(msg) + case pickerModel: + a.modelPicker, cmd = a.modelPicker.Update(msg) + } return a, cmd } @@ -339,6 +402,13 @@ func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) { if payload, ok := event.Payload.(provider.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: payload.Content}) } + case agentruntime.EventRunCanceled: + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + a.appendInlineMessage(roleSystem, "Canceled current run.") case agentruntime.EventError: a.state.StatusText = statusError a.state.IsAgentRunning = false @@ -436,7 +506,7 @@ func (a *App) focusPrev() { func (a *App) applyFocus() { a.state.Focus = a.focus - if a.focus == panelInput && !a.state.ShowModelPicker { + if a.focus == panelInput && a.state.ActivePicker == pickerNone { a.input.Focus() return } @@ -446,12 +516,18 @@ func (a *App) applyFocus() { func (a *App) resizeComponents() { lay := a.computeLayout() a.help.ShowAll = a.state.ShowHelp - a.sessions.SetSize(max(20, lay.sidebarWidth-4), max(4, lay.sidebarHeight-4)) + sidebarFrameWidth := a.styles.panelFocused.GetHorizontalFrameSize() + sidebarFrameHeight := a.styles.panelFocused.GetVerticalFrameSize() + sidebarBodyWidth := max(14, lay.sidebarWidth-sidebarFrameWidth) + sidebarBodyHeight := max(4, lay.sidebarHeight-sidebarFrameHeight-lipgloss.Height(a.renderSidebarHeader(sidebarBodyWidth))) + a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) a.transcript.Width = max(24, lay.rightWidth) - a.transcript.Height = max(6, lay.rightHeight-2-menuHeight-1) - a.input.SetWidth(max(24, lay.rightWidth-2)) - a.input.SetHeight(1) + promptInnerWidth := max(8, lay.rightWidth-a.styles.inputBoxFocused.GetHorizontalFrameSize()) + a.input.Width = max(4, promptInnerWidth-lipgloss.Width("> ")) + promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) + a.transcript.Height = max(6, lay.rightHeight-menuHeight-promptHeight) + a.providerPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, clamp(lay.rightWidth-14, 28, 52)), max(4, clamp(lay.rightHeight-10, 6, 10))) a.rebuildTranscript() } diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 74596fe1..5a62ad72 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -9,21 +9,26 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/config" - "github.com/dust/neo-code/internal/provider" - agentruntime "github.com/dust/neo-code/internal/runtime" - "github.com/dust/neo-code/internal/tools" + "neo-code/internal/config" + "neo-code/internal/provider" + "neo-code/internal/provider/builtin" + provideropenai "neo-code/internal/provider/openai" + agentruntime "neo-code/internal/runtime" + "neo-code/internal/tools" ) type stubRuntime struct { - runInputs []agentruntime.UserInput - events chan agentruntime.RuntimeEvent - sessions []agentruntime.SessionSummary - loads map[string]agentruntime.Session - runErr error - listErr error - loadErr error + runInputs []agentruntime.UserInput + events chan agentruntime.RuntimeEvent + sessions []agentruntime.SessionSummary + loads map[string]agentruntime.Session + runErr error + listErr error + loadErr error + cancelCalls int + cancelResult bool } func newStubRuntime() *stubRuntime { @@ -42,6 +47,11 @@ func (r *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { return r.events } +func (r *stubRuntime) CancelActiveRun() bool { + r.cancelCalls++ + return r.cancelResult +} + func (r *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { if r.listErr != nil { return nil, r.listErr @@ -66,26 +76,37 @@ func TestAppUpdateComposerCommands(t *testing.T) { assert func(t *testing.T, beforeRuntime *stubRuntime, manager *config.Manager, app App) }{ { - name: "slash set url updates config and does not start runtime", - input: "/set url https://test.com", + name: "unknown slash command stays local and surfaces error", + input: "/unknown", assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { t.Helper() if len(runtime.runInputs) != 0 { t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) } - cfg := manager.Get() - selected, err := cfg.SelectedProviderConfig() - if err != nil { - t.Fatalf("SelectedProviderConfig() error = %v", err) - } - if selected.BaseURL != "https://test.com" { - t.Fatalf("expected base url updated to https://test.com, got %q", selected.BaseURL) + if !strings.Contains(app.state.StatusText, "unknown command") { + t.Fatalf("expected unknown command error, got %q", app.state.StatusText) } if app.state.IsAgentRunning { t.Fatalf("expected agent to stay idle") } }, }, + { + name: "provider command opens picker and does not start runtime", + input: "/provider", + assert: func(t *testing.T, runtime *stubRuntime, manager *config.Manager, app App) { + t.Helper() + if len(runtime.runInputs) != 0 { + t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) + } + if app.state.ActivePicker != pickerProvider { + t.Fatalf("expected provider picker to open") + } + if app.state.StatusText != statusChooseProvider { + t.Fatalf("expected status %q, got %q", statusChooseProvider, app.state.StatusText) + } + }, + }, { name: "model command opens picker and does not start runtime", input: "/model", @@ -94,7 +115,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { if len(runtime.runInputs) != 0 { t.Fatalf("expected runtime not to run, got %d calls", len(runtime.runInputs)) } - if !app.state.ShowModelPicker { + if app.state.ActivePicker != pickerModel { t.Fatalf("expected model picker to open") } if app.state.StatusText != statusChooseModel { @@ -110,7 +131,7 @@ func TestAppUpdateComposerCommands(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -141,13 +162,13 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { { name: "escape closes model picker", setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ShowModelPicker = true + app.state.ActivePicker = pickerModel app.focus = panelInput }, msg: tea.KeyMsg{Type: tea.KeyEsc}, assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { t.Helper() - if app.state.ShowModelPicker { + if app.state.ActivePicker != pickerNone { t.Fatalf("expected model picker to close") } if app.state.Focus != panelInput { @@ -204,6 +225,26 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { } }, }, + { + name: "runtime canceled clears running state without error", + setup: func(t *testing.T, app *App, runtime *stubRuntime) { + app.state.IsAgentRunning = true + app.state.ActiveSessionID = "session-cancel" + }, + msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventRunCanceled, + SessionID: "session-cancel", + }}, + assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { + t.Helper() + if app.state.IsAgentRunning { + t.Fatalf("expected agent to stop running") + } + if app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled status without error, got %+v", app.state) + } + }, + }, { name: "runtime tool result error is surfaced", setup: func(t *testing.T, app *App, runtime *stubRuntime) { @@ -235,7 +276,7 @@ func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -266,7 +307,7 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } runtime.loads[now.ID] = now - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -275,8 +316,8 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { } app.openModelPicker() - app.closeModelPicker() - app.selectCurrentModel(config.DefaultOpenAIModel) + app.closePicker() + app.selectCurrentModel(provideropenai.DefaultModel) app.appendAssistantChunk("hello") app.appendAssistantChunk(" world") if !app.lastAssistantMatches("hello world") { @@ -299,17 +340,25 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { app.syncConfigState(manager.Get()) app.rebuildTranscript() - if app.View() == "" { + view := app.View() + if view == "" { t.Fatalf("expected non-empty View()") } - if app.renderHeader() == "" || app.renderBody(app.computeLayout()) == "" { + if lipgloss.Height(view) > app.height+1 { + t.Fatalf("expected view height to stay within window bounds, got %d", lipgloss.Height(view)) + } + lines := strings.Split(strings.TrimRight(view, "\n"), "\n") + if len(lines) == 0 || !strings.Contains(lines[len(lines)-1], "Ctrl+U") { + t.Fatalf("expected footer help to render on the last visible line") + } + if app.renderHeader(app.computeLayout().contentWidth) == "" || app.renderBody(app.computeLayout()) == "" { t.Fatalf("expected non-empty render output") } - app.state.ShowModelPicker = true - if app.renderModelPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { + app.state.ActivePicker = pickerModel + if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { t.Fatalf("expected model picker rendering") } - app.state.ShowModelPicker = false + app.state.ActivePicker = pickerNone if app.renderCommandMenu(80) == "" { app.input.SetValue("/") app.state.InputText = "/" @@ -320,6 +369,22 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if app.renderPrompt(80) == "" || app.renderHelp(80) == "" { t.Fatalf("expected prompt and help output") } + if lipgloss.Width(app.renderPrompt(80)) != 80 { + t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) + } + sidebar := app.renderSidebar(26, 12) + if lipgloss.Width(sidebar) != 26 || lipgloss.Height(sidebar) != 12 { + t.Fatalf("expected sidebar to respect requested dimensions, got %dx%d", lipgloss.Width(sidebar), lipgloss.Height(sidebar)) + } + if !strings.Contains(app.renderSidebar(26, 12), sidebarTitle) || !strings.Contains(app.renderSidebar(26, 12), sidebarOpenHint) { + t.Fatalf("expected updated sidebar header text") + } + if strings.Contains(app.renderPrompt(80), "Enter/Ctrl+S") { + t.Fatalf("expected composer hint line to be removed") + } + if strings.TrimSpace(app.renderPrompt(80)) == "" { + t.Fatalf("expected prompt to render a visible border") + } if app.focusLabel() == "" || app.statusBadge("ready") == "" { t.Fatalf("expected status helpers to render") } @@ -362,6 +427,9 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { if !app.isFilteringSessions() { t.Fatalf("expected filtering state") } + if app.sessions.ShowPagination() { + t.Fatalf("expected sessions pagination to stay hidden") + } } func TestTUIStandaloneHelpers(t *testing.T) { @@ -402,7 +470,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("expected delegate update to return nil") } var buf bytes.Buffer - model := newModelPicker() + model := newModelPicker([]provider.ModelDescriptor{{ID: "gpt-4.1", Name: "gpt-4.1"}}) sessionList := []list.Item{sItem} listModel := list.New(sessionList, delegate, 30, 10) delegate.Render(&buf, listModel, 0, sItem) @@ -430,7 +498,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } manager := newTestConfigManager(t) - msg := runModelSelection(manager, "gpt-5.4")() + msg := runModelSelection(newTestProviderService(t, manager), provideropenai.DefaultModel)() if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } @@ -469,6 +537,20 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "run finished canceled is not surfaced as error", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.IsAgentRunning = true + app.state.StatusText = statusCanceling + }, + msg: runFinishedMsg{err: context.Canceled}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if app.state.IsAgentRunning || app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled run to stop without error, got %+v", app.state) + } + }, + }, { name: "run finished error is surfaced", setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { @@ -483,7 +565,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "local command success updates state", + name: "model selection success updates state", msg: localCommandResultMsg{notice: "[System] ok"}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() @@ -493,7 +575,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, }, { - name: "local command error updates state", + name: "model selection error updates state", msg: localCommandResultMsg{err: context.Canceled}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() @@ -502,9 +584,28 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { } }, }, + { + name: "cancel shortcut interrupts running agent", + setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { + app.state.IsAgentRunning = true + app.state.StatusText = statusThinking + runtime.cancelResult = true + app.keys.CancelAgent.SetKeys("ctrl+@") + }, + msg: tea.KeyMsg{Type: tea.KeyCtrlAt}, + assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { + t.Helper() + if runtime.cancelCalls != 1 { + t.Fatalf("expected cancel to be called once, got %d", runtime.cancelCalls) + } + if app.state.StatusText != statusCanceling { + t.Fatalf("expected canceling status, got %q", app.state.StatusText) + } + }, + }, { name: "toggle help flips state", - msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}}, + msg: tea.KeyMsg{Type: tea.KeyCtrlQ}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if !app.state.ShowHelp || !app.help.ShowAll { @@ -627,7 +728,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, { name: "quit returns quit command", - msg: tea.KeyMsg{Type: tea.KeyCtrlC}, + msg: tea.KeyMsg{Type: tea.KeyCtrlU}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() foundQuit := false @@ -648,7 +749,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -665,22 +766,28 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.CurrentModel = "unsupported-current" + return nil + }); err != nil { + t.Fatalf("set unsupported current model: %v", err) + } runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } app.openModelPicker() - if len(app.modelPicker.Items()) < 2 { + if len(app.modelPicker.Items()) == 0 { t.Fatalf("expected model picker catalog") } - selected := app.modelPicker.Items()[1].(modelItem).name - app.modelPicker.Select(1) + selected := app.modelPicker.Items()[0].(modelItem).id + app.modelPicker.Select(0) model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) app = model.(App) - if app.state.ShowModelPicker { + if app.state.ActivePicker != pickerNone { t.Fatalf("expected picker to close after selection") } @@ -696,6 +803,98 @@ func TestAppUpdateModelPickerEnterAppliesSelection(t *testing.T) { } } +func TestAppUpdateProviderPickerEnterAppliesSelection(t *testing.T) { + manager := newTestConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Providers = append(cfg.Providers, config.ProviderConfig{ + Name: "openai-alt", + Driver: provideropenai.DriverName, + BaseURL: provideropenai.DefaultBaseURL, + Model: "gpt-4o", + Models: []string{"gpt-4o"}, + APIKeyEnv: provideropenai.DefaultAPIKeyEnv, + }) + cfg.CurrentModel = "unsupported-current" + return nil + }); err != nil { + t.Fatalf("append provider: %v", err) + } + + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.openProviderPicker() + if len(app.providerPicker.Items()) < 2 { + t.Fatalf("expected provider picker catalog") + } + selectedIndex := -1 + selected := "" + for idx, item := range app.providerPicker.Items() { + candidate, ok := item.(providerItem) + if !ok { + continue + } + if candidate.id == "openai-alt" { + selectedIndex = idx + selected = candidate.id + break + } + } + if selectedIndex < 0 { + t.Fatalf("expected provider picker to include openai-alt") + } + app.providerPicker.Select(selectedIndex) + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected picker to close after selection") + } + + for _, msg := range collectTeaMessages(cmd) { + model, follow := app.Update(msg) + app = model.(App) + _ = collectTeaMessages(follow) + } + + cfg := manager.Get() + if cfg.SelectedProvider != selected { + t.Fatalf("expected selected provider %q, got %q", selected, cfg.SelectedProvider) + } + if cfg.CurrentModel != "gpt-4o" { + t.Fatalf("expected current model to follow provider default, got %q", cfg.CurrentModel) + } +} + +func TestRefreshPickerKeepsSizeAfterRebuild(t *testing.T) { + manager := newTestConfigManager(t) + runtime := newStubRuntime() + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + app.providerPicker.SetSize(32, 9) + app.modelPicker.SetSize(28, 7) + + if err := app.refreshProviderPicker(); err != nil { + t.Fatalf("refreshProviderPicker() error = %v", err) + } + if err := app.refreshModelPicker(); err != nil { + t.Fatalf("refreshModelPicker() error = %v", err) + } + + if app.providerPicker.Width() != 32 || app.providerPicker.Height() != 9 { + t.Fatalf("expected provider picker size 32x9, got %dx%d", app.providerPicker.Width(), app.providerPicker.Height()) + } + if app.modelPicker.Width() != 28 || app.modelPicker.Height() != 7 { + t.Fatalf("expected model picker size 28x7, got %dx%d", app.modelPicker.Width(), app.modelPicker.Height()) + } +} + func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { tests := []struct { name string @@ -766,6 +965,21 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { } }, }, + { + name: "run canceled event clears current tool and error state", + setup: func(app *App) { + app.state.IsAgentRunning = true + app.state.CurrentTool = "filesystem_edit" + app.state.ExecutionError = "old" + }, + event: agentruntime.RuntimeEvent{Type: agentruntime.EventRunCanceled, SessionID: "s1"}, + assert: func(t *testing.T, app App) { + t.Helper() + if app.state.IsAgentRunning || app.state.CurrentTool != "" || app.state.ExecutionError != "" || app.state.StatusText != statusCanceled { + t.Fatalf("unexpected canceled state: %+v", app.state) + } + }, + }, } for _, tt := range tests { @@ -773,7 +987,7 @@ func TestAppHandleRuntimeEventAdditionalBranches(t *testing.T) { t.Run(tt.name, func(t *testing.T) { manager := newTestConfigManager(t) runtime := newStubRuntime() - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -792,7 +1006,7 @@ func TestAppRefreshErrorPaths(t *testing.T) { runtime := newStubRuntime() runtime.listErr = context.DeadlineExceeded - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err == nil || !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { t.Fatalf("expected list session error during New, got %v", err) } @@ -804,7 +1018,7 @@ func TestAppRefreshErrorPaths(t *testing.T) { runtime := newStubRuntime() runtime.loadErr = context.Canceled - app, err := New(nil, manager, runtime) + app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) if err != nil { t.Fatalf("New() error = %v", err) } @@ -819,13 +1033,22 @@ func TestAppRefreshErrorPaths(t *testing.T) { func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir())) + manager := config.NewManager(config.NewLoader(t.TempDir(), builtin.DefaultConfig())) if _, err := manager.Load(context.Background()); err != nil { t.Fatalf("load config: %v", err) } return manager } +func newTestProviderService(t *testing.T, manager *config.Manager) *provider.Service { + t.Helper() + registry, err := builtin.NewRegistry() + if err != nil { + t.Fatalf("register provider drivers: %v", err) + } + return provider.NewService(manager, registry) +} + func collectTeaMessages(cmd tea.Cmd) []tea.Msg { if cmd == nil { return nil diff --git a/internal/tui/view.go b/internal/tui/view.go index d7490ba9..a20bc2c4 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,13 +1,12 @@ package tui import ( - "fmt" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" - "github.com/dust/neo-code/internal/provider" + "neo-code/internal/provider" ) type layout struct { @@ -18,21 +17,32 @@ type layout struct { sidebarHeight int rightWidth int rightHeight int + bodyGap int } func (a App) View() string { - if a.width < 84 || a.height < 24 { - return a.styles.doc.Render("Window too small.\nPlease resize to at least 84x24.") + docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) + docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) + if docWidth < 84 || docHeight < 24 { + return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, "Window too small.\nPlease resize to at least 84x24.")), "\n") } lay := a.computeLayout() - header := a.renderHeader() + header := a.renderHeader(lay.contentWidth) body := a.renderBody(lay) helpView := a.renderHelp(lay.contentWidth) - return a.styles.doc.Render(lipgloss.JoinVertical(lipgloss.Left, header, body, helpView)) + usedHeight := lipgloss.Height(header) + lipgloss.Height(body) + lipgloss.Height(helpView) + spacerHeight := max(0, docHeight-usedHeight) + parts := []string{header, body} + if spacerHeight > 0 { + parts = append(parts, lipgloss.NewStyle().Height(spacerHeight).Render("")) + } + parts = append(parts, helpView) + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + return strings.TrimRight(a.styles.doc.Render(lipgloss.Place(docWidth, docHeight, lipgloss.Left, lipgloss.Top, content)), "\n") } -func (a App) renderHeader() string { +func (a App) renderHeader(width int) string { status := a.state.StatusText if a.state.IsAgentRunning { status = a.spinner.View() + " " + fallback(status, statusRunning) @@ -41,28 +51,35 @@ func (a App) renderHeader() string { brand := lipgloss.JoinHorizontal( lipgloss.Center, a.styles.headerBrand.Render("NeoCode"), - a.styles.headerSpacer.Render(""), - a.styles.headerSub.Render("immersive coding agent"), ) meta := lipgloss.JoinHorizontal( lipgloss.Top, - a.styles.badgeAgent.Render("Provider "+a.state.CurrentProvider), - a.styles.badgeUser.Render("Model "+a.state.CurrentModel), - a.styles.badgeMuted.Render("Focus "+a.focusLabel()), + a.styles.badgeAgent.Render(a.state.CurrentProvider), + a.styles.badgeUser.Render(a.state.CurrentModel), + a.styles.badgeMuted.Render(a.focusLabel()), a.statusBadge(status), ) - return lipgloss.JoinVertical( - lipgloss.Left, + spacerWidth := lipgloss.Width(a.styles.headerSpacer.Render("")) + workdirLabel := a.styles.headerLabel.Render("Workdir") + workdirWidth := max(12, width-lipgloss.Width(brand)-lipgloss.Width(meta)-(spacerWidth*2)-lipgloss.Width(workdirLabel)-1) + workdir := lipgloss.JoinHorizontal( + lipgloss.Center, + workdirLabel, + " ", + a.styles.headerPath.Render(trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + ) + + header := lipgloss.JoinHorizontal( + lipgloss.Center, brand, - lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.headerMeta.Render("Workdir "+trimMiddle(a.state.CurrentWorkdir, max(28, a.width/3))), - a.styles.headerSpacer.Render(""), - meta, - ), + a.styles.headerSpacer.Render(""), + workdir, + a.styles.headerSpacer.Render(""), + meta, ) + return a.styles.headerBar.Width(width).Render(lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, header)) } func (a App) renderBody(lay layout) string { @@ -71,100 +88,115 @@ func (a App) renderBody(lay layout) string { if lay.stacked { return lipgloss.JoinVertical(lipgloss.Left, sidebar, stream) } - return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, stream) + return lipgloss.JoinHorizontal( + lipgloss.Top, + sidebar, + lipgloss.NewStyle().Width(lay.bodyGap).Render(""), + stream, + ) } func (a App) renderSidebar(width int, height int) string { - return a.renderPanel(sidebarTitle, sidebarSubtitle, a.sessions.View(), width, height, a.focus == panelSessions) + style := a.styles.panel + if a.focus == panelSessions { + style = a.styles.panelFocused + } + + frameHeight := style.GetVerticalFrameSize() + borderWidth := 2 + paddingWidth := style.GetHorizontalFrameSize() - borderWidth + panelWidth := max(1, width-borderWidth) + bodyWidth := max(10, panelWidth-paddingWidth) + header := a.renderSidebarHeader(bodyWidth) + bodyHeight := max(3, height-frameHeight-lipgloss.Height(header)) + a.sessions.SetSize(bodyWidth, bodyHeight) + body := a.styles.panelBody.Width(bodyWidth).Height(bodyHeight).Render(a.sessions.View()) + + panel := style.Width(panelWidth).Height(max(1, height-frameHeight)).Render(lipgloss.JoinVertical(lipgloss.Left, header, body)) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } func (a App) renderWaterfall(width int, height int) string { - if a.state.ShowModelPicker { + if a.state.ActivePicker != pickerNone { return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, - a.renderModelPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), + a.renderPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), ) } - header := lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.streamTitle.Render(fallback(a.state.ActiveSessionTitle, draftSessionTitle)), - a.styles.headerSpacer.Render(""), - a.styles.streamMeta.Render(fmt.Sprintf("%d messages", len(a.activeMessages))), - ) - subline := lipgloss.JoinHorizontal( - lipgloss.Top, - a.styles.streamMeta.Render("Active model "+a.state.CurrentModel), - a.styles.headerSpacer.Render(""), - a.styles.streamMeta.Render(fallback(a.state.CurrentTool, a.state.StatusText)), - ) transcript := a.styles.streamContent.Width(width).Height(a.transcript.Height).Render(a.transcript.View()) - parts := []string{header, subline, transcript} + parts := []string{transcript} if menu := a.renderCommandMenu(width); menu != "" { parts = append(parts, menu) } parts = append(parts, a.renderPrompt(width)) - return lipgloss.NewStyle().Width(width).Height(height).Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, parts...)) } -func (a App) renderModelPicker(width int, height int) string { +func (a App) renderPicker(width int, height int) string { + frameHeight := a.styles.panelFocused.GetVerticalFrameSize() + title := modelPickerTitle + subtitle := modelPickerSubtitle + body := a.modelPicker.View() + if a.state.ActivePicker == pickerProvider { + title = providerPickerTitle + subtitle = providerPickerSubtitle + body = a.providerPicker.View() + } content := lipgloss.JoinVertical( lipgloss.Left, - a.styles.panelTitle.Render(modelPickerTitle), - a.styles.panelSubtitle.Render(modelPickerSubtitle), - a.modelPicker.View(), + a.styles.panelTitle.Render(title), + a.styles.panelSubtitle.Render(subtitle), + body, ) - return a.styles.panelFocused.Width(width).Height(height).Render(content) + panel := a.styles.panelFocused. + Width(max(1, width-2)). + Height(max(1, height-frameHeight)). + Render(content) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } func (a App) renderPrompt(width int) string { - return lipgloss.JoinVertical( - lipgloss.Left, - a.styles.inputMeta.Render(composerHintText), - a.styles.inputLine.Width(width).Render(a.input.View()), - ) -} - -func (a App) renderCommandMenu(width int) string { - suggestions := a.matchingSlashCommands(strings.TrimSpace(a.input.Value())) - if len(suggestions) == 0 { - return "" + box := a.styles.inputBox + if a.focus == panelInput && a.state.ActivePicker == pickerNone { + box = a.styles.inputBoxFocused } - lines := make([]string, 0, len(suggestions)+1) - lines = append(lines, a.styles.commandMenuTitle.Render(commandMenuTitle)) - for _, suggestion := range suggestions { - usageStyle := a.styles.commandUsage - if suggestion.Match { - usageStyle = a.styles.commandUsageMatch - } - lines = append(lines, lipgloss.JoinHorizontal( - lipgloss.Top, - usageStyle.Render(suggestion.Command.Usage), - lipgloss.NewStyle().Width(2).Render(""), - a.styles.commandDesc.Render(suggestion.Command.Description), - )) - } + // 计算边框和内边距占用的空间 + boxWidth := max(8, width-2) - return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) -} + prefix := a.styles.inputPrefix.Render(">") + inputContent := a.input.View() -func (a App) commandMenuHeight(width int) int { - menu := a.renderCommandMenu(width) - if strings.TrimSpace(menu) == "" { - return 0 - } - return lipgloss.Height(menu) + content := lipgloss.JoinHorizontal( + lipgloss.Left, + prefix, + lipgloss.NewStyle().Width(1).Render(" "), + a.styles.inputLine.Width(max(4, boxWidth-2-lipgloss.Width(prefix)-1)).Render(inputContent), + ) + return box.Width(boxWidth).Render(content) } -func (a App) renderHelp(width int) string { - a.help.ShowAll = a.state.ShowHelp - return a.styles.footer.Width(width).Render(a.help.View(a.keys)) +func (a App) renderSidebarHeader(width int) string { + title := a.styles.panelTitle.Render(sidebarTitle) + filterWidth := max(6, width-lipgloss.Width(title)-1) + titleRow := lipgloss.JoinHorizontal( + lipgloss.Center, + title, + lipgloss.NewStyle().Width(1).Render(""), + a.styles.panelSubtitle.Render(trimRunes(sidebarFilterHint, filterWidth)), + ) + openRow := a.styles.panelSubtitle.Render(trimRunes(sidebarOpenHint, width)) + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, titleRow), + lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, openRow), + ) } func (a App) renderPanel(title string, subtitle string, body string, width int, height int, focused bool) string { @@ -173,15 +205,21 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, style = a.styles.panelFocused } + frameHeight := style.GetVerticalFrameSize() + borderWidth := 2 + paddingWidth := style.GetHorizontalFrameSize() - borderWidth header := lipgloss.JoinHorizontal( lipgloss.Center, a.styles.panelTitle.Render(title), lipgloss.NewStyle().Width(2).Render(""), a.styles.panelSubtitle.Render(subtitle), ) - bodyHeight := max(3, height-lipgloss.Height(header)-2) - panelBody := a.styles.panelBody.Width(max(10, width-4)).Height(bodyHeight).Render(body) - return style.Width(width).Height(height).Render(lipgloss.JoinVertical(lipgloss.Left, header, panelBody)) + panelWidth := max(1, width-borderWidth) + bodyWidth := max(10, panelWidth-paddingWidth) + bodyHeight := max(3, height-frameHeight-lipgloss.Height(header)) + panelBody := a.styles.panelBody.Width(bodyWidth).Height(bodyHeight).Render(body) + panel := style.Width(panelWidth).Height(max(1, height-frameHeight)).Render(lipgloss.JoinVertical(lipgloss.Left, header, panelBody)) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } func (a App) renderMessageBlock(message provider.Message, width int) string { @@ -194,16 +232,19 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))) } - maxMessageWidth := clamp(width, 24, max(24, int(float64(width)*0.92))) + maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody + blockAlign := lipgloss.Left switch message.Role { case roleUser: + maxMessageWidth = clamp(int(float64(width)*0.68), 24, width) tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody + blockAlign = lipgloss.Right case roleTool: tag = messageTagTool tagStyle = a.styles.messageToolTag @@ -222,17 +263,66 @@ func (a App) renderMessageBlock(message provider.Message, width int) string { content = emptyMessageText } - return lipgloss.JoinVertical( - lipgloss.Left, + contentBlock := a.renderMessageContent(content, maxMessageWidth-2, bodyStyle) + if message.Role == roleUser { + contentBlock = lipgloss.PlaceHorizontal(maxMessageWidth, lipgloss.Right, contentBlock) + } + + block := lipgloss.JoinVertical( + blockAlign, tagStyle.Render(tag), - a.renderMessageContent(content, maxMessageWidth-2, bodyStyle), + contentBlock, ) + + if message.Role == roleUser { + return lipgloss.PlaceHorizontal(width, lipgloss.Right, block) + } + return block +} + +func (a App) renderCommandMenu(width int) string { + suggestions := a.matchingSlashCommands(strings.TrimSpace(a.input.Value())) + if len(suggestions) == 0 { + return "" + } + + lines := make([]string, 0, len(suggestions)+1) + lines = append(lines, a.styles.commandMenuTitle.Render(commandMenuTitle)) + for _, suggestion := range suggestions { + usageStyle := a.styles.commandUsage + if suggestion.Match { + usageStyle = a.styles.commandUsageMatch + } + lines = append(lines, lipgloss.JoinHorizontal( + lipgloss.Top, + usageStyle.Render(suggestion.Command.Usage), + lipgloss.NewStyle().Width(2).Render(""), + a.styles.commandDesc.Render(suggestion.Command.Description), + )) + } + + return a.styles.commandMenu.Width(width).Render(lipgloss.JoinVertical(lipgloss.Left, lines...)) +} + +func (a App) commandMenuHeight(width int) int { + menu := a.renderCommandMenu(width) + if strings.TrimSpace(menu) == "" { + return 0 + } + return lipgloss.Height(menu) +} + +func (a App) renderHelp(width int) string { + a.help.ShowAll = a.state.ShowHelp + helpContent := a.help.View(a.keys) + // 确保帮助视图填充整个宽度,避免边框断裂 + return a.styles.footer.Width(width).Render(helpContent) } func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss.Style) string { parts := strings.Split(content, "```") if len(parts) == 1 { - return bodyStyle.Width(width).Render(wrapPlain(content, max(16, width-2))) + return bodyStyle.Render(wrapPlain(content, max(16, width-2))) } blocks := make([]string, 0, len(parts)) @@ -242,7 +332,7 @@ func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss. if trimmed == "" { continue } - blocks = append(blocks, bodyStyle.Width(width).Render(wrapPlain(trimmed, max(16, width-2)))) + blocks = append(blocks, bodyStyle.Render(wrapPlain(trimmed, max(16, width-2)))) continue } @@ -255,7 +345,7 @@ func (a App) renderMessageContent(content string, width int, bodyStyle lipgloss. } if len(blocks) == 0 { - return bodyStyle.Width(width).Render(emptyMessageText) + return bodyStyle.Render(emptyMessageText) } return lipgloss.JoinVertical(lipgloss.Left, blocks...) @@ -266,6 +356,8 @@ func (a App) statusBadge(text string) string { switch { case strings.Contains(lower, "error") || strings.Contains(lower, "failed"): return a.styles.badgeError.Render(text) + case strings.Contains(lower, "cancel"): + return a.styles.badgeWarning.Render(text) case a.state.IsAgentRunning || strings.Contains(lower, "running") || strings.Contains(lower, "thinking"): return a.styles.badgeWarning.Render(text) default: @@ -285,13 +377,10 @@ func (a App) focusLabel() string { } func (a App) computeLayout() layout { - contentWidth := max(80, a.width-4) - helpHeight := 2 - if a.state.ShowHelp { - helpHeight = 6 - } - - contentHeight := max(18, a.height-7-helpHeight) + contentWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) + headerHeight := lipgloss.Height(a.renderHeader(contentWidth)) + helpHeight := lipgloss.Height(a.renderHelp(contentWidth)) + contentHeight := max(1, a.height-a.styles.doc.GetVerticalFrameSize()-headerHeight-helpHeight) lay := layout{contentWidth: contentWidth, contentHeight: contentHeight} if contentWidth < 110 { lay.stacked = true @@ -302,9 +391,10 @@ func (a App) computeLayout() layout { return lay } - lay.sidebarWidth = 30 + lay.bodyGap = 1 + lay.sidebarWidth = 22 lay.sidebarHeight = contentHeight - lay.rightWidth = contentWidth - lay.sidebarWidth + lay.rightWidth = max(24, contentWidth-lay.sidebarWidth-lay.bodyGap) lay.rightHeight = contentHeight return lay }