Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions internal/runtime/permission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
},
}
Expand Down
14 changes: 4 additions & 10 deletions internal/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
},
}
Expand Down
94 changes: 94 additions & 0 deletions internal/tui/docs/LAYERING.md
Original file line number Diff line number Diff line change
@@ -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 本地推导。
Empty file added internal/tui/state/.gitkeep
Empty file.
17 changes: 17 additions & 0 deletions internal/tui/state/chat_state.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions internal/tui/state/constants.go
Original file line number Diff line number Diff line change
@@ -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
)
53 changes: 53 additions & 0 deletions internal/tui/state/messages.go
Original file line number Diff line number Diff line change
@@ -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
}
43 changes: 43 additions & 0 deletions internal/tui/state/runtime_state.go
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions internal/tui/state/state_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
47 changes: 47 additions & 0 deletions internal/tui/state/ui_state.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading