diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 61516346..23102aea 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -170,19 +170,13 @@ func TestServiceRunPermissionRejectFlow(t *testing.T) { } scripted := &scriptedProvider{ - responses: []scriptedResponse{ + streams: [][]provider.StreamEvent{ { - Message: provider.Message{ - Role: "assistant", - ToolCalls: []provider.ToolCall{ - {ID: "call-ask-reject", Name: "webfetch", Arguments: `{"url":"https://example.com/private"}`}, - }, - }, - FinishReason: "tool_calls", + provider.NewToolCallStartStreamEvent(0, "call-ask-reject", "webfetch"), + provider.NewToolCallDeltaStreamEvent(0, "call-ask-reject", `{"url":"https://example.com/private"}`), }, { - Message: provider.Message{Role: "assistant", Content: "done"}, - FinishReason: "stop", + provider.NewTextDeltaStreamEvent("done"), }, }, } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 25980b8d..64f32bb5 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -1207,19 +1207,13 @@ func TestServiceRunEmitsRememberScopeWhenSessionRejectMemoryHits(t *testing.T) { } scripted := &scriptedProvider{ - responses: []scriptedResponse{ + streams: [][]provider.StreamEvent{ { - Message: provider.Message{ - Role: "assistant", - ToolCalls: []provider.ToolCall{ - {ID: "call-memory-reject", Name: "webfetch", Arguments: `{"url":"https://example.com/private"}`}, - }, - }, - FinishReason: "tool_calls", + provider.NewToolCallStartStreamEvent(0, "call-memory-reject", "webfetch"), + provider.NewToolCallDeltaStreamEvent(0, "call-memory-reject", `{"url":"https://example.com/private"}`), }, { - Message: provider.Message{Role: "assistant", Content: "done"}, - FinishReason: "stop", + provider.NewTextDeltaStreamEvent("done"), }, }, } diff --git a/internal/tui/docs/LAYERING.md b/internal/tui/docs/LAYERING.md new file mode 100644 index 00000000..4ff7b040 --- /dev/null +++ b/internal/tui/docs/LAYERING.md @@ -0,0 +1,94 @@ +# TUI 分层约束(Iteration 0) + +本文档用于约束 `internal/tui` 的分层职责与依赖方向,确保后续迭代按层收敛,不跨层扩散。 + +## 改造范围 + +- 本轮只处理 `internal/tui`。 +- 入口层 `cmd/neocode` 暂不处理。 + +## 分层定义 + +### L1 - Entry(暂缓) + +- 位置:`cmd/neocode/` +- 职责:参数解析、终端初始化、启动 Program。 +- 本轮状态:暂不纳入改造。 + +### L2 - Bootstrap + +- 位置:`internal/tui/bootstrap/` +- 职责:依赖注入(DI)与初始化编排。 +- 负责:工作区/配置初始化、服务装配、Offline/Mock 注入切换。 + +### L3 - App/Core + +- 位置:`internal/tui/core/` +- 职责:Bubble Tea 状态机中枢(ELM 单向数据流)。 +- 负责:消息路由、状态变更、布局调度。 + +### L4 - State + +- 位置:`internal/tui/state/` +- 职责:纯数据容器。 +- 约束:只放结构体和常量,不放方法与副作用。 + +### L5 - Component Adapter + +- 位置:`internal/tui/components/` +- 职责:原子渲染组件。 +- 输入:基础数据或 state。 +- 输出:渲染字符串。 + +### L6 - Services + +- 位置:`internal/tui/services/` +- 职责:对接 runtime/provider/本地系统能力。 +- 约束:统一返回 `tea.Cmd` 或异步产出 `tea.Msg`。 + +### L7 - Infrastructure + +- 位置:`internal/tui/infra/` +- 职责:底层 I/O 与系统能力。 +- 范围:shell 执行、文件扫描、终端 I/O、渲染器、剪贴板等。 + +## 依赖方向(允许) + +- `core` -> `state` +- `core` -> `components` +- `core` -> `services` +- `services` -> `infra` + +## 禁止项 + +- 禁止 `components` 直接访问 runtime/provider 或执行外部 I/O。 +- 禁止 `core` 直接调用底层系统能力(应经 `services`)。 +- 禁止 `state` 承载业务逻辑、网络调用或文件操作。 +- 禁止新增跨层直连(例如 `core` 直接依赖 `infra`)。 +- 禁止在本轮引入行为变更;Iteration 0 只做骨架与规则。 + +## Iteration 0 验收 + +- 目录骨架已创建:`bootstrap/core/state/components/services/infra` +- 分层约束文档已建立 +- `go test ./internal/tui/...` 通过 + +## Iteration 6 补充(Bootstrap 落地) + +- `internal/tui/bootstrap` 已提供 `Build` 装配入口,统一完成 `ConfigManager + Runtime + ProviderService` 注入。 +- 支持 `Mode`(`live/offline/mock`)与 `ServiceFactory` 扩展点,可在不修改 `core` 的情况下替换注入实现。 +- `internal/tui.New(...)` 保持兼容签名,对外作为薄封装;实际装配路径为 `New -> bootstrap.Build -> newApp`。 + +## Iteration 7 补充(Runtime Source 收敛) + +- Runtime 事件新增并接入 UI 桥接: + - `EventToolStatus` + - `EventRunContext` + - `EventUsage` +- Runtime 查询接口已落地: + - `GetRunSnapshot(runID)` + - `GetSessionContext(sessionID)` + - `GetSessionUsage(sessionID)` + - `GetRunUsage(runID)` +- `internal/tui/core/runtime_bridge.go` 统一处理 payload -> VM 映射与 Tool 状态去重合并(覆盖重复/乱序事件场景)。 +- TUI 在会话刷新时优先通过 runtime 查询回填 context/token 快照,避免由 UI 本地推导。 diff --git a/internal/tui/state/.gitkeep b/internal/tui/state/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/state/chat_state.go b/internal/tui/state/chat_state.go new file mode 100644 index 00000000..18440412 --- /dev/null +++ b/internal/tui/state/chat_state.go @@ -0,0 +1,17 @@ +package state + +import "time" + +// ActivityEntry 表示 Activity 面板中的单条事件记录。 +type ActivityEntry struct { + Time time.Time + Kind string + Title string + Detail string + IsError bool +} + +// CommandMenuMeta 表示命令建议菜单的标题等元信息。 +type CommandMenuMeta struct { + Title string +} diff --git a/internal/tui/state/constants.go b/internal/tui/state/constants.go new file mode 100644 index 00000000..3205ff91 --- /dev/null +++ b/internal/tui/state/constants.go @@ -0,0 +1,15 @@ +package state + +import "time" + +// 这些常量定义了输入框与粘贴检测在 Update 流程中的基础行为阈值。 +const ( + ComposerMinHeight = 1 + ComposerMaxHeight = 5 + ComposerPromptWidth = 2 + MouseWheelStepLines = 3 + PasteBurstWindow = 120 * time.Millisecond + PasteEnterGuard = 180 * time.Millisecond + PasteSessionGuard = 5 * time.Second + PasteBurstThreshold = 12 +) diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go new file mode 100644 index 00000000..9016281d --- /dev/null +++ b/internal/tui/state/messages.go @@ -0,0 +1,53 @@ +package state + +import ( + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +// RuntimeMsg 封装 runtime 事件流消息。 +type RuntimeMsg struct { + Event agentruntime.RuntimeEvent +} + +// RuntimeClosedMsg 表示 runtime 事件通道已关闭。 +type RuntimeClosedMsg struct{} + +// RunFinishedMsg 表示一次 Run 调用结束。 +type RunFinishedMsg struct { + Err error +} + +// ModelCatalogRefreshMsg 表示模型目录刷新结果。 +type ModelCatalogRefreshMsg struct { + ProviderID string + Models []config.ModelDescriptor + Err error +} + +// CompactFinishedMsg 表示 compact 调用结束。 +type CompactFinishedMsg struct { + Err error +} + +// LocalCommandResultMsg 表示本地命令执行结果。 +type LocalCommandResultMsg struct { + Notice string + Err error + ProviderChanged bool + ModelChanged bool +} + +// SessionWorkdirResultMsg 表示会话工作目录命令结果。 +type SessionWorkdirResultMsg struct { + Notice string + Workdir string + Err error +} + +// WorkspaceCommandResultMsg 表示工作区命令执行结果。 +type WorkspaceCommandResultMsg struct { + Command string + Output string + Err error +} diff --git a/internal/tui/state/runtime_state.go b/internal/tui/state/runtime_state.go new file mode 100644 index 00000000..7909ed11 --- /dev/null +++ b/internal/tui/state/runtime_state.go @@ -0,0 +1,43 @@ +package state + +import "time" + +// ToolLifecycleStatus 描述工具执行生命周期状态。 +type ToolLifecycleStatus string + +const ( + ToolLifecyclePlanned ToolLifecycleStatus = "planned" + ToolLifecycleRunning ToolLifecycleStatus = "running" + ToolLifecycleSucceeded ToolLifecycleStatus = "succeeded" + ToolLifecycleFailed ToolLifecycleStatus = "failed" +) + +// ToolState 记录单个工具调用在 UI 中展示的状态。 +type ToolState struct { + ToolCallID string + ToolName string + Status ToolLifecycleStatus + Message string + DurationMS int64 + UpdatedAt time.Time +} + +// ContextWindowState 描述 runtime 透出的上下文窗口信息。 +type ContextWindowState struct { + RunID string + SessionID string + Provider string + Model string + Workdir string + Mode string +} + +// TokenUsageState 描述 token 统计在 UI 的展示结构。 +type TokenUsageState struct { + RunInputTokens int + RunOutputTokens int + RunTotalTokens int + SessionInputTokens int + SessionOutputTokens int + SessionTotalTokens int +} diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go new file mode 100644 index 00000000..73e34cda --- /dev/null +++ b/internal/tui/state/state_test.go @@ -0,0 +1,25 @@ +package state + +import "testing" + +func TestPanelAndPickerConstants(t *testing.T) { + if PanelSessions != 0 || PanelTranscript != 1 || PanelActivity != 2 || PanelInput != 3 { + t.Fatalf("unexpected panel constants: %d %d %d %d", PanelSessions, PanelTranscript, PanelActivity, PanelInput) + } + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 { + t.Fatalf("unexpected picker constants: %d %d %d %d", PickerNone, PickerProvider, PickerModel, PickerFile) + } +} + +func TestUIStateCarriesFocusAndPicker(t *testing.T) { + s := UIState{ + Focus: PanelInput, + ActivePicker: PickerModel, + } + if s.Focus != PanelInput { + t.Fatalf("expected focus panel input, got %v", s.Focus) + } + if s.ActivePicker != PickerModel { + t.Fatalf("expected model picker, got %v", s.ActivePicker) + } +} diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go new file mode 100644 index 00000000..ac6c4611 --- /dev/null +++ b/internal/tui/state/ui_state.go @@ -0,0 +1,47 @@ +package state + +import agentruntime "neo-code/internal/runtime" + +// Panel 定义 TUI 中可聚焦的主面板。 +type Panel int + +const ( + PanelSessions Panel = iota + PanelTranscript + PanelActivity + PanelInput +) + +// PickerMode 定义当前激活的选择器类型。 +type PickerMode int + +const ( + PickerNone PickerMode = iota + PickerProvider + PickerModel + PickerFile +) + +// UIState 保存顶层界面状态快照,仅作为数据容器使用。 +type UIState struct { + Sessions []agentruntime.SessionSummary + ActiveSessionID string + ActiveSessionTitle string + ActiveRunID string + InputText string + IsAgentRunning bool + IsCompacting bool + StreamingReply bool + CurrentTool string + ToolStates []ToolState + RunContext ContextWindowState + TokenUsage TokenUsageState + ExecutionError string + StatusText string + CurrentProvider string + CurrentModel string + CurrentWorkdir string + ShowHelp bool + ActivePicker PickerMode + Focus Panel +}