From 08321a86da7d548336a35fe6536e58e6215ef979 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 25 Mar 2026 22:15:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?:=E6=96=B0=E5=A2=9Eflow=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E6=96=B9=E6=A1=88=E5=92=8Ctodo=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/flow-design.md | 675 +++++++++++++++ docs/todo-flow-aligned-implementation-plan.md | 786 ++++++++++++++++++ 2 files changed, 1461 insertions(+) create mode 100644 docs/flow-design.md create mode 100644 docs/todo-flow-aligned-implementation-plan.md diff --git a/docs/flow-design.md b/docs/flow-design.md new file mode 100644 index 00000000..b928e48f --- /dev/null +++ b/docs/flow-design.md @@ -0,0 +1,675 @@ +# NeoCode 思考流程改造统一方案(优化版) + +## 1. 文档定位 + +本文整合并替代此前关于 NeoCode 思考流程改造的多份草案,形成一份统一的团队讨论与实施指导文档。目标是同时回答以下问题: + +- 当前项目是否适合推进思考流程改造 +- 这次改造应该落实到什么边界 +- 当记忆模块和工具模块后续继续大改时,如何保证 Flow 模块能独立落地 +- Flow 相关新增能力应该如何组织目录与职责 +- 是否应引入设计模式,如果引入,应引入哪些、落在哪些位置 + +本文强调两点: + +- 先稳定边界,再扩展流程 +- 引入设计模式,但只引入“对当前项目真正有帮助的模式”,避免过度框架化 + +--- + +## 2. 结论摘要 + +当前项目适合推进思考流程改造,而且现有代码结构已经具备较好的基础: + +- 服务端当前仍是“单次请求 -> 单次模型调用”,见 [chat_service.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go#L29) +- 工具闭环主要在 TUI 内完成,见 [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L63) +- 角色、记忆、工作记忆、安全、工具注册、配置系统均已存在,可被编排层统一收口 + +但如果目标是长期可演进,那么本次改造不能只做“功能追加”,而应同时完成三类建设: + +1. 建立 Flow 编排层 +2. 冻结 Flow 与记忆/工具之间的稳定能力接口 +3. 用适合当前项目的设计模式组织流程,而不是继续用散落的 `if-else` 和 UI 闭环来驱动 + +--- + +## 3. 当前问题与改造动机 + +### 3.1 当前主要问题 +当前系统的核心问题不是“不能调用工具”,而是“流程能力没有统一归位”。 + +具体表现为: + +- Service 只负责单轮问答 +- TUI 负责工具闭环、审批暂停、结果回灌 +- 记忆和工具能力被主链路直接调用,缺乏中间抽象 +- 新增流程时,很容易牵一发动全身 + +### 3.2 如果不改,会发生什么 +如果继续沿用当前模式,后续新增任何一种思考流程时,都需要重复处理这些问题: + +- 何时调模型 +- 何时调工具 +- 工具结果如何回灌 +- 审批如何暂停与恢复 +- 记忆何时注入,何时写回 +- 何时终止,何时回退 + +这会直接导致: + +- 流程逻辑分散 +- TUI 越来越重 +- 函数命名和接口不断震荡 +- 测试难以稳定 + +--- + +## 4. 推荐的设计模式 + +本项目可以考虑引入设计模式,但应遵循“少而准”的原则。推荐引入以下模式: + +## 4.1 Strategy 模式:用于 Flow 本身 +最适合当前项目的模式是 Strategy。 + +不同思考流程本质上就是不同执行策略: + +- `single_shot` +- `react` +- `plan_execute` + +它们共享依赖、共享上下文能力、共享工具执行器,但控制步骤不同。因此最自然的建模方式就是: + +```go +type Flow interface { + Run(ctx context.Context, req *ChatRequest) (<-chan ChatEvent, error) +} +``` + +每个 Flow 都是一个策略实现。 + +### 为什么适合 +- 与当前“可切换思考流程”的目标天然匹配 +- 新增流程时只新增实现,不改主链路 +- 比把流程分支继续塞进 `chat_service.go` 更可维护 + +--- + +## 4.2 Factory + Registry 模式:用于 Flow 创建与切换 +Flow 不应靠 `switch name { ... }` 散落在多个地方创建。 + +建议引入 Factory + Registry: + +```go +type FlowFactory func(deps FlowDeps) Flow +``` + +配合注册表: + +- 注册 `single_shot` +- 注册 `react` +- 注册 `plan_execute` +- 统一负责名称归一化、查找、默认值、未知值回退 + +### 为什么适合 +- 与配置化切换、`/flow ` 命令天然匹配 +- 能避免流程名称字符串散落在 TUI、Service、Config 校验中 +- 新增流程时对主链路零侵入 + +--- + +## 4.3 Ports and Adapters:用于 Flow 与记忆/工具解耦 +严格来说这不是一个传统 GoF 模式,而是一种非常适合当前项目的架构模式。 + +Flow 不应直接依赖: + +- `MemoryService` 内部算法 +- `WorkingMemoryService` 内部状态建模 +- `GlobalRegistry` +- 工具安全审批内部实现 + +Flow 只应依赖几个稳定端口: + +- `TurnContextAssembler` +- `TurnFinalizer` +- `FlowToolExecutor` +- `ToolCallParser` + +然后由适配器桥接现有实现。 + +### 为什么适合 +- 当前项目最大的风险就是“记忆和工具模块未来还会大改” +- 这个模式可以让 Flow 成为相对稳定的上层模块 +- 底层重构时只改适配器,不改 Flow 策略本体 + +--- + +## 4.4 State Machine 模式:用于 `react` / `plan_execute` 的内部控制 +对于 `single_shot`,不必引入状态机;但对于 `react` 和 `plan_execute`,建议显式引入轻量状态机思想。 + +建议至少把这些状态明确化: + +- `PreparingContext` +- `CallingLLM` +- `ParsingToolCall` +- `WaitingApproval` +- `ExecutingTool` +- `Summarizing` +- `Completed` +- `Failed` + +不一定需要引入外部状态机框架,但流程代码应按状态推进,而不是在一个函数里不断嵌套条件。 + +### 为什么适合 +- ReAct 和 Plan/Execute 都天然是多阶段流程 +- 状态化后更容易处理暂停/恢复、终止条件和错误回退 +- 审批恢复特别适合用状态视角来建模 + +--- + +## 4.5 Rule Set / Policy Object:用于终止、审批、回退决策 +你提到“规则树”,这个思路可以用,但不建议做成过重的通用规则树引擎。 + +更适合当前项目的做法是: + +- 在几个高风险决策点引入轻量规则对象或策略组合 +- 不做全局通用规则树 DSL + +推荐用于以下场景: + +### 终止策略 +例如: +- 是否达到 `max_loops` +- 是否达到 `max_tool_calls` +- 是否当前已完成 +- 是否应强制回退 + +### 审批策略 +例如: +- 当前工具调用是否应 allow / ask / deny +- ask 后如何恢复 +- reject 后是否继续生成解释性回复 + +### 回退策略 +例如: +- planner schema 校验失败后回退到 `react` +- 工具协议解析失败时改为普通文本继续处理 +- 工具异常时是否停止流程或继续下一轮总结 + +### 为什么是“规则对象”而不是“规则树引擎” +因为当前项目规模还没到需要引入完整规则树框架的程度。过早引入会造成: + +- 规则表达复杂度高于业务复杂度 +- 团队后续维护成本变大 +- 调试困难 + +因此建议是: + +- 可以引入“策略对象 / policy object” +- 不建议一开始引入重量级规则树或工作流引擎 + +--- + +## 4.6 不建议优先引入的模式 +以下模式当前不建议优先上: + +### Template Method +原因: +- 当前流程差异不仅是步骤顺序差异,还有阶段能力差异 +- Strategy + 状态机更自然 + +### Command 模式 +原因: +- 当前项目里的“动作”虽然可以抽象成 `CallLLM / ExecuteTool / Emit / Finalize` +- 但暂时没有必要将所有动作对象化,否则会把简单编排变复杂 + +### Visitor +原因: +- 当前没有复杂 AST 或多层对象遍历需求 +- 不适用于当前主要问题 + +--- + +## 5. 推荐总体架构 + +## 5.1 目录结构 + +```text +internal/server/domain/ + chat.go + tool.go + memory.go + working_memory.go + flow.go + +internal/server/service/ + chat_service.go + memory_service.go + working_memory_service.go + todo_service.go + role_service.go + security_service.go + +internal/server/orchestration/flow/ + engine.go + registry.go + ports.go + policy.go + single_shot.go + react.go + plan_execute.go + context_assembler.go + turn_finalizer.go + tool_executor.go + tool_protocol_json.go +``` + +## 5.2 各层职责 +### `domain/` +放稳定契约: + +- Flow 接口 +- 事件结构 +- Flow 状态 +- 审批请求 +- 抽象端口定义 + +### `service/` +保留稳定领域服务: + +- 记忆服务 +- 工作记忆服务 +- 角色服务 +- TODO 服务 +- 安全服务 + +### `orchestration/flow/` +放所有与 Flow 一起变化的内容: + +- 流程实现 +- 工具协议解析 +- 上下文组装 +- 最终写回 +- 工具执行适配 +- 终止/审批/回退策略 + +--- + +## 6. 稳定能力接口 + +建议在 `domain/flow.go` 或 `orchestration/flow/ports.go` 中定义以下接口。 + +## 6.1 TurnContextAssembler +```go +type TurnContextAssembler interface { + Assemble(ctx context.Context, req *ChatRequest, state *FlowState) ([]ContextBlock, error) +} +``` + +职责: + +- 统一组装 role、working memory、todo、retrieved memory +- Flow 不直接依赖各类 context service + +--- + +## 6.2 TurnFinalizer +```go +type TurnFinalizer interface { + Finalize(ctx context.Context, req *ChatRequest, result *TurnResult) error +} +``` + +职责: + +- 最终回复结束后刷新 working memory +- 最终回复结束后写 long-term memory +- 未来可扩展 todo、trace、metrics + +--- + +## 6.3 FlowToolExecutor +```go +type FlowToolExecutor interface { + Execute(ctx context.Context, call ToolCall) (*ToolExecution, error) + ResolveApproval(ctx context.Context, token string, approved bool) (*ToolExecution, error) +} +``` + +职责: + +- 工具查找 +- 安全检查 +- 审批 token 生成与恢复 +- 工具结果标准化 + +--- + +## 6.4 ToolCallParser +```go +type ToolCallParser interface { + ParseAssistantMessage(text string) (*ToolCall, bool, error) +} +``` + +职责: + +- 从 assistant 文本中识别工具调用 +- 当前实现为 `json_v1` +- 未来可替换为 native function calling + +--- + +## 6.5 FlowPolicy +建议新增统一策略对象: + +```go +type FlowPolicy interface { + ShouldStop(state *FlowState) (bool, string) + ShouldFallback(err error, state *FlowState) (bool, FlowName) +} +``` + +如果不做统一接口,也至少应在 `policy.go` 中集中放置终止与回退策略。 + +--- + +## 7. 三类 Flow 的职责 + +## 7.1 `single_shot` +- 最简单的策略实现 +- 只做单轮上下文组装、单次模型调用、最终写回 +- 不进入工具循环 +- 作用是保持兼容现状,并作为所有新架构的基准流程 + +## 7.2 `react` +- 使用状态机思想驱动: + - 组装上下文 + - 调模型 + - 解析工具调用 + - 执行工具 + - 审批暂停/恢复 + - 继续迭代 +- 终止逻辑由 policy 控制,不写死在流程体中 + +## 7.3 `plan_execute` +- 先调用 planner +- 对计划做 schema 校验 +- 步骤执行复用 `react` 的工具执行能力 +- 校验失败或 planner 不可靠时,允许按 policy 回退到 `react` + +--- + +## 8. 如何保证记忆模块和工具模块大改时不影响 Flow + +## 8.1 核心原则 +Flow 不依赖实现,只依赖能力。 + +### 具体意味着 +Flow 不应直接依赖: + +- [MemoryService.BuildContext](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/memory.go#L44) +- [MemoryService.Save](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/memory.go#L44) +- [WorkingMemoryService.BuildContext](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/working_memory.go#L36) +- [WorkingMemoryService.Refresh](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/working_memory.go#L36) +- [GlobalRegistry.Execute](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/infra/tools/tool.go#L67) +- [ApproveSecurityAsk](C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/infra/tools/security.go#L33) + +Flow 只应依赖: + +- `TurnContextAssembler` +- `TurnFinalizer` +- `FlowToolExecutor` +- `ToolCallParser` + +--- + +## 8.2 记忆模块大改时的改动边界 +如果未来记忆模块重构为: + +- 多源检索 +- 向量数据库 +- 结构化工作记忆 +- 新的写回策略 + +应只改这些位置: + +- `memory_service.go` +- `working_memory_service.go` +- `context_assembler.go` +- `turn_finalizer.go` + +不应改: + +- `single_shot.go` +- `react.go` +- `plan_execute.go` + +如果必须改 Flow 主循环,说明边界设计失败。 + +--- + +## 8.3 工具模块大改时的改动边界 +如果未来工具模块重构为: + +- 远端执行 +- 原生 function calling +- 新审批模型 +- 新工具元数据结构 + +应只改这些位置: + +- `infra/tools/*` +- `tool_executor.go` +- `tool_protocol_json.go` 或新的 parser +- 安全相关适配层 + +不应改: + +- Flow 主循环 +- TUI 主事件处理 +- Flow 状态机逻辑 + +--- + +## 9. TUI 的新职责 + +TUI 应从当前的“半个流程控制器”退回为“事件渲染层 + 用户交互层”。 + +## 9.1 TUI 保留职责 +- 渲染文本流 +- 渲染工具状态 +- 渲染审批提示 +- 发送 `/y` `/n` +- 发送 `/flow `、`/provider`、`/switch` + +## 9.2 TUI 移除职责 +- assistant JSON 工具调用解析 +- 本地执行工具 +- 工具结果回灌为 system message +- 审批恢复状态机本体 + +相关现状参考: + +- [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L82) +- [update.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update.go#L165) +- [runtime_services.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/services/runtime_services.go#L46) + +--- + +## 10. 配置与切换 + +建议在 [app_config.go](C:/Users/10116/Desktop/study/gopro/neo-code/configs/app_config.go#L20) 的 `AI` 下新增结构化 `flow` 配置: + +```yaml +ai: + provider: "openll" + api_key: "AI_API_KEY" + model: "gpt-5.4" + flow: + name: "single_shot" + tool_protocol: "json_v1" + max_loops: 8 + max_tool_calls: 12 + planner_model: "" + executor_model: "" + plan_template_path: "" + executor_template_path: "" +``` + +建议支持环境变量覆盖: + +- `NEOCODE_FLOW` + +TUI 新增: + +- `/flow ` + +状态栏新增: + +- 当前 `model` +- 当前 `flow` + +--- + +## 11. 推荐实施顺序 + +## 阶段 A:先建模式与边界,不改核心实现 +实施内容: + +- 新建 `domain/flow.go` +- 新建 `orchestration/flow/ports.go` +- 新建 `orchestration/flow/registry.go` +- 新建 `orchestration/flow/policy.go` + +此阶段只完成: + +- Strategy +- Factory/Registry +- Ports and Adapters +- 轻量 Policy + +暂不改记忆算法和工具系统。 + +--- + +## 阶段 B:旧实现接入新适配层 +实施内容: + +- `context_assembler.go` 桥接当前记忆/角色/todo/working memory +- `turn_finalizer.go` 桥接当前写回逻辑 +- `tool_executor.go` 桥接当前工具注册表和安全检查 +- `tool_protocol_json.go` 承接当前 JSON 协议 + +此阶段目标是“旧实现,新边界”。 + +--- + +## 阶段 C:落地 `single_shot` 与 `react` +实施内容: + +- `single_shot.go` +- `react.go` +- TUI 改为事件流消费 +- `/y` `/n` 改为调用审批恢复接口 + +--- + +## 阶段 D:落地 `plan_execute` +实施内容: + +- `plan_execute.go` +- planner schema 校验 +- 回退策略 +- 复用 `react` 执行器 + +--- + +## 阶段 E:推进记忆与工具模块重构 +前提: + +- Flow 已经只依赖稳定接口 +- Flow 主循环已有合同测试 + +这样后续底层模块可独立演进。 + +--- + +## 12. 测试策略 + +## 12.1 Flow 合同测试 +Flow 测试必须只依赖 fake 端口,不依赖真实实现。 + +应验证: + +- 流程切换 +- 状态推进 +- 审批暂停与恢复 +- 终止与回退 +- `single_shot` / `react` / `plan_execute` 的输出行为 + +## 12.2 适配器测试 +分别验证: + +- `context_assembler.go` +- `turn_finalizer.go` +- `tool_executor.go` +- `tool_protocol_json.go` + +这些测试负责保护 Flow 与底层模块之间的桥接逻辑。 + +## 12.3 TUI 回归测试 +现有大量测试都围绕 TUI 本地工具闭环,应逐步改为: + +- TUI 接收事件 +- TUI 展示事件 +- TUI 发起审批恢复 + +重点受影响测试参考: + +- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L949) +- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L1009) +- [update_test.go](C:/Users/10116/Desktop/study/gopro/neo-code/internal/tui/core/update_test.go#L1381) + +--- + +## 13. 风险与规避 + +### 风险一:模式引入过多,复杂度上升 +规避: +- 只引入四类模式:Strategy、Factory/Registry、Ports and Adapters、轻量 State Machine/Policy +- 不引入重量级规则树引擎和工作流框架 + +### 风险二:Flow 继续碰底层实现 +规避: +- 通过包结构和合同测试约束 Flow 只能依赖端口接口 + +### 风险三:`service` 再次变成杂糅层 +规避: +- 所有 Flow 相关适配与策略统一进入 `orchestration/flow/` + +### 风险四:审批恢复设计不完整 +规避: +- 明确 token 化审批恢复接口 +- 不再依赖 TUI 本地状态机拼装流程恢复 + +--- + +## 14. 建议团队优先确认的决策 + +这份方案建议团队优先讨论并确认以下几点: + +- 是否接受以 Strategy 模式建模思考流程 +- 是否接受以 Factory + Registry 管理 Flow 创建与切换 +- 是否接受用 Ports and Adapters 保护 Flow 不受记忆与工具重构影响 +- 是否接受用轻量 Policy/State Machine 管理 `react` 和 `plan_execute` +- 是否接受将 Flow 相关适配层统一放入 `internal/server/orchestration/flow/` +- 是否接受本轮只升级内部事件流,不改外部传输契约 + +--- + +## 15. 最终建议 + +建议团队采纳以下统一方向: + +“NeoCode 将引入独立的 Flow 编排上下文,以 Strategy 模式定义不同思考流程,以 Factory + Registry 模式实现配置化切换,并通过 Ports and Adapters 将记忆模块与工具模块隔离在稳定边界之外。`react` 与 `plan_execute` 的内部控制采用轻量状态机与策略对象管理终止、审批和回退决策。所有与 Flow 一起演进的适配层统一放入 `internal/server/orchestration/flow/`,而不是继续堆入 `service` 根包。外部传输契约本轮保持兼容,待内部编排稳定后再评估进一步升级。” + diff --git a/docs/todo-flow-aligned-implementation-plan.md b/docs/todo-flow-aligned-implementation-plan.md new file mode 100644 index 00000000..1b511a18 --- /dev/null +++ b/docs/todo-flow-aligned-implementation-plan.md @@ -0,0 +1,786 @@ +# NeoCode Todo 模块实施方案(完全对齐 Flow 设计) + +## 1. 文档目标 + +本文将当前 Todo 模块的增强方案重写为**完全符合** [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的版本,目标是: + +- 让 Todo 成为 Flow 可复用的稳定能力,而不是新的编排中心 +- 保持 `single_shot / react / plan_execute` 为唯一的流程策略入口 +- 让 TUI 从“半个流程控制器”退回为“事件渲染层 + 用户交互层” +- 在不破坏现有 `json_v1` 工具协议的前提下,逐步增强 Todo 的表达力与可维护性 + +本文覆盖: + +- 模块边界 +- 接口规范 +- 依赖关系 +- 调用流程 +- 设计模式选择 +- 分阶段实施计划 +- 测试与风险控制 + +--- + +## 2. 设计原则 + +本方案严格遵守 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的以下原则: + +1. **Flow-first** + Flow 是一等公民;Todo 只是 Flow 通过稳定端口接入的一项能力。 + +2. **Ports and Adapters** + Flow 主循环只依赖稳定端口,不直接依赖 `TodoService`、`MemoryService`、`GlobalRegistry` 等具体实现。 + +3. **orchestration/flow 收口** + 所有与 Flow 一起演进的内容统一进入 `internal/server/orchestration/flow/`,不继续堆入 `service` 根包。 + +4. **TUI 瘦身** + TUI 不负责 assistant JSON 解析、工具执行、工具结果回灌或审批恢复状态机本体。 + +5. **兼容优先** + 第一阶段保留现有 `todo` 工具名和 `json_v1` 协议,优先做“旧实现,新边界”。 + +6. **少而准的模式** + 只使用文档已认可的模式:Strategy、Factory/Registry、Ports and Adapters、轻量 State Machine、Policy Object。 + 不优先引入 Command、重型规则树或工作流引擎。 + +--- + +## 3. 总体架构 + +### 3.1 角色定位 + +- `domain/`:定义稳定契约与领域模型 +- `service/`:保留稳定领域服务,如 Todo、Memory、WorkingMemory、Role、Security +- `orchestration/flow/`:承载 Flow 策略、上下文组装、最终落盘、工具执行适配、协议解析、终止/回退策略 +- `tui/`:渲染事件、接收用户输入、发起审批恢复或用户命令 + +### 3.2 Todo 的定位 + +Todo 在新架构中不是独立流程引擎,而是三种能力之一: + +- 被 `TurnContextAssembler` 读取,拼装为当前轮上下文 +- 被 `TurnFinalizer` 更新,用于在回合结束后推进状态或写入补充信息 +- 被 `FlowToolExecutor` 间接调用,通过 `todo` 工具显式增删改查 + +--- + +## 4. 目录建议 + +在遵守 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 推荐目录的前提下,Todo 相关改造建议如下: + +```text +internal/server/domain/ + todo.go + flow.go + +internal/server/service/ + todo_service.go + +internal/server/infra/repository/ + todo_repository.go + file_todo_repository.go + +internal/server/infra/tools/ + todo.go + +internal/server/orchestration/flow/ + engine.go + registry.go + ports.go + policy.go + single_shot.go + react.go + plan_execute.go + context_assembler.go + turn_finalizer.go + tool_executor.go + tool_protocol_json.go +``` + +注意: + +- Todo 领域模型和领域服务继续留在 `domain/` 与 `service/` +- Todo 的 Flow 接入点只能放在 `orchestration/flow/` +- 不新增 `service/agent_orchestrator.go`、`service/plan_context_provider.go` 之类文件 + +--- + +## 5. 模块边界与依赖关系 + +### 5.1 允许的依赖方向 + +```text +TUI -> services facade -> orchestration/flow -> domain ports + | | + v v + service domain + | + v + infra/repository + infra/tools +``` + +### 5.2 不允许的依赖 + +Flow 层不得直接依赖: + +- `TodoService` 的具体实现细节 +- `MemoryService.BuildContext(...)` +- `WorkingMemoryService.Refresh(...)` +- `tools.GlobalRegistry.Execute(...)` +- `tools.ApproveSecurityAsk(...)` +- TUI 状态结构 + +### 5.3 Todo 与 Flow 的正确边界 + +正确方式: + +- `context_assembler.go` 依赖 `TodoService`,把 Todo 摘要装配为 `ContextBlock` +- `turn_finalizer.go` 依赖 `TodoService`,根据当前轮结果决定是否推进 Todo +- `tool_executor.go` 依赖工具注册表,间接执行 `todo` 工具 + +错误方式: + +- `react.go` 直接调用 `TodoService.AddTodo(...)` +- `plan_execute.go` 直接依赖 `todo_repository` +- TUI 继续解析模型输出 JSON 并直接执行 `todo` 工具 + +--- + +## 6. 接口规范 + +## 6.1 Flow 稳定端口 + +这些接口应定义在 `domain/flow.go` 或 `orchestration/flow/ports.go`,并成为 Flow 的唯一稳定依赖。 + +### TurnContextAssembler + +```go +type TurnContextAssembler interface { + Assemble(ctx context.Context, req *ChatRequest, state *FlowState) ([]ContextBlock, error) +} +``` + +职责: + +- 统一组装 role +- 统一组装 working memory +- 统一组装 todo context +- 统一组装 long-term memory context + +Todo 相关要求: + +- Todo 只能通过这里进入 Flow 上下文 +- 不允许在 `single_shot.go`、`react.go`、`plan_execute.go` 中手写 Todo prompt 拼装逻辑 + +### TurnFinalizer + +```go +type TurnFinalizer interface { + Finalize(ctx context.Context, req *ChatRequest, result *TurnResult) error +} +``` + +职责: + +- 刷新 working memory +- 保存 long-term memory +- 更新 Todo 派生状态 +- 记录 trace / metrics 的扩展点 + +Todo 相关要求: + +- Todo 的自动推进只能经由这里扩展 +- 不能把 Todo 状态推进逻辑散落在 TUI 或 Flow 策略实现中 + +### FlowToolExecutor + +```go +type FlowToolExecutor interface { + Execute(ctx context.Context, call ToolCall) (*ToolExecution, error) + ResolveApproval(ctx context.Context, token string, approved bool) (*ToolExecution, error) +} +``` + +职责: + +- 查找工具 +- 安全检查 +- 审批 token 生成与恢复 +- 返回标准化工具执行结果 + +Todo 相关要求: + +- Flow 若要显式修改 Todo,必须通过 `todo` 工具进入 +- 不允许绕过工具执行器直接修改仓储 + +### ToolCallParser + +```go +type ToolCallParser interface { + ParseAssistantMessage(text string) (*ToolCall, bool, error) +} +``` + +职责: + +- 识别 assistant 输出中的工具调用 +- 当前承接 `json_v1` +- 未来可替换为 native function calling + +Todo 相关要求: + +- `todo` 工具协议解析只能在 parser 中演进 +- TUI 不再承担 assistant JSON 解析 + +### FlowPolicy + +```go +type FlowPolicy interface { + ShouldStop(state *FlowState) (bool, string) + ShouldFallback(err error, state *FlowState) (bool, FlowName) +} +``` + +职责: + +- 终止条件 +- planner 失败回退 +- 工具异常后的继续/中断决策 + +Todo 相关要求: + +- Todo 不能承担 Flow 是否继续执行的判定职责 +- Todo 只能提供上下文和状态,不替代 FlowPolicy + +--- + +## 6.2 Todo 领域接口 + +Todo 仍然属于领域服务,不属于 Flow 端口。 + +建议在不破坏现有契约的前提下,增强 [todo.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/domain/todo.go)。 + +### 领域模型 + +保持现有核心字段: + +```go +type Todo struct { + ID string `json:"id"` + Content string `json:"content"` + Status TodoStatus `json:"status"` + Priority TodoPriority `json:"priority"` +} +``` + +建议逐步新增可选字段: + +```go +type Todo struct { + ID string `json:"id"` + Content string `json:"content"` + Status TodoStatus `json:"status"` + Priority TodoPriority `json:"priority"` + Detail string `json:"detail,omitempty"` + Dependencies []string `json:"dependencies,omitempty"` + Source string `json:"source,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} +``` + +说明: + +- `content/status/priority` 继续保持外部兼容 +- 新字段只作为渐进增强,不影响当前工具协议的最小集 + +### TodoService + +保守演进版本: + +```go +type TodoService interface { + AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) + UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error + ListTodos(ctx context.Context) ([]Todo, error) + ClearTodos(ctx context.Context) error + RemoveTodo(ctx context.Context, id string) error +} +``` + +增强建议: + +```go +type TodoService interface { + AddTodo(ctx context.Context, content string, priority TodoPriority) (*Todo, error) + UpdateTodoStatus(ctx context.Context, id string, status TodoStatus) error + UpdateTodo(ctx context.Context, todo Todo) (*Todo, error) + ListTodos(ctx context.Context) ([]Todo, error) + ListActiveTodos(ctx context.Context) ([]Todo, error) + ClearTodos(ctx context.Context) error + RemoveTodo(ctx context.Context, id string) error +} +``` + +注意: + +- 即使增强 `TodoService`,Flow 也不直接依赖它 +- Flow 只通过 `TurnContextAssembler` / `TurnFinalizer` / `FlowToolExecutor` 间接使用 Todo 能力 + +### TodoRepository + +```go +type TodoRepository interface { + Add(ctx context.Context, todo Todo) (*Todo, error) + UpdateStatus(ctx context.Context, id string, status TodoStatus) error + Update(ctx context.Context, todo Todo) (*Todo, error) + List(ctx context.Context) ([]Todo, error) + Clear(ctx context.Context) error + Remove(ctx context.Context, id string) error +} +``` + +第一阶段可以保留内存仓储,第二阶段增加文件仓储实现。 + +--- + +## 6.3 todo 工具协议 + +为了符合“外部契约保持兼容”的要求,保留当前工具名与主协议: + +```json +{ + "tool": "todo", + "params": { + "action": "add|update|list|remove|clear", + "content": "write tests", + "priority": "medium", + "id": "todo-1", + "status": "completed" + } +} +``` + +实施建议: + +- 阶段 A/B 不修改外部协议 +- 阶段 C 以后再考虑增加可选动作,如 `replace`、`advance` +- 即使新增动作,也必须保持老协议可用 + +--- + +## 7. 调用流程 + +## 7.1 `single_shot` 流程 + +```text +TUI -> flow engine(single_shot) + -> TurnContextAssembler + -> ChatProvider.Chat + -> TurnFinalizer + -> TUI 渲染最终回答 +``` + +Todo 参与方式: + +- 只通过 `TurnContextAssembler` 提供 Todo 摘要 +- 不进入工具循环 +- 可由 `TurnFinalizer` 做非常保守的收尾更新 + +适用场景: + +- 保持现状兼容 +- 作为新架构基线 + +## 7.2 `react` 流程 + +```text +TUI -> flow engine(react) + -> Assemble context + -> Call LLM + -> Parse tool call + -> FlowToolExecutor.Execute + -> 生成 ToolExecution 事件 + -> 继续下一轮 + -> TurnFinalizer +``` + +Todo 参与方式: + +- Todo 摘要由 `TurnContextAssembler` 注入 +- 模型若需显式修改 Todo,必须调用 `todo` 工具 +- 工具执行完成后,由 `TurnFinalizer` 做状态补充或归档 + +审批要求: + +- `FlowToolExecutor` 负责 token 化审批 +- TUI 只展示审批并发起 `/y` `/n` +- 审批恢复走 `ResolveApproval(...)` + +## 7.3 `plan_execute` 流程 + +```text +TUI -> flow engine(plan_execute) + -> planner 生成计划 + -> schema 校验 + -> 合法则进入执行阶段 + -> 执行阶段复用 react 的工具执行能力 + -> TurnFinalizer +``` + +Todo 参与方式: + +- planner 产生的计划可以映射为 Todo 列表 +- 计划写入不应直接发生在 `plan_execute.go` +- 应通过 `todo` 工具或 `TurnFinalizer` 的明确策略完成 + +回退要求: + +- planner schema 校验失败时,由 `FlowPolicy` 决定是否回退到 `react` + +## 7.4 TUI 手工 `/todo` 流程 + +```text +用户输入 /todo -> TUI 命令处理 -> ChatClient facade -> TodoService -> Repository +``` + +注意: + +- `/todo` 是用户交互入口,不是 Flow 主循环 +- TUI 可以保留 `/todo` 命令,但不能继续承担工具协议解析与本地工具闭环 +- `/todo` 与 agent 自动 `todo` 工具操作最终应落到同一 `TodoService` + +--- + +## 8. Todo 上下文策略 + +Todo 不能再像当前 [chat_service.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go#L117) 那样在主链路直接手写拼接。 + +建议由 `context_assembler.go` 统一生成: + +```text +[TODO] +In Progress: +- todo-3: implement parser + +Pending: +- todo-4: add tests +- todo-5: update docs + +Blocked: +- todo-6: waiting user decision +``` + +规则: + +- 优先显示 `in_progress` +- `pending` 最多显示前 3 到 5 项 +- `completed` 默认不注入 +- Todo 过多时只摘要,不全量塞入 prompt + +这样做的好处: + +- 减少上下文噪音 +- 保持 Flow 策略代码无 Todo 拼接细节 +- 后续 Todo 模型增强时只需改 `context_assembler.go` + +--- + +## 9. Todo 状态机 + +文档推荐对 `react / plan_execute` 使用轻量状态机;这里 Todo 自身也应有轻量状态迁移规则,但它是**领域状态机**,不是 Flow 状态机。 + +建议状态: + +- `pending` +- `in_progress` +- `completed` +- 第二阶段可增加 `blocked` + +建议迁移: + +```text +pending -> in_progress +in_progress -> completed +in_progress -> pending +blocked -> in_progress +pending -> cancelled (可选,后续阶段) +``` + +约束: + +- 状态迁移校验放在 `TodoService` 内部 +- TUI 不再自己定义“切一下状态就行” +- `todo` 工具更新状态时也走同一校验逻辑 + +--- + +## 10. 设计模式选择 + +## 10.1 应采用的模式 + +### Strategy + +用于三种 Flow: + +- `single_shot` +- `react` +- `plan_execute` + +Todo 不是 Strategy 主体,只是被策略消费的一种能力。 + +### Factory + Registry + +用于创建和切换 Flow: + +- 注册 `single_shot` +- 注册 `react` +- 注册 `plan_execute` + +Todo 不参与 Factory,只作为底层能力提供给适配器。 + +### Ports and Adapters + +这是 Todo 改造中最关键的约束: + +- Flow 不直接依赖 Todo 实现 +- Todo 能力通过 `TurnContextAssembler`、`TurnFinalizer`、`FlowToolExecutor` 间接进入 Flow + +### Lightweight State Machine + +用于: + +- `react` / `plan_execute` 的流程状态推进 +- TodoService 内部的状态迁移校验 + +### Policy Object + +用于: + +- Flow 终止条件 +- planner 失败回退 +- 工具审批恢复 +- 工具异常后继续/中断决策 + +## 10.2 不建议优先引入的模式 + +### Command + +不引入 `AddTodoCommand`、`UpdateTodoCommand` 这类完整 Command 模式体系。 + +原因: + +- 与 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 的“少而准”原则不一致 +- 当前阶段会把简单编排进一步对象化,复杂度高于收益 + +### 重型规则树 / DSL + +不引入通用规则引擎,不做全局 DSL。 + +原因: + +- 当前规模不需要 +- 调试和维护成本过高 + +--- + +## 11. 分阶段实施 + +## 阶段 A:先建边界,不改核心实现 + +目标: + +- 建立 Flow 的稳定端口 +- 明确目录与依赖边界 + +工作项: + +- 新建 `domain/flow.go` +- 新建 `orchestration/flow/ports.go` +- 新建 `orchestration/flow/registry.go` +- 新建 `orchestration/flow/policy.go` + +Todo 相关要求: + +- 不重写 TodoService +- 不改工具协议 +- 不改 Memory 算法 +- 只为后续 Todo 接入预留合法位置 + +## 阶段 B:旧实现接入新适配层 + +目标: + +- 做到“旧实现,新边界” + +工作项: + +- `context_assembler.go` 桥接 role / working memory / todo / memory +- `turn_finalizer.go` 桥接 working memory 刷新、memory save、todo 补充更新 +- `tool_executor.go` 桥接当前工具注册表与安全检查 +- `tool_protocol_json.go` 承接当前 `json_v1` + +Todo 相关要求: + +- Todo 从 [chat_service.go](/C:/Users/10116/Desktop/study/gopro/neo-code/internal/server/service/chat_service.go) 的直接拼接中移出 +- TUI 不再处理 assistant JSON tool call + +## 阶段 C:落地 `single_shot` 与 `react` + +目标: + +- 让 Flow engine 接管当前闭环 + +工作项: + +- 实现 `single_shot.go` +- 实现 `react.go` +- TUI 改为消费 Flow 事件 +- `/y` `/n` 改为调用审批恢复接口 + +Todo 相关要求: + +- `/todo` 保留为用户命令 +- agent 自动修改 Todo 通过 `todo` 工具执行 +- Todo 状态推进从 TUI 迁出,统一由 TodoService 校验 + +## 阶段 D:落地 `plan_execute` + +目标: + +- 在不破坏边界的前提下引入规划能力 + +工作项: + +- 实现 `plan_execute.go` +- planner schema 校验 +- fallback 策略 +- 执行阶段复用 `react` + +Todo 相关要求: + +- planner 输出可映射为 Todo +- 映射逻辑放在 `plan_execute` 配套适配器中,不直接侵入 Todo 仓储 + +## 阶段 E:增强 Todo 领域能力 + +前提: + +- Flow 已稳定运行在新架构中 +- Flow 合同测试已建立 + +工作项: + +- 引入文件型 Todo 仓储 +- 增加 `detail/dependencies/source/updated_at` +- 增强筛选、排序、归档 +- 视情况增加 `blocked` + +注意: + +- 这一阶段增强 Todo,但不修改 Flow 主循环 +- 若需要改 `react.go` 或 `plan_execute.go` 才能支持 Todo 新能力,说明边界设计失败 + +--- + +## 12. 测试策略 + +## 12.1 Flow 合同测试 + +用 fake 端口测试: + +- `TurnContextAssembler` +- `TurnFinalizer` +- `FlowToolExecutor` +- `ToolCallParser` + +验证: + +- 流程切换 +- 工具执行 +- 审批暂停与恢复 +- 终止与回退 + +## 12.2 Todo 领域测试 + +验证: + +- 状态迁移是否合法 +- 排序与筛选逻辑 +- 文件仓储与内存仓储的一致性 +- `/todo add` 多词内容场景 + +## 12.3 适配器测试 + +重点覆盖: + +- `context_assembler.go` +- `turn_finalizer.go` +- `tool_executor.go` +- `tool_protocol_json.go` + +验证 Todo 是否正确通过适配层进入 Flow,而非被 Flow 直接依赖。 + +## 12.4 TUI 回归测试 + +TUI 测试目标改为: + +- 接收 Flow 事件 +- 展示工具状态 +- 展示审批 +- 发起 `/y` `/n` +- 处理 `/todo` + +而不是继续测试“本地 JSON 解析 + 本地工具执行 + system message 回灌”。 + +--- + +## 13. 风险与优化建议 + +### 风险 1:Todo 继续演化成新的编排中心 + +规避: + +- 不新增 `PlanService` 作为 Flow 的直接依赖 +- 不让 `react.go` 或 `plan_execute.go` 直接 import Todo 实现细节 + +### 风险 2:TUI 迁移不彻底 + +规避: + +- 第一优先迁走 assistant JSON 解析 +- 第二优先迁走工具执行 +- 第三优先迁走审批恢复状态机 + +### 风险 3:Todo 增强过早,拖慢 Flow 重构 + +规避: + +- 阶段 A/B 只做边界 +- Todo 富模型增强放到阶段 E + +### 风险 4:外部协议破坏兼容 + +规避: + +- 保留 `todo` 工具名 +- 保留 `json_v1` +- 保留 `add/update/list/remove/clear` + +--- + +## 14. 最终建议 + +建议团队采用以下方向: + +- 先按 [flow-design.md](/C:/Users/10116/Desktop/study/gopro/neo-code/docs/flow-design.md) 建立 `Flow engine + Ports + Registry + Policy` +- Todo 不升级为新的流程中心,而是保留为领域能力 +- Todo 与 Flow 的连接统一收敛到 `internal/server/orchestration/flow/` +- TUI 只做渲染与交互,不做编排 +- 在 Flow 稳定后,再逐步增强 Todo 的模型、持久化和规划表达力 + +一句话概括: + +**先让 Todo 成为“被 Flow 正确消费的能力”,再让它变强;不要让 Todo 在 Flow 稳定之前抢走编排中心的位置。** From 2b2f0f0d4c2117332a6c27e6937c04ce07b92d01 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 28 Mar 2026 11:15:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?pref:=E4=BC=98=E5=8C=96TUI=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=92=8C=E7=83=AD=E9=94=AE=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 37 ------ internal/tui/app.go | 22 ++-- internal/tui/commands.go | 6 +- internal/tui/keymap.go | 32 ++--- internal/tui/styles.go | 41 ++++-- internal/tui/update.go | 17 ++- internal/tui/update_test.go | 36 ++++- internal/tui/view.go | 255 +++++++++++++++++++++++------------- 8 files changed, 269 insertions(+), 177 deletions(-) delete mode 100644 .gitignore diff --git a/.gitignore b/.gitignore deleted file mode 100644 index ce2d9e9e..00000000 --- a/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts -cover -coverage -*.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# Local configuration and secrets -.env -config.yaml -config.local.yaml - -# Local data -data/ - -# Editor/IDE -.idea/ -workspace.xml -.vscode/ diff --git a/internal/tui/app.go b/internal/tui/app.go index 3f8a7538..1adc41ab 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -6,7 +6,7 @@ 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" @@ -26,7 +26,7 @@ type App struct { sessions list.Model modelPicker list.Model transcript viewport.Model - input textarea.Model + input textinput.Model activeMessages []provider.Message focus panel width int @@ -48,19 +48,25 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime 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 = "筛选:" + sessionList.FilterInput.Placeholder = "输入关键词…" - 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 @@ -110,5 +116,5 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime } 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..fc5d3305 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -22,12 +22,12 @@ const ( 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" - sidebarTitle = "Sessions" - sidebarSubtitle = "Use / to filter, Enter to open" + sidebarTitle = "Sessions" + sidebarFilterHint = "按 \"/\" 筛选" + sidebarOpenHint = "Enter 打开" draftSessionTitle = "Draft" emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type / to browse local commands." diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index afe04047..c115f686 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -23,59 +23,59 @@ func newKeyMap() keyMap { return keyMap{ Send: key.NewBinding( key.WithKeys("enter", "ctrl+s"), - key.WithHelp("enter", "send"), + key.WithHelp("Enter/Ctrl+S", "发送(输入框)"), ), 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("↑/k", "向上滚动"), ), ScrollDown: key.NewBinding( key.WithKeys("down", "j"), - key.WithHelp("down/j", "down"), + key.WithHelp("↓/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", "跳到底部"), ), } } diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ccbd99e1..199de3d8 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -56,8 +56,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 @@ -71,12 +73,12 @@ func newStyles() styles { panel := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color(colorBorder)). - Background(lipgloss.Color(colorPanel)). + Background(lipgloss.Color(colorBg)). Padding(0, 1) return styles{ doc: lipgloss.NewStyle(). - Padding(1, 2). + Padding(1, 2, 0, 2). Background(lipgloss.Color(colorBg)). Foreground(lipgloss.Color(colorText)), headerBrand: lipgloss.NewStyle(). @@ -99,7 +101,8 @@ func newStyles() styles { panelSubtitle: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), panelBody: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorBg)), empty: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). Padding(1, 0), @@ -123,19 +126,20 @@ func newStyles() styles { streamMeta: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)), streamContent: lipgloss.NewStyle(). - Foreground(lipgloss.Color(colorText)), + Foreground(lipgloss.Color(colorText)). + Background(lipgloss.Color(colorBg)), messageUserTag: tagStyle(colorUser, colorInk), messageAgentTag: tagStyle(colorPrimary, colorInk), messageToolTag: tagStyle(colorSuccess, colorInk), messageBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - PaddingLeft(1), + Padding(0, 1), messageUserBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorText)). - PaddingLeft(1), + Padding(0, 1), messageToolBody: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSuccess)). - PaddingLeft(1), + Padding(0, 1), inlineNotice: lipgloss.NewStyle(). Foreground(lipgloss.Color(colorSubtle)). Italic(true), @@ -154,7 +158,7 @@ func newStyles() styles { codeText: lipgloss.NewStyle(). Foreground(lipgloss.Color("#BAC2DE")), commandMenu: lipgloss.NewStyle(). - Background(lipgloss.Color(colorCode)). + Background(lipgloss.Color(colorBg)). Padding(1, 1), commandMenuTitle: lipgloss.NewStyle(). Bold(true). @@ -166,13 +170,24 @@ 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)). + Background(lipgloss.Color(colorBg)). + Padding(0, 1), + inputBoxFocused: lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color(colorUser)). + Background(lipgloss.Color(colorBg)). + Padding(0, 1), footer: lipgloss.NewStyle(). - PaddingTop(1). - Foreground(lipgloss.Color(colorSubtle)), + Foreground(lipgloss.Color(colorSubtle)). + Background(lipgloss.Color(colorBg)), badgeUser: badge(colorUser, colorInk), badgeAgent: badge(colorPrimary, colorInk), badgeSuccess: badge(colorSuccess, colorInk), diff --git a/internal/tui/update.go b/internal/tui/update.go index 3a4e0c51..a187751b 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -8,6 +8,7 @@ import ( "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" @@ -122,7 +123,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 +136,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: @@ -446,12 +448,17 @@ 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.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..ec2defc5 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -9,6 +9,7 @@ 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" @@ -299,10 +300,18 @@ 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 @@ -320,6 +329,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 +387,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) { @@ -504,7 +532,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, { 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 +655,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 diff --git a/internal/tui/view.go b/internal/tui/view.go index d7490ba9..ec001494 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -1,7 +1,6 @@ package tui import ( - "fmt" "strings" "github.com/charmbracelet/bubbles/list" @@ -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).Background(lipgloss.Color(colorBg)).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,28 @@ 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("")) + workdirWidth := max(12, width-lipgloss.Width(brand)-lipgloss.Width(meta)-(spacerWidth*2)-lipgloss.Width("Workdir ")) + + 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(""), + a.styles.headerMeta.Render("Workdir "+trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + a.styles.headerSpacer.Render(""), + meta, ) + return lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, header) } func (a App) renderBody(lay layout) string { @@ -71,11 +81,32 @@ 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).Background(lipgloss.Color(colorBg)).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 { @@ -89,82 +120,68 @@ func (a App) renderWaterfall(width int, height int) string { ) } - 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 { + frameHeight := a.styles.panelFocused.GetVerticalFrameSize() content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(modelPickerTitle), a.styles.panelSubtitle.Render(modelPickerSubtitle), a.modelPicker.View(), ) - 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.ShowModelPicker { + 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 +190,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 +217,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 +248,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 +317,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 +330,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...) @@ -285,13 +360,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 +374,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 }