From 8cec73e78348161269017ce42af20b346afaffb8 Mon Sep 17 00:00:00 2001 From: creatang Date: Mon, 6 Apr 2026 14:09:18 +0800 Subject: [PATCH 01/54] refactor(tui): add core command status and workspace helpers --- internal/tui/core/commands/parser.go | 77 +++++++++++++ internal/tui/core/commands/parser_test.go | 66 ++++++++++++ internal/tui/core/commands/workspace.go | 70 ++++++++++++ internal/tui/core/commands/workspace_test.go | 107 +++++++++++++++++++ internal/tui/core/status/snapshot.go | 104 ++++++++++++++++++ internal/tui/core/utils/view_helpers.go | 93 ++++++++++++++++ internal/tui/core/workspace/resolver.go | 50 +++++++++ 7 files changed, 567 insertions(+) create mode 100644 internal/tui/core/commands/parser.go create mode 100644 internal/tui/core/commands/parser_test.go create mode 100644 internal/tui/core/commands/workspace.go create mode 100644 internal/tui/core/commands/workspace_test.go create mode 100644 internal/tui/core/status/snapshot.go create mode 100644 internal/tui/core/utils/view_helpers.go create mode 100644 internal/tui/core/workspace/resolver.go diff --git a/internal/tui/core/commands/parser.go b/internal/tui/core/commands/parser.go new file mode 100644 index 00000000..1ffac80e --- /dev/null +++ b/internal/tui/core/commands/parser.go @@ -0,0 +1,77 @@ +package commands + +import ( + "fmt" + "strings" +) + +// SlashCommand 描述单个 slash 命令定义。 +type SlashCommand struct { + Usage string + Description string +} + +// CommandSuggestion 表示输入匹配后的命令建议。 +type CommandSuggestion struct { + Command SlashCommand + Match bool +} + +// MatchSlashCommands 根据输入匹配可展示的 slash 命令建议。 +func MatchSlashCommands(input string, slashPrefix string, commands []SlashCommand) []CommandSuggestion { + if !strings.HasPrefix(input, slashPrefix) { + return nil + } + + query := strings.ToLower(strings.TrimSpace(input)) + if IsCompleteSlashCommand(query, commands) { + return nil + } + out := make([]CommandSuggestion, 0, len(commands)) + for _, command := range commands { + normalized := strings.ToLower(command.Usage) + match := query == slashPrefix || strings.HasPrefix(normalized, query) + if query == slashPrefix || match || strings.Contains(normalized, query) { + out = append(out, CommandSuggestion{Command: command, Match: match}) + } + } + return out +} + +// IsCompleteSlashCommand 判断输入是否已完整匹配某个命令。 +func IsCompleteSlashCommand(input string, commands []SlashCommand) bool { + for _, command := range commands { + if strings.EqualFold(strings.TrimSpace(command.Usage), strings.TrimSpace(input)) { + return true + } + } + return false +} + +// SplitFirstWord 拆分首个 token 与其后续参数。 +func SplitFirstWord(input string) (string, string) { + input = strings.TrimSpace(input) + if input == "" { + return "", "" + } + index := strings.IndexAny(input, " \t") + if index < 0 { + return input, "" + } + return input[:index], strings.TrimSpace(input[index+1:]) +} + +// IsWorkspaceSlashCommand 判断是否为工作区命令(例如 /cwd)。 +func IsWorkspaceSlashCommand(raw string, commandName string) bool { + command, _ := SplitFirstWord(strings.ToLower(strings.TrimSpace(raw))) + return command == strings.ToLower(strings.TrimSpace(commandName)) +} + +// ParseWorkspaceSlashCommand 解析工作区命令参数,非目标命令时返回错误。 +func ParseWorkspaceSlashCommand(raw string, commandName string) (string, error) { + command, args := SplitFirstWord(strings.TrimSpace(raw)) + if strings.ToLower(command) != strings.ToLower(strings.TrimSpace(commandName)) { + return "", fmt.Errorf("unknown command %q", command) + } + return strings.TrimSpace(args), nil +} diff --git a/internal/tui/core/commands/parser_test.go b/internal/tui/core/commands/parser_test.go new file mode 100644 index 00000000..a43915c4 --- /dev/null +++ b/internal/tui/core/commands/parser_test.go @@ -0,0 +1,66 @@ +package commands + +import "testing" + +func TestMatchSlashCommands(t *testing.T) { + commands := []SlashCommand{ + {Usage: "/help", Description: "show help"}, + {Usage: "/provider", Description: "pick provider"}, + {Usage: "/model", Description: "pick model"}, + } + + got := MatchSlashCommands("/pro", "/", commands) + if len(got) != 1 { + t.Fatalf("expected one suggestion for /pro, got %d", len(got)) + } + if got[0].Command.Usage != "/provider" || !got[0].Match { + t.Fatalf("unexpected suggestion: %+v", got[0]) + } + + if complete := MatchSlashCommands("/help", "/", commands); complete != nil { + t.Fatalf("expected nil suggestion when command is complete, got %+v", complete) + } +} + +func TestIsCompleteSlashCommand(t *testing.T) { + commands := []SlashCommand{{Usage: "/help"}, {Usage: "/provider"}} + if !IsCompleteSlashCommand("/help", commands) { + t.Fatalf("expected /help to be complete") + } + if IsCompleteSlashCommand("/hel", commands) { + t.Fatalf("expected /hel to be incomplete") + } +} + +func TestSplitFirstWord(t *testing.T) { + first, rest := SplitFirstWord(" /cwd ./tmp/project ") + if first != "/cwd" || rest != "./tmp/project" { + t.Fatalf("unexpected split result: first=%q rest=%q", first, rest) + } + + first, rest = SplitFirstWord(" ") + if first != "" || rest != "" { + t.Fatalf("expected empty split for blank input, got first=%q rest=%q", first, rest) + } +} + +func TestWorkspaceSlashCommandHelpers(t *testing.T) { + if !IsWorkspaceSlashCommand("/cwd ./tmp", "/cwd") { + t.Fatalf("expected /cwd to be recognized") + } + if IsWorkspaceSlashCommand("/status", "/cwd") { + t.Fatalf("did not expect /status as workspace command") + } + + args, err := ParseWorkspaceSlashCommand("/cwd ./tmp", "/cwd") + if err != nil { + t.Fatalf("ParseWorkspaceSlashCommand() error = %v", err) + } + if args != "./tmp" { + t.Fatalf("expected args ./tmp, got %q", args) + } + + if _, err := ParseWorkspaceSlashCommand("/status", "/cwd"); err == nil { + t.Fatalf("expected parse error for non-workspace command") + } +} diff --git a/internal/tui/core/commands/workspace.go b/internal/tui/core/commands/workspace.go new file mode 100644 index 00000000..0886164d --- /dev/null +++ b/internal/tui/core/commands/workspace.go @@ -0,0 +1,70 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + agentruntime "neo-code/internal/runtime" +) + +// SessionWorkdirSetter 定义设置会话工作目录所需的最小 runtime 能力。 +type SessionWorkdirSetter interface { + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) +} + +// SessionWorkdirCommandResult 表示工作目录命令执行结果。 +type SessionWorkdirCommandResult struct { + Notice string + Workdir string + Err error +} + +// ExecuteSessionWorkdirCommand 执行 /cwd 命令的核心流程,返回统一结果结构。 +func ExecuteSessionWorkdirCommand( + runtime SessionWorkdirSetter, + sessionID string, + currentWorkdir string, + raw string, + parseCommand func(string) (string, error), + resolveWorkspacePath func(string, string) (string, error), + selectSessionWorkdir func(string, string) string, +) SessionWorkdirCommandResult { + requested, err := parseCommand(raw) + if err != nil { + return SessionWorkdirCommandResult{Err: err} + } + + if strings.TrimSpace(requested) == "" { + workdir := strings.TrimSpace(currentWorkdir) + if workdir == "" { + return SessionWorkdirCommandResult{Err: fmt.Errorf("usage: /cwd ")} + } + return SessionWorkdirCommandResult{ + Notice: fmt.Sprintf("[System] Current workspace is %s.", workdir), + Workdir: workdir, + } + } + + if strings.TrimSpace(sessionID) == "" { + workdir, err := resolveWorkspacePath(currentWorkdir, requested) + if err != nil { + return SessionWorkdirCommandResult{Err: err} + } + return SessionWorkdirCommandResult{ + Notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), + Workdir: workdir, + } + } + + session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) + if err != nil { + return SessionWorkdirCommandResult{Err: err} + } + + workdir := selectSessionWorkdir(session.Workdir, currentWorkdir) + return SessionWorkdirCommandResult{ + Notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), + Workdir: workdir, + } +} diff --git a/internal/tui/core/commands/workspace_test.go b/internal/tui/core/commands/workspace_test.go new file mode 100644 index 00000000..53650046 --- /dev/null +++ b/internal/tui/core/commands/workspace_test.go @@ -0,0 +1,107 @@ +package commands + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + agentruntime "neo-code/internal/runtime" + tuiworkspace "neo-code/internal/tui/core/workspace" +) + +type stubSessionWorkdirSetter struct { + session agentruntime.Session + err error + calls int +} + +func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { + s.calls++ + if s.err != nil { + return agentruntime.Session{}, s.err + } + return s.session, nil +} + +func TestExecuteSessionWorkdirCommand(t *testing.T) { + parse := func(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "/bad" { + return "", errors.New("unknown command") + } + if raw == "/cwd" { + return "", nil + } + if strings.HasPrefix(raw, "/cwd ") { + return strings.TrimSpace(strings.TrimPrefix(raw, "/cwd ")), nil + } + return "", errors.New("unknown command") + } + + t.Run("parse error", func(t *testing.T) { + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "/bad", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + if result.Err == nil { + t.Fatalf("expected parse error") + } + }) + + t.Run("empty requested without current workdir", func(t *testing.T) { + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", "", "/cwd", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { + t.Fatalf("expected usage error, got %+v", result) + } + }) + + t.Run("empty requested with current workdir", func(t *testing.T) { + current := t.TempDir() + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", current, "/cwd", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if result.Workdir != current || !strings.Contains(result.Notice, "Current workspace is") { + t.Fatalf("unexpected result: %+v", result) + } + }) + + t.Run("draft session resolves requested path", func(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "sub") + if err := ensureDir(target); err != nil { + t.Fatalf("mkdir target: %v", err) + } + result := ExecuteSessionWorkdirCommand(&stubSessionWorkdirSetter{}, "", base, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Notice, "Draft workspace switched") { + t.Fatalf("unexpected notice: %q", result.Notice) + } + }) + + t.Run("runtime error", func(t *testing.T) { + stub := &stubSessionWorkdirSetter{err: errors.New("set workdir failed")} + result := ExecuteSessionWorkdirCommand(stub, "session-1", t.TempDir(), "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + if result.Err == nil || !strings.Contains(result.Err.Error(), "set workdir failed") { + t.Fatalf("expected runtime error, got %+v", result) + } + }) + + t.Run("runtime empty workdir fallback", func(t *testing.T) { + current := t.TempDir() + stub := &stubSessionWorkdirSetter{session: agentruntime.Session{ID: "session-1", Workdir: ""}} + result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if result.Workdir != current { + t.Fatalf("expected fallback workdir %q, got %q", current, result.Workdir) + } + }) +} + +func ensureDir(path string) error { + return os.MkdirAll(path, 0o755) +} diff --git a/internal/tui/core/status/snapshot.go b/internal/tui/core/status/snapshot.go new file mode 100644 index 00000000..6444f86c --- /dev/null +++ b/internal/tui/core/status/snapshot.go @@ -0,0 +1,104 @@ +package status + +import ( + "fmt" + "strings" + + tuiutils "neo-code/internal/tui/core/utils" + tuistate "neo-code/internal/tui/state" +) + +// Snapshot 表示 /status 命令所需的界面状态快照。 +type Snapshot struct { + ActiveSessionID string + ActiveSessionTitle string + ActiveRunID string + IsAgentRunning bool + IsCompacting bool + CurrentProvider string + CurrentModel string + CurrentWorkdir string + CurrentTool string + ToolStateCount int + RunTotalTokens int + SessionTotalTokens int + ExecutionError string + FocusLabel string + PickerLabel string + MessageCount int +} + +// BuildFromUIState 根据 UIState 与附加上下文构建 /status 所需快照。 +func BuildFromUIState( + state tuistate.UIState, + messageCount int, + focusLabel string, + pickerLabel string, +) Snapshot { + return Snapshot{ + ActiveSessionID: state.ActiveSessionID, + ActiveSessionTitle: state.ActiveSessionTitle, + ActiveRunID: state.ActiveRunID, + IsAgentRunning: state.IsAgentRunning, + IsCompacting: state.IsCompacting, + CurrentProvider: state.CurrentProvider, + CurrentModel: state.CurrentModel, + CurrentWorkdir: state.CurrentWorkdir, + CurrentTool: state.CurrentTool, + ToolStateCount: len(state.ToolStates), + RunTotalTokens: state.TokenUsage.RunTotalTokens, + SessionTotalTokens: state.TokenUsage.SessionTotalTokens, + ExecutionError: state.ExecutionError, + FocusLabel: focusLabel, + PickerLabel: pickerLabel, + MessageCount: messageCount, + } +} + +// Format 将状态快照格式化为多行文本,用于 /status 命令输出。 +func Format(snapshot Snapshot, draftSessionTitle string) string { + sessionID := snapshot.ActiveSessionID + if strings.TrimSpace(sessionID) == "" { + sessionID = "" + } + sessionTitle := snapshot.ActiveSessionTitle + if strings.TrimSpace(sessionTitle) == "" { + sessionTitle = draftSessionTitle + } + running := "no" + if snapshot.IsAgentRunning || snapshot.IsCompacting { + running = "yes" + } + currentTool := snapshot.CurrentTool + if strings.TrimSpace(currentTool) == "" { + currentTool = "" + } + errorText := snapshot.ExecutionError + if strings.TrimSpace(errorText) == "" { + errorText = "" + } + picker := snapshot.PickerLabel + if strings.TrimSpace(picker) == "" { + picker = "none" + } + + lines := []string{ + "Status:", + "Session: " + sessionTitle, + "Session ID: " + sessionID, + "Run ID: " + tuiutils.Fallback(strings.TrimSpace(snapshot.ActiveRunID), ""), + "Running: " + running, + "Provider: " + snapshot.CurrentProvider, + "Model: " + snapshot.CurrentModel, + "Workdir: " + snapshot.CurrentWorkdir, + "Focus: " + snapshot.FocusLabel, + "Picker: " + picker, + "Current Tool: " + currentTool, + fmt.Sprintf("Tool States: %d", snapshot.ToolStateCount), + fmt.Sprintf("Run Tokens: %d", snapshot.RunTotalTokens), + fmt.Sprintf("Session Tokens: %d", snapshot.SessionTotalTokens), + fmt.Sprintf("Messages: %d", snapshot.MessageCount), + "Error: " + errorText, + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go new file mode 100644 index 00000000..4c0d029a --- /dev/null +++ b/internal/tui/core/utils/view_helpers.go @@ -0,0 +1,93 @@ +package utils + +import ( + "strings" + + tuistate "neo-code/internal/tui/state" +) + +// PickerLabelFromMode 将 picker 模式映射为状态快照展示标签。 +func PickerLabelFromMode(mode tuistate.PickerMode) string { + switch mode { + case tuistate.PickerProvider: + return "provider" + case tuistate.PickerModel: + return "model" + case tuistate.PickerFile: + return "file" + default: + return "none" + } +} + +// RequestedWorkdirForRun 在发起 run 时计算应转发的工作目录。 +func RequestedWorkdirForRun(activeSessionID string, currentWorkdir string) string { + if strings.TrimSpace(activeSessionID) == "" { + return currentWorkdir + } + return "" +} + +// IsBusy 统一判断当前是否存在进行中的 agent 或 compact 操作。 +func IsBusy(isAgentRunning bool, isCompacting bool) bool { + return isAgentRunning || isCompacting +} + +// FocusLabelFromPanel 将焦点面板枚举映射为界面展示标签。 +func FocusLabelFromPanel( + focus tuistate.Panel, + sessionsLabel string, + transcriptLabel string, + activityLabel string, + composerLabel string, +) string { + switch focus { + case tuistate.PanelSessions: + return sessionsLabel + case tuistate.PanelTranscript: + return transcriptLabel + case tuistate.PanelActivity: + return activityLabel + default: + return composerLabel + } +} + +// TrimRunes 按 rune 数裁剪文本,超长时尾部追加省略号。 +func TrimRunes(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit || limit < 4 { + return text + } + return string(runes[:limit-3]) + "..." +} + +// TrimMiddle 在中间裁剪长文本,保留首尾并插入省略号。 +func TrimMiddle(text string, limit int) string { + runes := []rune(text) + if len(runes) <= limit || limit < 7 { + return text + } + left := (limit - 3) / 2 + right := limit - 3 - left + return string(runes[:left]) + "..." + string(runes[len(runes)-right:]) +} + +// Fallback 当 value 为空白文本时返回 fallbackValue。 +func Fallback(value string, fallbackValue string) string { + if strings.TrimSpace(value) == "" { + return fallbackValue + } + return value +} + +// Clamp 将数值限制在 [minValue, maxValue] 范围内。 +func Clamp(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} diff --git a/internal/tui/core/workspace/resolver.go b/internal/tui/core/workspace/resolver.go new file mode 100644 index 00000000..ccf6c103 --- /dev/null +++ b/internal/tui/core/workspace/resolver.go @@ -0,0 +1,50 @@ +package workspace + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// ResolveWorkspacePath 解析并校验工作区路径,确保返回存在且可用的目录绝对路径。 +func ResolveWorkspacePath(base string, requested string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + workingDir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("workspace: resolve current directory: %w", err) + } + base = workingDir + } + + target := strings.TrimSpace(requested) + if target == "" { + target = "." + } + if !filepath.IsAbs(target) { + target = filepath.Join(base, target) + } + + absolute, err := filepath.Abs(target) + if err != nil { + return "", fmt.Errorf("workspace: resolve path: %w", err) + } + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("workspace: resolve path: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("workspace: %q is not a directory", absolute) + } + return filepath.Clean(absolute), nil +} + +// SelectSessionWorkdir 优先返回会话工作目录,缺失时回退到默认工作目录。 +func SelectSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { + workdir := strings.TrimSpace(sessionWorkdir) + if workdir != "" { + return workdir + } + return strings.TrimSpace(defaultWorkdir) +} From 0e21cf367d2bbd57f1d9f734dcd8d379b3eda569 Mon Sep 17 00:00:00 2001 From: creatang Date: Mon, 6 Apr 2026 14:09:54 +0800 Subject: [PATCH 02/54] refactor(tui): migrate app into core and bootstrap wiring --- internal/tui/bootstrap/.gitkeep | 0 internal/tui/bootstrap/builder.go | 90 +++ internal/tui/bootstrap/builder_test.go | 236 ++++++ internal/tui/bootstrap/factory.go | 25 + internal/tui/bootstrap/mode.go | 27 + internal/tui/{ => core/app}/app.go | 157 +++- internal/tui/{ => core/app}/command_menu.go | 152 +++- .../tui/{ => core/app}/command_menu_test.go | 0 internal/tui/{ => core/app}/commands.go | 224 ++---- internal/tui/{ => core/app}/commands_test.go | 13 +- internal/tui/{ => core/app}/copy_code.go | 7 +- internal/tui/{ => core/app}/copy_code_test.go | 0 internal/tui/{ => core/app}/input_features.go | 248 +----- .../tui/{ => core/app}/input_features_test.go | 2 +- internal/tui/{ => core/app}/keymap.go | 0 internal/tui/core/app/markdown_renderer.go | 17 + .../{ => core/app}/markdown_renderer_test.go | 30 +- internal/tui/{ => core/app}/styles.go | 36 +- internal/tui/{ => core/app}/update.go | 757 ++++++++++-------- internal/tui/{ => core/app}/update_test.go | 134 +++- internal/tui/{ => core/app}/view.go | 128 ++- internal/tui/markdown_renderer.go | 100 --- internal/tui/provider_service.go | 15 - internal/tui/state.go | 214 ----- internal/tui/tui.go | 21 + 25 files changed, 1364 insertions(+), 1269 deletions(-) create mode 100644 internal/tui/bootstrap/.gitkeep create mode 100644 internal/tui/bootstrap/builder.go create mode 100644 internal/tui/bootstrap/builder_test.go create mode 100644 internal/tui/bootstrap/factory.go create mode 100644 internal/tui/bootstrap/mode.go rename internal/tui/{ => core/app}/app.go (59%) rename internal/tui/{ => core/app}/command_menu.go (58%) rename internal/tui/{ => core/app}/command_menu_test.go (100%) rename internal/tui/{ => core/app}/commands.go (66%) rename internal/tui/{ => core/app}/commands_test.go (96%) rename internal/tui/{ => core/app}/copy_code.go (96%) rename internal/tui/{ => core/app}/copy_code_test.go (100%) rename internal/tui/{ => core/app}/input_features.go (51%) rename internal/tui/{ => core/app}/input_features_test.go (99%) rename internal/tui/{ => core/app}/keymap.go (100%) create mode 100644 internal/tui/core/app/markdown_renderer.go rename internal/tui/{ => core/app}/markdown_renderer_test.go (71%) rename internal/tui/{ => core/app}/styles.go (92%) rename internal/tui/{ => core/app}/update.go (65%) rename internal/tui/{ => core/app}/update_test.go (95%) rename internal/tui/{ => core/app}/view.go (83%) delete mode 100644 internal/tui/markdown_renderer.go delete mode 100644 internal/tui/provider_service.go delete mode 100644 internal/tui/state.go create mode 100644 internal/tui/tui.go diff --git a/internal/tui/bootstrap/.gitkeep b/internal/tui/bootstrap/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/tui/bootstrap/builder.go b/internal/tui/bootstrap/builder.go new file mode 100644 index 00000000..739b6970 --- /dev/null +++ b/internal/tui/bootstrap/builder.go @@ -0,0 +1,90 @@ +package bootstrap + +import ( + "context" + "fmt" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +// ProviderService 定义 TUI 需要注入的 provider 交互能力。 +type ProviderService interface { + ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) + SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + ListModels(ctx context.Context) ([]config.ModelDescriptor, error) + ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) + SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) +} + +// Options 定义 bootstrap 装配输入。 +type Options struct { + Config *config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + Mode Mode + Factory ServiceFactory +} + +// Container 表示完成装配后供 TUI Core 使用的依赖集合。 +type Container struct { + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderService ProviderService + Mode Mode +} + +// Build 执行 TUI bootstrap 装配,并返回可注入到 App/Core 的容器。 +func Build(options Options) (Container, error) { + if options.ConfigManager == nil { + return Container{}, fmt.Errorf("tui bootstrap: config manager is nil") + } + if options.Runtime == nil { + return Container{}, fmt.Errorf("tui bootstrap: runtime is nil") + } + if options.ProviderService == nil { + return Container{}, fmt.Errorf("tui bootstrap: provider service is nil") + } + + mode := NormalizeMode(options.Mode) + cfg := resolveConfigSnapshot(options.Config, options.ConfigManager) + + factory := options.Factory + if factory == nil { + factory = passthroughFactory{} + } + + runtimeSvc, err := factory.BuildRuntime(mode, options.Runtime) + if err != nil { + return Container{}, fmt.Errorf("tui bootstrap: build runtime: %w", err) + } + if runtimeSvc == nil { + return Container{}, fmt.Errorf("tui bootstrap: runtime factory returned nil") + } + + providerSvc, err := factory.BuildProvider(mode, options.ProviderService) + if err != nil { + return Container{}, fmt.Errorf("tui bootstrap: build provider service: %w", err) + } + if providerSvc == nil { + return Container{}, fmt.Errorf("tui bootstrap: provider factory returned nil") + } + + return Container{ + Config: cfg, + ConfigManager: options.ConfigManager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Mode: mode, + }, nil +} + +// resolveConfigSnapshot 返回用于本次 TUI 初始化的配置快照。 +func resolveConfigSnapshot(cfg *config.Config, manager *config.Manager) config.Config { + if cfg == nil { + return manager.Get() + } + return cfg.Clone() +} diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go new file mode 100644 index 00000000..0957423f --- /dev/null +++ b/internal/tui/bootstrap/builder_test.go @@ -0,0 +1,236 @@ +package bootstrap + +import ( + "context" + "errors" + "strings" + "testing" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" +) + +type stubRuntime struct { + events chan agentruntime.RuntimeEvent +} + +func newStubRuntime() *stubRuntime { + return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} +} + +func (s *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (s *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (s *stubRuntime) CancelActiveRun() bool { + return false +} + +func (s *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { + return s.events +} + +func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { + return nil, nil +} + +func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentruntime.Session, error) { + return agentruntime.Session{}, nil +} + +func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { + return agentruntime.Session{}, nil +} + +type stubProviderService struct{} + +func (s *stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s *stubProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s *stubProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *stubProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +type spyFactory struct { + runtimeOut agentruntime.Runtime + providerOut ProviderService + err error + modeSeen Mode + runtimeHits int + providerHits int +} + +func (s *spyFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + s.modeSeen = mode + s.runtimeHits++ + if s.err != nil { + return nil, s.err + } + if s.runtimeOut != nil { + return s.runtimeOut, nil + } + return current, nil +} + +func (s *spyFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + s.modeSeen = mode + s.providerHits++ + if s.err != nil { + return nil, s.err + } + if s.providerOut != nil { + return s.providerOut, nil + } + return current, nil +} + +func TestBuildValidatesDependencies(t *testing.T) { + manager := newTestConfigManager(t) + runtimeSvc := newStubRuntime() + providerSvc := &stubProviderService{} + + cases := []struct { + name string + options Options + wantErr string + }{ + { + name: "nil manager", + options: Options{ + Runtime: runtimeSvc, + ProviderService: providerSvc, + }, + wantErr: "config manager is nil", + }, + { + name: "nil runtime", + options: Options{ + ConfigManager: manager, + ProviderService: providerSvc, + }, + wantErr: "runtime is nil", + }, + { + name: "nil provider", + options: Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + }, + wantErr: "provider service is nil", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Build(tc.options) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("Build() error = %v, want substring %q", err, tc.wantErr) + } + }) + } +} + +func TestBuildUsesManagerSnapshotWhenConfigNil(t *testing.T) { + manager := newTestConfigManager(t) + runtimeSvc := newStubRuntime() + providerSvc := &stubProviderService{} + + container, err := Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + if container.Mode != ModeLive { + t.Fatalf("expected default mode %q, got %q", ModeLive, container.Mode) + } + if container.Config.SelectedProvider != manager.Get().SelectedProvider { + t.Fatalf("expected config snapshot from manager, got %+v", container.Config) + } +} + +func TestBuildUsesConfigSnapshotAndFactory(t *testing.T) { + manager := newTestConfigManager(t) + runtimeSvc := newStubRuntime() + providerSvc := &stubProviderService{} + + override := manager.Get() + override.CurrentModel = "custom-model" + + altRuntime := newStubRuntime() + altProvider := &stubProviderService{} + factory := &spyFactory{ + runtimeOut: altRuntime, + providerOut: altProvider, + } + + container, err := Build(Options{ + Config: &override, + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Mode: ModeMock, + Factory: factory, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + + override.CurrentModel = "mutated-after-build" + if container.Config.CurrentModel != "custom-model" { + t.Fatalf("expected config clone snapshot, got %q", container.Config.CurrentModel) + } + if container.Runtime != altRuntime || container.ProviderService != altProvider { + t.Fatalf("expected factory outputs to be injected") + } + if factory.modeSeen != ModeMock || factory.runtimeHits != 1 || factory.providerHits != 1 { + t.Fatalf("factory was not invoked as expected: %+v", factory) + } +} + +func TestBuildFactoryError(t *testing.T) { + manager := newTestConfigManager(t) + factory := &spyFactory{err: errors.New("boom")} + + _, err := Build(Options{ + ConfigManager: manager, + Runtime: newStubRuntime(), + ProviderService: &stubProviderService{}, + Factory: factory, + }) + if err == nil || !strings.Contains(err.Error(), "build runtime") { + t.Fatalf("Build() error = %v, want runtime factory error", err) + } +} + +// newTestConfigManager 创建隔离配置目录,返回可用于 bootstrap 单测的配置管理器。 +func newTestConfigManager(t *testing.T) *config.Manager { + t.Helper() + + loader := config.NewLoader(t.TempDir(), config.DefaultConfig()) + manager := config.NewManager(loader) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("manager.Load() error = %v", err) + } + return manager +} diff --git a/internal/tui/bootstrap/factory.go b/internal/tui/bootstrap/factory.go new file mode 100644 index 00000000..9077cdc5 --- /dev/null +++ b/internal/tui/bootstrap/factory.go @@ -0,0 +1,25 @@ +package bootstrap + +import ( + agentruntime "neo-code/internal/runtime" +) + +// ServiceFactory 定义 runtime/provider 的可切换装配策略。 +type ServiceFactory interface { + // BuildRuntime 根据 mode 返回实际注入到 TUI 的 runtime 实现。 + BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) + // BuildProvider 根据 mode 返回实际注入到 TUI 的 provider service 实现。 + BuildProvider(mode Mode, current ProviderService) (ProviderService, error) +} + +type passthroughFactory struct{} + +// BuildRuntime 默认直接透传已有 runtime,不做替换。 +func (passthroughFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + return current, nil +} + +// BuildProvider 默认直接透传已有 provider service,不做替换。 +func (passthroughFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + return current, nil +} diff --git a/internal/tui/bootstrap/mode.go b/internal/tui/bootstrap/mode.go new file mode 100644 index 00000000..8abf93b1 --- /dev/null +++ b/internal/tui/bootstrap/mode.go @@ -0,0 +1,27 @@ +package bootstrap + +import "strings" + +// Mode 定义 TUI bootstrap 的装配模式。 +type Mode string + +const ( + // ModeLive 表示使用真实依赖进行正常装配。 + ModeLive Mode = "live" + // ModeOffline 表示使用离线装配策略(可由工厂映射为本地实现)。 + ModeOffline Mode = "offline" + // ModeMock 表示使用 mock 装配策略(通常用于测试)。 + ModeMock Mode = "mock" +) + +// NormalizeMode 归一化 mode 输入,未知值默认回退到 live。 +func NormalizeMode(mode Mode) Mode { + switch Mode(strings.ToLower(strings.TrimSpace(string(mode)))) { + case ModeOffline: + return ModeOffline + case ModeMock: + return ModeMock + default: + return ModeLive + } +} diff --git a/internal/tui/app.go b/internal/tui/core/app/app.go similarity index 59% rename from internal/tui/app.go rename to internal/tui/core/app/app.go index 2fdfaf81..4a52095e 100644 --- a/internal/tui/app.go +++ b/internal/tui/core/app/app.go @@ -1,7 +1,7 @@ package tui import ( - "fmt" + "context" "time" "github.com/charmbracelet/bubbles/filepicker" @@ -17,19 +17,60 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" agentruntime "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuistate "neo-code/internal/tui/state" ) -type App struct { - state UIState - configManager *config.Manager - providerSvc ProviderController - runtime agentruntime.Runtime +type panel = tuistate.Panel + +const ( + panelSessions panel = tuistate.PanelSessions + panelTranscript panel = tuistate.PanelTranscript + panelActivity panel = tuistate.PanelActivity + panelInput panel = tuistate.PanelInput +) + +type pickerMode = tuistate.PickerMode + +const ( + pickerNone pickerMode = tuistate.PickerNone + pickerProvider pickerMode = tuistate.PickerProvider + pickerModel pickerMode = tuistate.PickerModel + pickerFile pickerMode = tuistate.PickerFile +) + +type RuntimeMsg = tuistate.RuntimeMsg +type RuntimeClosedMsg = tuistate.RuntimeClosedMsg +type runFinishedMsg = tuistate.RunFinishedMsg +type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg +type compactFinishedMsg = tuistate.CompactFinishedMsg +type localCommandResultMsg = tuistate.LocalCommandResultMsg +type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg +type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg + +type ProviderController interface { + ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) + SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) + ListModels(ctx context.Context) ([]config.ModelDescriptor, error) + ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) + SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) +} + +// appServices 聚合 App 需要的服务依赖,避免与渲染状态混在同一层级。 +type appServices struct { + configManager *config.Manager + providerSvc ProviderController + runtime agentruntime.Runtime +} + +// appComponents 聚合 Bubble Tea 组件与渲染器。 +type appComponents struct { keys keyMap help help.Model spinner spinner.Model sessions list.Model commandMenu list.Model - commandMenuMeta commandMenuMeta + commandMenuMeta tuistate.CommandMenuMeta providerPicker list.Model modelPicker list.Model fileBrowser filepicker.Model @@ -38,6 +79,10 @@ type App struct { activity viewport.Model input textarea.Model markdownRenderer markdownContentRenderer +} + +// appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 +type appRuntimeState struct { codeCopyBlocks map[int]string pendingCopyID int nowFn func() time.Time @@ -47,29 +92,49 @@ type App struct { inputBurstCount int pasteMode bool activeMessages []provider.Message - activities []activityEntry + activities []tuistate.ActivityEntry fileCandidates []string modelRefreshID string focus panel runProgressValue float64 runProgressKnown bool runProgressLabel string - width int - height int - styles styles +} + +type App struct { + state tuistate.UIState + appServices + appComponents + appRuntimeState + width int + height int + styles styles } 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 + return NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: configManager, + Runtime: runtime, + ProviderService: providerSvc, + }) +} + +// NewWithBootstrap 通过 bootstrap 层完成依赖装配,再构建可运行的 TUI App。 +func NewWithBootstrap(options tuibootstrap.Options) (App, error) { + container, err := tuibootstrap.Build(options) + if err != nil { + return App{}, err } + return newApp(container) +} + +// newApp 根据 bootstrap 装配结果初始化 App 状态与组件。 +func newApp(container tuibootstrap.Container) (App, error) { + cfg := container.Config + configManager := container.ConfigManager + runtime := container.Runtime + providerSvc := container.ProviderService uiStyles := newStyles() markdownRenderer, err := newMarkdownRenderer() @@ -135,7 +200,7 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime progressBar.Width = 22 app := App{ - state: UIState{ + state: tuistate.UIState{ StatusText: statusReady, CurrentProvider: cfg.SelectedProvider, CurrentModel: cfg.CurrentModel, @@ -143,28 +208,34 @@ func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime ActiveSessionTitle: draftSessionTitle, Focus: panelInput, }, - configManager: configManager, - providerSvc: providerSvc, - runtime: runtime, - keys: keys, - help: h, - spinner: spin, - sessions: sessionList, - commandMenu: commandMenu, - providerPicker: newSelectionPickerItems(nil), - modelPicker: newSelectionPickerItems(nil), - fileBrowser: fileBrowser, - progress: progressBar, - transcript: viewport.New(0, 0), - activity: viewport.New(0, 0), - input: input, - markdownRenderer: markdownRenderer, - codeCopyBlocks: make(map[int]string), - nowFn: time.Now, - focus: panelInput, - width: 128, - height: 40, - styles: uiStyles, + appServices: appServices{ + configManager: configManager, + providerSvc: providerSvc, + runtime: runtime, + }, + appComponents: appComponents{ + keys: keys, + help: h, + spinner: spin, + sessions: sessionList, + commandMenu: commandMenu, + providerPicker: newSelectionPickerItems(nil), + modelPicker: newSelectionPickerItems(nil), + fileBrowser: fileBrowser, + progress: progressBar, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + input: input, + markdownRenderer: markdownRenderer, + }, + appRuntimeState: appRuntimeState{ + codeCopyBlocks: make(map[int]string), + nowFn: time.Now, + focus: panelInput, + }, + width: 128, + height: 40, + styles: uiStyles, } if err := app.refreshSessions(); err != nil { diff --git a/internal/tui/command_menu.go b/internal/tui/core/app/command_menu.go similarity index 58% rename from internal/tui/command_menu.go rename to internal/tui/core/app/command_menu.go index b05ae8c9..2effe828 100644 --- a/internal/tui/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -1,11 +1,18 @@ -package tui +package tui import ( + "fmt" + "io" "path/filepath" "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/runtime" + tuicomponents "neo-code/internal/tui/components" + tuiutils "neo-code/internal/tui/core/utils" + tuistate "neo-code/internal/tui/state" ) const ( @@ -13,18 +20,142 @@ const ( commandMenuBrowse = "@ browse files..." ) +type commandMenuItem struct { + title string + description string + filter string + highlight bool + replacement string + useReplaceRange bool + replaceStart int + replaceEnd int + openFileBrowser bool +} + +func (c commandMenuItem) Title() string { + return c.title +} + +func (c commandMenuItem) Description() string { + return c.description +} + +func (c commandMenuItem) FilterValue() string { + base := strings.TrimSpace(c.filter) + if base != "" { + return strings.ToLower(base) + } + return strings.ToLower(c.title + " " + c.description) +} + +type commandMenuDelegate struct { + styles styles +} + +func (d commandMenuDelegate) Height() int { + return 1 +} + +func (d commandMenuDelegate) Spacing() int { + return 0 +} + +func (d commandMenuDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} + +func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + entry, ok := item.(commandMenuItem) + if !ok { + return + } + fmt.Fprint(w, tuicomponents.RenderCommandMenuRow(tuicomponents.CommandMenuRowData{ + Title: entry.title, + Description: entry.description, + Highlight: entry.highlight, + Selected: index == m.Index(), + Width: m.Width(), + UsageStyle: d.styles.commandUsage, + UsageMatchStyle: d.styles.commandUsageMatch, + DescriptionStyle: d.styles.commandDesc, + })) +} + +type sessionItem struct { + Summary agentruntime.SessionSummary + Active bool +} + +func (s sessionItem) FilterValue() string { + return strings.ToLower(s.Summary.Title) +} + +type selectionItem struct { + id string + name string + description string +} + +func (s selectionItem) Title() string { + return s.name +} + +func (s selectionItem) Description() string { + return s.description +} + +func (s selectionItem) FilterValue() string { + return strings.ToLower(s.id + " " + s.name + " " + s.description) +} + +type sessionDelegate struct { + styles styles +} + +func (d sessionDelegate) Height() int { + return 3 +} + +func (d sessionDelegate) Spacing() int { + return 1 +} + +func (d sessionDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} + +func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + session, ok := item.(sessionItem) + if !ok { + return + } + fmt.Fprint(w, tuicomponents.RenderSessionRow(tuicomponents.SessionRowData{ + Title: session.Summary.Title, + UpdatedAtLabel: session.Summary.UpdatedAt.Format("01-02 15:04"), + Active: session.Active, + Selected: index == m.Index(), + Width: m.Width(), + RowStyle: d.styles.sessionRow, + RowActiveStyle: d.styles.sessionRowActive, + RowFocusStyle: d.styles.sessionRowFocused, + MetaStyle: d.styles.sessionMeta, + MetaActiveStyle: d.styles.sessionMetaActive, + MetaFocusStyle: d.styles.sessionMetaFocus, + })) +} + func (a *App) refreshCommandMenu() { input := a.input.Value() if a.state.ActivePicker != pickerNone { a.commandMenu.SetItems(nil) - a.commandMenuMeta = commandMenuMeta{} + a.commandMenuMeta = tuistate.CommandMenuMeta{} return } items, meta := a.buildCommandMenuItems(input, a.transcript.Width) if len(items) == 0 { a.commandMenu.SetItems(nil) - a.commandMenuMeta = commandMenuMeta{} + a.commandMenuMeta = tuistate.CommandMenuMeta{} return } @@ -50,13 +181,13 @@ func (a *App) refreshCommandMenu() { func (a *App) resizeCommandMenu() { width := max(24, a.transcript.Width) - rows := clamp(len(a.commandMenu.Items()), 0, maxCommandMenuRows) + rows := tuiutils.Clamp(len(a.commandMenu.Items()), 0, maxCommandMenuRows) a.commandMenu.SetSize(max(16, width-4), max(1, rows)) } -func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, commandMenuMeta) { +func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, tuistate.CommandMenuMeta) { if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 { - return suggestions, commandMenuMeta{Title: fileMenuTitle} + return suggestions, tuistate.CommandMenuMeta{Title: fileMenuTitle} } trimmed := strings.TrimSpace(input) @@ -64,7 +195,7 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, replacement := trimmed item := commandMenuItem{ title: workspaceCommandUsage, - description: trimMiddle(a.state.CurrentWorkdir, max(24, width-28)), + description: tuiutils.TrimMiddle(a.state.CurrentWorkdir, max(24, width-28)), highlight: true, replacement: replacement, } @@ -75,12 +206,12 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, item.replaceStart = start item.replaceEnd = end } - return []commandMenuItem{item}, commandMenuMeta{Title: shellMenuTitle} + return []commandMenuItem{item}, tuistate.CommandMenuMeta{Title: shellMenuTitle} } suggestions := a.matchingSlashCommands(trimmed) if len(suggestions) == 0 { - return nil, commandMenuMeta{} + return nil, tuistate.CommandMenuMeta{} } start, end, _, _ := tokenRange(input, tokenSelectorFirst) @@ -97,7 +228,7 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, replaceEnd: end, }) } - return items, commandMenuMeta{Title: commandMenuTitle} + return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} } func (a App) fileMenuSuggestions(input string) []commandMenuItem { @@ -206,3 +337,4 @@ func (a *App) openFileBrowser() { a.input.Blur() a.applyComponentLayout(true) } + diff --git a/internal/tui/command_menu_test.go b/internal/tui/core/app/command_menu_test.go similarity index 100% rename from internal/tui/command_menu_test.go rename to internal/tui/core/app/command_menu_test.go diff --git a/internal/tui/commands.go b/internal/tui/core/app/commands.go similarity index 66% rename from internal/tui/commands.go rename to internal/tui/core/app/commands.go index a79c44f4..ad373587 100644 --- a/internal/tui/commands.go +++ b/internal/tui/core/app/commands.go @@ -9,6 +9,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + tuicommands "neo-code/internal/tui/core/commands" + tuistatus "neo-code/internal/tui/core/status" + tuiservices "neo-code/internal/tui/services" ) const ( @@ -91,30 +94,8 @@ const ( statusCodeCopyError = "Failed to copy code block" ) -type slashCommand struct { - Usage string - Description string -} - -type commandSuggestion struct { - Command slashCommand - Match bool -} - -type statusSnapshot struct { - ActiveSessionID string - ActiveSessionTitle string - IsAgentRunning bool - IsCompacting bool - CurrentProvider string - CurrentModel string - CurrentWorkdir string - CurrentTool string - ExecutionError string - FocusLabel string - PickerLabel string - MessageCount int -} +type slashCommand = tuicommands.SlashCommand +type commandSuggestion = tuicommands.CommandSuggestion var builtinSlashCommands = []slashCommand{ {Usage: slashUsageHelp, Description: "Show slash command help"}, @@ -256,90 +237,77 @@ func (a *App) selectCurrentModel(modelID string) { } func (a App) matchingSlashCommands(input string) []commandSuggestion { - if !strings.HasPrefix(input, slashPrefix) { - return nil - } - - query := strings.ToLower(strings.TrimSpace(input)) - if isCompleteSlashCommand(query) { - return nil - } - out := make([]commandSuggestion, 0, len(builtinSlashCommands)) - for _, command := range builtinSlashCommands { - normalized := strings.ToLower(command.Usage) - match := query == slashPrefix || strings.HasPrefix(normalized, query) - if query == slashPrefix || match || strings.Contains(normalized, query) { - out = append(out, commandSuggestion{Command: command, Match: match}) - } - } - return out + return tuicommands.MatchSlashCommands(input, slashPrefix, builtinSlashCommands) } func isCompleteSlashCommand(input string) bool { - for _, command := range builtinSlashCommands { - if strings.EqualFold(strings.TrimSpace(command.Usage), strings.TrimSpace(input)) { - return true - } - } - return false + return tuicommands.IsCompleteSlashCommand(input, builtinSlashCommands) } func runProviderSelection(providerSvc ProviderController, providerName string) tea.Cmd { - return func() tea.Msg { - selection, err := providerSvc.SelectProvider(context.Background(), providerName) - if err != nil { - return localCommandResultMsg{err: err} - } - return localCommandResultMsg{ - notice: fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), - providerChanged: true, - } - } + return tuiservices.SelectProviderCmd( + providerSvc, + providerName, + func(selection config.ProviderSelection, err error) tea.Msg { + if err != nil { + return localCommandResultMsg{Err: err} + } + return localCommandResultMsg{ + Notice: fmt.Sprintf("[System] Current provider switched to %s.", selection.ProviderID), + ProviderChanged: true, + } + }, + ) } 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), - modelChanged: true, - } - } -} - -func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, snapshot statusSnapshot, raw string) tea.Cmd { - return func() tea.Msg { - notice, err := executeLocalCommand(context.Background(), configManager, providerSvc, snapshot, raw) - result := localCommandResultMsg{notice: notice, err: err} - if err == nil { - cfg := configManager.Get() - result.providerChanged = !strings.EqualFold(snapshot.CurrentProvider, cfg.SelectedProvider) - result.modelChanged = !strings.EqualFold(snapshot.CurrentModel, cfg.CurrentModel) - } - return result - } + return tuiservices.SelectModelCmd( + providerSvc, + modelID, + func(selection config.ProviderSelection, err error) tea.Msg { + if err != nil { + return localCommandResultMsg{Err: err} + } + return localCommandResultMsg{ + Notice: fmt.Sprintf("[System] Current model switched to %s.", selection.ModelID), + ModelChanged: true, + } + }, + ) +} + +func runLocalCommand(configManager *config.Manager, providerSvc ProviderController, snapshot tuistatus.Snapshot, raw string) tea.Cmd { + return tuiservices.RunLocalCommandCmd( + func(ctx context.Context) (string, error) { + return executeLocalCommand(ctx, configManager, providerSvc, snapshot, raw) + }, + func(notice string, err error) tea.Msg { + result := localCommandResultMsg{Notice: notice, Err: err} + if err == nil { + cfg := configManager.Get() + result.ProviderChanged = !strings.EqualFold(snapshot.CurrentProvider, cfg.SelectedProvider) + result.ModelChanged = !strings.EqualFold(snapshot.CurrentModel, cfg.CurrentModel) + } + return result + }, + ) } func runModelCatalogRefresh(providerSvc ProviderController, providerID string) tea.Cmd { - providerID = strings.TrimSpace(providerID) - if providerSvc == nil || providerID == "" { - return nil - } - - return func() tea.Msg { - models, err := providerSvc.ListModels(context.Background()) - return modelCatalogRefreshMsg{ - providerID: providerID, - models: models, - err: err, - } - } -} - -func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, snapshot statusSnapshot, raw string) (string, error) { + return tuiservices.RefreshModelCatalogCmd( + providerSvc, + providerID, + func(providerID string, models []config.ModelDescriptor, err error) tea.Msg { + return modelCatalogRefreshMsg{ + ProviderID: providerID, + Models: models, + Err: err, + } + }, + ) +} + +func executeLocalCommand(ctx context.Context, configManager *config.Manager, providerSvc ProviderController, snapshot tuistatus.Snapshot, raw string) (string, error) { fields := strings.Fields(strings.TrimSpace(raw)) if len(fields) == 0 { return "", fmt.Errorf("empty command") @@ -357,47 +325,8 @@ func executeLocalCommand(ctx context.Context, configManager *config.Manager, pro } } -func executeStatusCommand(snapshot statusSnapshot) string { - sessionID := snapshot.ActiveSessionID - if strings.TrimSpace(sessionID) == "" { - sessionID = "" - } - sessionTitle := snapshot.ActiveSessionTitle - if strings.TrimSpace(sessionTitle) == "" { - sessionTitle = draftSessionTitle - } - running := "no" - if snapshot.IsAgentRunning || snapshot.IsCompacting { - running = "yes" - } - currentTool := snapshot.CurrentTool - if strings.TrimSpace(currentTool) == "" { - currentTool = "" - } - errorText := snapshot.ExecutionError - if strings.TrimSpace(errorText) == "" { - errorText = "" - } - picker := snapshot.PickerLabel - if strings.TrimSpace(picker) == "" { - picker = "none" - } - - lines := []string{ - "Status:", - "Session: " + sessionTitle, - "Session ID: " + sessionID, - "Running: " + running, - "Provider: " + snapshot.CurrentProvider, - "Model: " + snapshot.CurrentModel, - "Workdir: " + snapshot.CurrentWorkdir, - "Focus: " + snapshot.FocusLabel, - "Picker: " + picker, - "Current Tool: " + currentTool, - fmt.Sprintf("Messages: %d", snapshot.MessageCount), - "Error: " + errorText, - } - return strings.Join(lines, "\n") +func executeStatusCommand(snapshot tuistatus.Snapshot) string { + return tuistatus.Format(snapshot, draftSessionTitle) } func executeProviderCommand(ctx context.Context, providerSvc ProviderController, value string) (string, error) { @@ -421,28 +350,13 @@ func slashHelpText() string { } func splitFirstWord(input string) (string, string) { - input = strings.TrimSpace(input) - if input == "" { - return "", "" - } - index := strings.IndexAny(input, " \t") - if index < 0 { - return input, "" - } - return input[:index], strings.TrimSpace(input[index+1:]) + return tuicommands.SplitFirstWord(input) } func isWorkspaceSlashCommand(raw string) bool { - command, _ := splitFirstWord(strings.ToLower(strings.TrimSpace(raw))) - return command == slashCommandCWD + return tuicommands.IsWorkspaceSlashCommand(raw, slashCommandCWD) } func parseWorkspaceSlashCommand(raw string) (string, error) { - command, args := splitFirstWord(strings.TrimSpace(raw)) - switch strings.ToLower(command) { - case slashCommandCWD: - return strings.TrimSpace(args), nil - default: - return "", fmt.Errorf("unknown command %q", command) - } + return tuicommands.ParseWorkspaceSlashCommand(raw, slashCommandCWD) } diff --git a/internal/tui/commands_test.go b/internal/tui/core/app/commands_test.go similarity index 96% rename from internal/tui/commands_test.go rename to internal/tui/core/app/commands_test.go index bf889406..1bbb7cc0 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -8,6 +8,7 @@ import ( "unicode/utf16" "neo-code/internal/config" + tuistatus "neo-code/internal/tui/core/status" ) func TestExecuteLocalCommand(t *testing.T) { @@ -174,9 +175,9 @@ func containsUsage(suggestions []commandSuggestion, usage string) bool { return false } -func defaultTestStatusSnapshot(manager *config.Manager) statusSnapshot { +func defaultTestStatusSnapshot(manager *config.Manager) tuistatus.Snapshot { cfg := manager.Get() - return statusSnapshot{ + return tuistatus.Snapshot{ ActiveSessionTitle: draftSessionTitle, CurrentProvider: cfg.SelectedProvider, CurrentModel: cfg.CurrentModel, @@ -285,19 +286,19 @@ func TestLocalCommandWrappers(t *testing.T) { msg := runLocalCommand(manager, providerSvc, defaultTestStatusSnapshot(manager), "/help")() result, ok := msg.(localCommandResultMsg) - if !ok || result.err != nil || !strings.Contains(result.notice, "Available slash commands") { + if !ok || result.Err != nil || !strings.Contains(result.Notice, "Available slash commands") { t.Fatalf("expected help command result, got %+v", msg) } msg = runProviderSelection(providerSvc, "missing-provider")() result, ok = msg.(localCommandResultMsg) - if !ok || result.err == nil { + if !ok || result.Err == nil { t.Fatalf("expected provider selection error, got %+v", msg) } } func TestExecuteStatusCommandSnapshot(t *testing.T) { - notice := executeStatusCommand(statusSnapshot{ + notice := executeStatusCommand(tuistatus.Snapshot{ ActiveSessionID: "session-123", ActiveSessionTitle: "Implement slash UX", IsAgentRunning: true, @@ -329,7 +330,7 @@ func TestExecuteStatusCommandSnapshot(t *testing.T) { } func TestExecuteStatusCommandTreatsCompactingAsRunning(t *testing.T) { - notice := executeStatusCommand(statusSnapshot{ + notice := executeStatusCommand(tuistatus.Snapshot{ ActiveSessionTitle: draftSessionTitle, IsCompacting: true, CurrentProvider: "openai", diff --git a/internal/tui/copy_code.go b/internal/tui/core/app/copy_code.go similarity index 96% rename from internal/tui/copy_code.go rename to internal/tui/core/app/copy_code.go index b92b87fa..2bdb89ad 100644 --- a/internal/tui/copy_code.go +++ b/internal/tui/core/app/copy_code.go @@ -6,9 +6,9 @@ import ( "strconv" "strings" - "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + tuiinfra "neo-code/internal/tui/infra" ) type copyCodeButtonBinding struct { @@ -32,7 +32,8 @@ type markdownSegment struct { var ( copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) - clipboardWriteAll = clipboard.WriteAll + copyCodeANSIPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + clipboardWriteAll = tuiinfra.CopyText ) func splitMarkdownSegments(content string) []markdownSegment { @@ -233,7 +234,7 @@ func (a *App) setCodeCopyBlocks(bindings []copyCodeButtonBinding) { } func parseCopyCodeButton(line string) (id int, startCol int, endCol int, ok bool) { - clean := ansiEscapePattern.ReplaceAllString(line, "") + clean := copyCodeANSIPattern.ReplaceAllString(line, "") matches := copyCodeButtonPattern.FindStringSubmatchIndex(clean) if len(matches) < 4 { return 0, 0, 0, false diff --git a/internal/tui/copy_code_test.go b/internal/tui/core/app/copy_code_test.go similarity index 100% rename from internal/tui/copy_code_test.go rename to internal/tui/core/app/copy_code_test.go diff --git a/internal/tui/input_features.go b/internal/tui/core/app/input_features.go similarity index 51% rename from internal/tui/input_features.go rename to internal/tui/core/app/input_features.go index 154f195f..c0a99795 100644 --- a/internal/tui/input_features.go +++ b/internal/tui/core/app/input_features.go @@ -1,23 +1,16 @@ package tui import ( - "bytes" "context" - "errors" "fmt" - "io/fs" - "os/exec" "path/filepath" - "regexp" - "sort" "strings" - "time" - "unicode" - "unicode/utf16" tea "github.com/charmbracelet/bubbletea" "neo-code/internal/config" + tuiinfra "neo-code/internal/tui/infra" + tuiservices "neo-code/internal/tui/services" ) const ( @@ -30,12 +23,6 @@ const ( maxFileSuggestions = 6 ) -type workspaceCommandResultMsg struct { - command string - output string - err error -} - type tokenSelector int const ( @@ -45,8 +32,6 @@ const ( var workspaceCommandExecutor = defaultWorkspaceCommandExecutor -var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) - func isWorkspaceCommandInput(input string) bool { return strings.HasPrefix(strings.TrimSpace(input), workspaceCommandPrefix) } @@ -64,14 +49,18 @@ func extractWorkspaceCommand(input string) (string, error) { } func runWorkspaceCommand(configManager *config.Manager, workdir string, raw string) tea.Cmd { - return func() tea.Msg { - command, output, err := executeWorkspaceCommand(context.Background(), configManager, workdir, raw) - return workspaceCommandResultMsg{ - command: command, - output: output, - err: err, - } - } + return tuiservices.RunWorkspaceCommandCmd( + func(ctx context.Context) (string, string, error) { + return executeWorkspaceCommand(ctx, configManager, workdir, raw) + }, + func(command string, output string, err error) tea.Msg { + return workspaceCommandResultMsg{ + Command: command, + Output: output, + Err: err, + } + }, + ) } func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, workdir string, raw string) (string, string, error) { @@ -86,57 +75,15 @@ func executeWorkspaceCommand(ctx context.Context, configManager *config.Manager, } func defaultWorkspaceCommandExecutor(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { - command = strings.TrimSpace(command) - if command == "" { - return "", errors.New("command is empty") - } - targetWorkdir := strings.TrimSpace(workdir) - if targetWorkdir == "" { - targetWorkdir = cfg.Workdir - } - - timeoutSec := cfg.ToolTimeoutSec - if timeoutSec <= 0 { - timeoutSec = config.DefaultToolTimeoutSec - } - - runCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) - defer cancel() - - args := shellArgs(cfg.Shell, command) - cmd := exec.CommandContext(runCtx, args[0], args[1:]...) - cmd.Dir = targetWorkdir - output, err := cmd.CombinedOutput() - text := sanitizeWorkspaceOutput(output) - - if runCtx.Err() == context.DeadlineExceeded { - return text, fmt.Errorf("command timed out after %ds", timeoutSec) - } - if err != nil { - return text, err - } - if text == "" { - return "(no output)", nil - } - return text, nil + return tuiinfra.DefaultWorkspaceCommandExecutor(ctx, cfg, workdir, command) } func shellArgs(shell string, command string) []string { - switch strings.ToLower(strings.TrimSpace(shell)) { - case "powershell", "pwsh": - return []string{"powershell", "-NoProfile", "-Command", powershellUTF8Command(command)} - case "bash": - return []string{"bash", "-lc", command} - case "sh": - return []string{"sh", "-lc", command} - default: - return []string{"powershell", "-NoProfile", "-Command", powershellUTF8Command(command)} - } + return tuiinfra.ShellArgs(shell, command) } func powershellUTF8Command(command string) string { - utf8Setup := "[Console]::InputEncoding=[System.Text.Encoding]::UTF8; [Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $OutputEncoding=[System.Text.Encoding]::UTF8; chcp 65001 > $null" - return utf8Setup + "; " + command + return tuiinfra.PowerShellUTF8Command(command) } func formatWorkspaceCommandResult(command string, output string, err error) string { @@ -158,97 +105,11 @@ func formatWorkspaceCommandResult(command string, output string, err error) stri } func sanitizeWorkspaceOutput(raw []byte) string { - text := decodeWorkspaceOutput(raw) - text = strings.ToValidUTF8(text, "?") - text = ansiEscapePattern.ReplaceAllString(text, "") - text = strings.ReplaceAll(text, "\r\n", "\n") - text = strings.ReplaceAll(text, "\r", "\n") - text = strings.Map(func(r rune) rune { - switch { - case r == '\n' || r == '\t': - return r - case r < 0x20: - return -1 - default: - return r - } - }, text) - return strings.TrimSpace(text) + return tuiinfra.SanitizeWorkspaceOutput(raw) } func decodeWorkspaceOutput(raw []byte) string { - if len(raw) == 0 { - return "" - } - - switch { - case bytes.HasPrefix(raw, []byte{0xFF, 0xFE}): - return decodeUTF16(raw[2:], true) - case bytes.HasPrefix(raw, []byte{0xFE, 0xFF}): - return decodeUTF16(raw[2:], false) - } - - if len(raw)%2 == 0 { - le := decodeUTF16(raw, true) - be := decodeUTF16(raw, false) - rawText := string(raw) - rawScore := decodedTextScore(rawText) - leScore := decodedTextScore(le) - beScore := decodedTextScore(be) - - bestText := rawText - bestScore := rawScore - if leScore > bestScore { - bestText = le - bestScore = leScore - } - if beScore > bestScore { - bestText = be - } - return bestText - } - - return string(raw) -} - -func decodedTextScore(text string) int { - if text == "" { - return 0 - } - - score := 0 - for _, r := range text { - switch { - case r == '\n' || r == '\r' || r == '\t': - score += 1 - case r == unicode.ReplacementChar: - score -= 6 - case unicode.IsPrint(r): - score += 2 - default: - score -= 3 - } - } - return score -} - -func decodeUTF16(raw []byte, littleEndian bool) string { - if len(raw) < 2 { - return string(raw) - } - if len(raw)%2 != 0 { - raw = raw[:len(raw)-1] - } - - words := make([]uint16, 0, len(raw)/2) - for i := 0; i < len(raw); i += 2 { - if littleEndian { - words = append(words, uint16(raw[i])|uint16(raw[i+1])<<8) - } else { - words = append(words, uint16(raw[i])<<8|uint16(raw[i+1])) - } - } - return string(utf16.Decode(words)) + return tuiinfra.DecodeWorkspaceOutput(raw) } func (a *App) refreshFileCandidates() error { @@ -257,56 +118,15 @@ func (a *App) refreshFileCandidates() error { return err } a.fileCandidates = candidates - if workdir := strings.TrimSpace(a.state.CurrentWorkdir); workdir != "" { - if absolute, absErr := filepath.Abs(workdir); absErr == nil { - a.fileBrowser.CurrentDirectory = absolute - } + if absolute := tuiservices.ResolveWorkspaceDirectory(a.state.CurrentWorkdir); absolute != "" { + a.fileBrowser.CurrentDirectory = absolute } a.refreshCommandMenu() return nil } func collectWorkspaceFiles(root string, limit int) ([]string, error) { - root, err := filepath.Abs(root) - if err != nil { - return nil, err - } - - var ( - candidates []string - limitErr = errors.New("file limit reached") - ) - - err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - - name := d.Name() - if d.IsDir() { - switch name { - case ".git", ".gocache", "node_modules": - return filepath.SkipDir - } - return nil - } - - rel, err := filepath.Rel(root, path) - if err != nil { - return err - } - candidates = append(candidates, filepath.ToSlash(rel)) - if limit > 0 && len(candidates) >= limit { - return limitErr - } - return nil - }) - if err != nil && !errors.Is(err, limitErr) { - return nil, err - } - - sort.Strings(candidates) - return candidates, nil + return tuiservices.CollectWorkspaceFiles(root, limit) } func (a App) resolveFileReferenceSuggestions(input string) (start int, end int, query string, suggestions []string, ok bool) { @@ -321,29 +141,7 @@ func (a App) resolveFileReferenceSuggestions(input string) (start int, end int, } func collectFileSuggestionMatches(query string, candidates []string, limit int) []string { - if len(candidates) == 0 || limit <= 0 { - return nil - } - prefixMatches := make([]string, 0, maxFileSuggestions) - containsMatches := make([]string, 0, maxFileSuggestions) - for _, candidate := range candidates { - lower := strings.ToLower(candidate) - switch { - case query == "" || strings.HasPrefix(lower, query): - prefixMatches = append(prefixMatches, candidate) - case strings.Contains(lower, query): - containsMatches = append(containsMatches, candidate) - } - if len(prefixMatches)+len(containsMatches) >= maxFileSuggestions { - break - } - } - - out := append(prefixMatches, containsMatches...) - if len(out) > limit { - out = out[:limit] - } - return out + return tuiservices.SuggestFileMatches(query, candidates, limit) } func tokenRange(input string, selector tokenSelector) (start int, end int, token string, ok bool) { diff --git a/internal/tui/input_features_test.go b/internal/tui/core/app/input_features_test.go similarity index 99% rename from internal/tui/input_features_test.go rename to internal/tui/core/app/input_features_test.go index 7e587771..6e5428fa 100644 --- a/internal/tui/input_features_test.go +++ b/internal/tui/core/app/input_features_test.go @@ -41,7 +41,7 @@ func TestWorkspaceCommandHelpers(t *testing.T) { if !ok { t.Fatalf("expected workspaceCommandResultMsg, got %T", msg) } - if result.err != nil || result.command != "git status" || result.output != "ok" { + if result.Err != nil || result.Command != "git status" || result.Output != "ok" { t.Fatalf("unexpected runWorkspaceCommand result: %+v", result) } }) diff --git a/internal/tui/keymap.go b/internal/tui/core/app/keymap.go similarity index 100% rename from internal/tui/keymap.go rename to internal/tui/core/app/keymap.go diff --git a/internal/tui/core/app/markdown_renderer.go b/internal/tui/core/app/markdown_renderer.go new file mode 100644 index 00000000..7666b241 --- /dev/null +++ b/internal/tui/core/app/markdown_renderer.go @@ -0,0 +1,17 @@ +package tui + +import tuiinfra "neo-code/internal/tui/infra" + +const ( + defaultMarkdownStyle = "dark" + defaultMarkdownCacheMax = 128 +) + +type markdownContentRenderer interface { + Render(content string, width int) (string, error) +} + +// newMarkdownRenderer 创建 TUI 使用的 Markdown 渲染器,实际实现下沉到 infra 层。 +func newMarkdownRenderer() (markdownContentRenderer, error) { + return tuiinfra.NewCachedMarkdownRenderer(defaultMarkdownStyle, defaultMarkdownCacheMax, emptyMessageText), nil +} diff --git a/internal/tui/markdown_renderer_test.go b/internal/tui/core/app/markdown_renderer_test.go similarity index 71% rename from internal/tui/markdown_renderer_test.go rename to internal/tui/core/app/markdown_renderer_test.go index ad04a8a1..1f1345e3 100644 --- a/internal/tui/markdown_renderer_test.go +++ b/internal/tui/core/app/markdown_renderer_test.go @@ -4,6 +4,8 @@ import ( "regexp" "strings" "testing" + + tuiinfra "neo-code/internal/tui/infra" ) var markdownTestANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -14,9 +16,9 @@ func TestNewMarkdownRendererAndRender(t *testing.T) { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer, ok := rendererAny.(*glamourMarkdownRenderer) + renderer, ok := rendererAny.(*tuiinfra.CachedMarkdownRenderer) if !ok { - t.Fatalf("expected glamourMarkdownRenderer type, got %T", rendererAny) + t.Fatalf("expected CachedMarkdownRenderer type, got %T", rendererAny) } output, err := renderer.Render("# Title\n\n- one\n- two", 40) @@ -26,11 +28,11 @@ func TestNewMarkdownRendererAndRender(t *testing.T) { if output == "" { t.Fatalf("expected non-empty markdown output") } - if len(renderer.renderers) != 1 { - t.Fatalf("expected one cached term renderer, got %d", len(renderer.renderers)) + if renderer.RendererCount() != 1 { + t.Fatalf("expected one cached term renderer, got %d", renderer.RendererCount()) } - if len(renderer.cache) != 1 { - t.Fatalf("expected one cached render result, got %d", len(renderer.cache)) + if renderer.CacheCount() != 1 { + t.Fatalf("expected one cached render result, got %d", renderer.CacheCount()) } } @@ -39,7 +41,7 @@ func TestMarkdownRendererHandlesEmptyInputAndCacheEviction(t *testing.T) { if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer := rendererAny.(*glamourMarkdownRenderer) + renderer := rendererAny.(*tuiinfra.CachedMarkdownRenderer) emptyOutput, err := renderer.Render(" \n\t ", 32) if err != nil { @@ -49,15 +51,15 @@ func TestMarkdownRendererHandlesEmptyInputAndCacheEviction(t *testing.T) { t.Fatalf("expected empty message placeholder, got %q", emptyOutput) } - renderer.maxCacheEntries = 1 + renderer.SetMaxCacheEntries(1) if _, err := renderer.Render("first", 20); err != nil { t.Fatalf("Render(first) error = %v", err) } if _, err := renderer.Render("second", 20); err != nil { t.Fatalf("Render(second) error = %v", err) } - if len(renderer.cacheOrder) != 1 || len(renderer.cache) != 1 { - t.Fatalf("expected cache eviction to keep one entry, got order=%d cache=%d", len(renderer.cacheOrder), len(renderer.cache)) + if renderer.CacheOrderCount() != 1 || renderer.CacheCount() != 1 { + t.Fatalf("expected cache eviction to keep one entry, got order=%d cache=%d", renderer.CacheOrderCount(), renderer.CacheCount()) } } @@ -66,7 +68,7 @@ func TestMarkdownRendererCachesByWidth(t *testing.T) { if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer := rendererAny.(*glamourMarkdownRenderer) + renderer := rendererAny.(*tuiinfra.CachedMarkdownRenderer) text := "plain text" if _, err := renderer.Render(text, 20); err != nil { @@ -75,8 +77,8 @@ func TestMarkdownRendererCachesByWidth(t *testing.T) { if _, err := renderer.Render(text, 50); err != nil { t.Fatalf("Render(width=50) error = %v", err) } - if len(renderer.renderers) != 2 { - t.Fatalf("expected width-specific renderer cache, got %d", len(renderer.renderers)) + if renderer.RendererCount() != 2 { + t.Fatalf("expected width-specific renderer cache, got %d", renderer.RendererCount()) } } @@ -85,7 +87,7 @@ func TestMarkdownRendererPreservesContent(t *testing.T) { if err != nil { t.Fatalf("newMarkdownRenderer() error = %v", err) } - renderer := rendererAny.(*glamourMarkdownRenderer) + renderer := rendererAny.(*tuiinfra.CachedMarkdownRenderer) output, err := renderer.Render("Title\n\n- first item\n- second item", 40) if err != nil { diff --git a/internal/tui/styles.go b/internal/tui/core/app/styles.go similarity index 92% rename from internal/tui/styles.go rename to internal/tui/core/app/styles.go index ed67013a..6c0e8467 100644 --- a/internal/tui/styles.go +++ b/internal/tui/core/app/styles.go @@ -1,4 +1,4 @@ -package tui +package tui import ( "strings" @@ -303,31 +303,6 @@ func wrapCodeBlock(text string, width int) string { return strings.Join(out, "\n") } -func trimRunes(text string, limit int) string { - runes := []rune(text) - if len(runes) <= limit || limit < 4 { - return text - } - return string(runes[:limit-3]) + "..." -} - -func trimMiddle(text string, limit int) string { - runes := []rune(text) - if len(runes) <= limit || limit < 7 { - return text - } - left := (limit - 3) / 2 - right := limit - 3 - left - return string(runes[:left]) + "..." + string(runes[len(runes)-right:]) -} - -func fallback(value string, fallbackValue string) string { - if strings.TrimSpace(value) == "" { - return fallbackValue - } - return value -} - func preview(text string, width int, lines int) string { rawLines := strings.Split(strings.TrimSpace(text), "\n") out := make([]string, 0, lines) @@ -351,12 +326,3 @@ func preview(text string, width int, lines int) string { return joined } -func clamp(value int, minValue int, maxValue int) int { - if value < minValue { - return minValue - } - if value > maxValue { - return maxValue - } - return value -} diff --git a/internal/tui/update.go b/internal/tui/core/app/update.go similarity index 65% rename from internal/tui/update.go rename to internal/tui/core/app/update.go index db804480..669326b0 100644 --- a/internal/tui/update.go +++ b/internal/tui/core/app/update.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "path/filepath" "strings" "time" @@ -19,44 +18,27 @@ import ( "neo-code/internal/provider" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" + tuicommands "neo-code/internal/tui/core/commands" + tuistatus "neo-code/internal/tui/core/status" + tuiutils "neo-code/internal/tui/core/utils" + tuiworkspace "neo-code/internal/tui/core/workspace" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" ) -type RuntimeMsg struct{ Event agentruntime.RuntimeEvent } -type RuntimeClosedMsg struct{} -type runFinishedMsg struct{ err error } -type modelCatalogRefreshMsg struct { - providerID string - models []config.ModelDescriptor - err error -} - -type compactFinishedMsg struct { - err error -} - -type localCommandResultMsg struct { - notice string - err error - providerChanged bool - modelChanged bool -} -type sessionWorkdirResultMsg struct { - notice string - workdir string - err error -} - const ( - composerMinHeight = 1 - composerMaxHeight = 5 - composerPromptWidth = 2 - mouseWheelStepLines = 3 - pasteBurstWindow = 120 * time.Millisecond - pasteEnterGuard = 180 * time.Millisecond - pasteSessionGuard = 5 * time.Second - pasteBurstThreshold = 12 + composerMinHeight = tuistate.ComposerMinHeight + composerMaxHeight = tuistate.ComposerMaxHeight + composerPromptWidth = tuistate.ComposerPromptWidth + mouseWheelStepLines = tuistate.MouseWheelStepLines + pasteBurstWindow = tuistate.PasteBurstWindow + pasteEnterGuard = tuistate.PasteEnterGuard + pasteSessionGuard = tuistate.PasteSessionGuard + pasteBurstThreshold = tuistate.PasteBurstThreshold ) +var panelOrder = []panel{panelSessions, panelTranscript, panelActivity, panelInput} + func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var spinCmd tea.Cmd @@ -82,6 +64,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeClosedMsg: a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" a.clearRunProgress() a.state.IsCompacting = false if strings.TrimSpace(a.state.StatusText) == "" { @@ -89,17 +74,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, tea.Batch(cmds...) case runFinishedMsg: - if typed.err != nil { + if typed.Err != nil { a.state.IsAgentRunning = false + a.state.ActiveRunID = "" a.clearRunProgress() a.state.StreamingReply = false a.state.CurrentTool = "" - if errors.Is(typed.err, context.Canceled) { + 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.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() } } if !a.state.IsAgentRunning { @@ -109,27 +95,27 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncActiveSessionTitle() return a, tea.Batch(cmds...) case modelCatalogRefreshMsg: - if strings.EqualFold(a.modelRefreshID, typed.providerID) { + if strings.EqualFold(a.modelRefreshID, typed.ProviderID) { a.modelRefreshID = "" } - if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(typed.providerID)) { + if !strings.EqualFold(strings.TrimSpace(a.state.CurrentProvider), strings.TrimSpace(typed.ProviderID)) { return a, tea.Batch(cmds...) } - if typed.err != nil { - a.appendActivity("provider", "Failed to refresh models", typed.err.Error(), true) + if typed.Err != nil { + a.appendActivity("provider", "Failed to refresh models", typed.Err.Error(), true) return a, tea.Batch(cmds...) } - replacePickerItems(&a.modelPicker, mapModelItems(typed.models)) + replacePickerItems(&a.modelPicker, mapModelItems(typed.Models)) cfg := a.configManager.Get() a.syncConfigState(cfg) selectPickerItemByID(&a.modelPicker, cfg.CurrentModel) return a, tea.Batch(cmds...) case compactFinishedMsg: a.state.IsCompacting = false - if typed.err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() + if typed.Err != nil && strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() } if err := a.refreshSessions(); err != nil { a.state.ExecutionError = err.Error() @@ -146,16 +132,16 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.transcript.GotoBottom() return a, tea.Batch(cmds...) case localCommandResultMsg: - if typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("command", "Local command failed", typed.err.Error(), true) + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("command", "Local command failed", typed.Err.Error(), true) } else { a.state.ExecutionError = "" - a.state.StatusText = typed.notice + a.state.StatusText = typed.Notice cfg := a.configManager.Get() a.syncConfigState(cfg) - if typed.providerChanged { + if typed.ProviderChanged { if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() @@ -173,42 +159,42 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd := a.requestModelCatalogRefresh(cfg.SelectedProvider); cmd != nil { cmds = append(cmds, cmd) } - } else if typed.modelChanged { + } else if typed.ModelChanged { a.selectCurrentModel(cfg.CurrentModel) } - a.appendActivity("command", typed.notice, "", false) + a.appendActivity("command", typed.Notice, "", false) } return a, tea.Batch(cmds...) case sessionWorkdirResultMsg: - if typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("workspace", "Workspace command failed", typed.err.Error(), true) + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("workspace", "Workspace command failed", typed.Err.Error(), true) return a, tea.Batch(cmds...) } a.state.ExecutionError = "" - a.state.StatusText = typed.notice - a.state.CurrentWorkdir = strings.TrimSpace(typed.workdir) + a.state.StatusText = typed.Notice + a.state.CurrentWorkdir = strings.TrimSpace(typed.Workdir) if err := a.refreshFileCandidates(); err != nil { a.state.ExecutionError = err.Error() a.state.StatusText = err.Error() a.appendActivity("workspace", "Failed to refresh workspace files", err.Error(), true) return a, tea.Batch(cmds...) } - a.appendActivity("workspace", typed.notice, "", false) + a.appendActivity("workspace", typed.Notice, "", false) return a, tea.Batch(cmds...) case workspaceCommandResultMsg: - if typed.command == "" && typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = typed.err.Error() - a.appendActivity("command", "Workspace command failed", typed.err.Error(), true) + if typed.Command == "" && typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("command", "Workspace command failed", typed.Err.Error(), true) return a, tea.Batch(cmds...) } - result := formatWorkspaceCommandResult(typed.command, typed.output, typed.err) - if typed.err != nil { - a.state.ExecutionError = typed.err.Error() - a.state.StatusText = fmt.Sprintf("Command failed: %s", typed.command) + result := formatWorkspaceCommandResult(typed.Command, typed.Output, typed.Err) + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = fmt.Sprintf("Command failed: %s", typed.Command) a.appendActivity("command", "Command failed", result, true) } else { a.state.ExecutionError = "" @@ -400,11 +386,10 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.CurrentTool = "" a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) a.rebuildTranscript() - requestedWorkdir := "" - if strings.TrimSpace(a.state.ActiveSessionID) == "" { - requestedWorkdir = a.state.CurrentWorkdir - } - cmds = append(cmds, runAgent(a.runtime, a.state.ActiveSessionID, requestedWorkdir, input)) + runID := fmt.Sprintf("run-%d", a.now().UnixNano()) + a.state.ActiveRunID = runID + requestedWorkdir := tuiutils.RequestedWorkdirForRun(a.state.ActiveSessionID, a.state.CurrentWorkdir) + cmds = append(cmds, runAgent(a.runtime, runID, a.state.ActiveSessionID, requestedWorkdir, input)) return a, tea.Batch(cmds...) } } @@ -614,7 +599,8 @@ func (a *App) refreshMessages() error { a.activeMessages = session.Messages a.clearActivities() a.state.ActiveSessionTitle = session.Title - a.state.CurrentWorkdir = selectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) + a.state.CurrentWorkdir = tuiworkspace.SelectSessionWorkdir(session.Workdir, a.configManager.Get().Workdir) + a.refreshRuntimeSourceSnapshot() return nil } @@ -660,139 +646,327 @@ func (a *App) syncConfigState(cfg config.Config) { } } +// refreshRuntimeSourceSnapshot 浠?runtime 鏌ヨ context/token/tool 蹇収锛岀敤浜庝細璇濆垏鎹㈡垨鎭㈠鏃跺洖濉?UI銆 +func (a *App) refreshRuntimeSourceSnapshot() { + sessionID := strings.TrimSpace(a.state.ActiveSessionID) + if sessionID != "" { + if source, ok := a.runtime.(runtimeSessionContextSource); ok { + raw, err := source.GetSessionContext(context.Background(), sessionID) + if err == nil { + contextSnapshot, parsed := tuiservices.ParseSessionContextSnapshot(raw) + if parsed { + mapped := tuiservices.MapSessionContextSnapshot(contextSnapshot) + a.state.RunContext.Provider = mapped.Provider + a.state.RunContext.Model = mapped.Model + a.state.RunContext.Workdir = mapped.Workdir + a.state.RunContext.Mode = mapped.Mode + a.state.RunContext.SessionID = mapped.SessionID + } + } + } + if source, ok := a.runtime.(runtimeSessionUsageSource); ok { + raw, err := source.GetSessionUsage(context.Background(), sessionID) + if err == nil { + usageSnapshot, parsed := tuiservices.ParseUsageSnapshot(raw) + if parsed { + a.state.TokenUsage = tuiservices.MapUsageSnapshot(usageSnapshot, a.state.TokenUsage) + } + } + } + } + + runID := strings.TrimSpace(a.state.ActiveRunID) + if runID == "" { + return + } + if source, ok := a.runtime.(runtimeRunSnapshotSource); ok { + raw, err := source.GetRunSnapshot(context.Background(), runID) + if err == nil { + runSnapshot, parsed := tuiservices.ParseRunSnapshot(raw) + if parsed { + contextVM, toolVM, usageVM := tuiservices.MapRunSnapshot(runSnapshot) + if strings.TrimSpace(contextVM.Provider) != "" { + a.state.RunContext = contextVM + } + if len(toolVM) > 0 { + a.state.ToolStates = append([]tuistate.ToolState(nil), toolVM...) + } + a.state.TokenUsage = usageVM + } + } + } +} + +// runtimeSessionContextSource 约束可选的会话上下文查询能力。 +type runtimeSessionContextSource interface { + GetSessionContext(ctx context.Context, sessionID string) (any, error) +} + +// runtimeSessionUsageSource 约束可选的会话 token 使用量查询能力。 +type runtimeSessionUsageSource interface { + GetSessionUsage(ctx context.Context, sessionID string) (any, error) +} + +// runtimeRunSnapshotSource 约束可选的运行快照查询能力。 +type runtimeRunSnapshotSource interface { + GetRunSnapshot(ctx context.Context, runID string) (any, error) +} + +var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentruntime.RuntimeEvent) bool{ + agentruntime.EventUserMessage: runtimeEventUserMessageHandler, + agentruntime.EventType(tuiservices.RuntimeEventRunContext): runtimeEventRunContextHandler, + agentruntime.EventType(tuiservices.RuntimeEventToolStatus): runtimeEventToolStatusHandler, + agentruntime.EventType(tuiservices.RuntimeEventUsage): runtimeEventUsageHandler, + agentruntime.EventToolCallThinking: runtimeEventToolCallThinkingHandler, + agentruntime.EventToolStart: runtimeEventToolStartHandler, + agentruntime.EventToolResult: runtimeEventToolResultHandler, + agentruntime.EventAgentChunk: runtimeEventAgentChunkHandler, + agentruntime.EventToolChunk: runtimeEventToolChunkHandler, + agentruntime.EventAgentDone: runtimeEventAgentDoneHandler, + agentruntime.EventRunCanceled: runtimeEventRunCanceledHandler, + agentruntime.EventError: runtimeEventErrorHandler, + agentruntime.EventProviderRetry: runtimeEventProviderRetryHandler, + agentruntime.EventCompactDone: runtimeEventCompactDoneHandler, + agentruntime.EventCompactError: runtimeEventCompactErrorHandler, +} + +// handleRuntimeEvent 通过注册表分发 runtime 事件,避免巨型 switch 膨胀。 func (a *App) handleRuntimeEvent(event agentruntime.RuntimeEvent) bool { if a.state.ActiveSessionID == "" { a.state.ActiveSessionID = event.SessionID } + handler, ok := runtimeEventHandlerRegistry[event.Type] + if !ok { + return false + } + return handler(a, event) +} - transcriptDirty := false +// runtimeEventUserMessageHandler 处理用户消息进入运行队列后的状态同步。 +func runtimeEventUserMessageHandler(a *App, event agentruntime.RuntimeEvent) bool { + if strings.TrimSpace(event.RunID) != "" { + a.state.ActiveRunID = strings.TrimSpace(event.RunID) + } + a.state.StatusText = statusThinking + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ExecutionError = "" + a.setRunProgress(0.15, "Queued") + return false +} - switch event.Type { - case agentruntime.EventUserMessage: - a.state.StatusText = statusThinking - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.state.ExecutionError = "" - a.setRunProgress(0.15, "Queued") - case agentruntime.EventToolCallThinking: - if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { - a.state.CurrentTool = payload - a.setRunProgress(0.35, "Planning") - a.appendActivity("tool", "Planning tool call", payload, false) - } - case agentruntime.EventToolStart: - a.state.StatusText = statusRunningTool - a.state.StreamingReply = false - if payload, ok := event.Payload.(provider.ToolCall); ok { - a.state.CurrentTool = payload.Name - a.setRunProgress(0.6, "Running tool") - a.appendActivity("tool", "Running tool", payload.Name, false) - } - case agentruntime.EventToolResult: - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.setRunProgress(0.8, "Integrating result") - if payload, ok := event.Payload.(tools.ToolResult); ok { - a.activeMessages = append(a.activeMessages, provider.Message{ - Role: roleTool, - Content: payload.Content, - IsError: payload.IsError, - }) - transcriptDirty = true - if payload.IsError { - a.state.ExecutionError = payload.Content - a.state.StatusText = statusToolError - a.appendActivity("tool", "Tool error", preview(payload.Content, 88, 4), true) - } else if strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.StatusText = statusToolFinished - a.appendActivity("tool", "Completed tool", payload.Name, false) - } - } - case agentruntime.EventAgentChunk: - if payload, ok := event.Payload.(string); ok { - a.appendAssistantChunk(payload) - if !a.runProgressKnown { - a.setRunProgress(0.72, "Generating") - } - transcriptDirty = true - } - case agentruntime.EventToolChunk: - if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { - a.state.StatusText = statusRunningTool - a.appendActivity("tool", "Tool output", preview(payload, 88, 4), false) - } - case agentruntime.EventAgentDone: - a.state.IsAgentRunning = false - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.clearRunProgress() - if strings.TrimSpace(a.state.ExecutionError) == "" { - a.state.StatusText = statusReady - } - 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}) - transcriptDirty = true +// runtimeEventRunContextHandler 处理 runtime 上下文事件并回填界面状态。 +func runtimeEventRunContextHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := tuiservices.ParseRunContextPayload(event.Payload) + if !ok { + return false + } + mapped := tuiservices.MapRunContextPayload(event.RunID, event.SessionID, payload) + a.state.RunContext = mapped + if strings.TrimSpace(mapped.RunID) != "" { + a.state.ActiveRunID = mapped.RunID + } + if strings.TrimSpace(mapped.Provider) != "" { + a.state.CurrentProvider = mapped.Provider + } + if strings.TrimSpace(mapped.Model) != "" { + a.state.CurrentModel = mapped.Model + } + if strings.TrimSpace(mapped.Workdir) != "" { + a.state.CurrentWorkdir = mapped.Workdir + } + return false +} + +// runtimeEventToolStatusHandler 处理工具状态流转并更新当前工具展示。 +func runtimeEventToolStatusHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := tuiservices.ParseToolStatusPayload(event.Payload) + if !ok { + return false + } + toolVM := tuiservices.MapToolStatusPayload(payload) + a.state.ToolStates = tuiservices.MergeToolStates(a.state.ToolStates, toolVM, 16) + switch toolVM.Status { + case tuistate.ToolLifecyclePlanned, tuistate.ToolLifecycleRunning: + if strings.TrimSpace(toolVM.ToolName) != "" { + a.state.CurrentTool = toolVM.ToolName } - case agentruntime.EventRunCanceled: - a.state.IsAgentRunning = false - a.state.StreamingReply = false - a.state.CurrentTool = "" - a.state.ExecutionError = "" - a.state.StatusText = statusCanceled - a.clearRunProgress() - a.appendActivity("run", "Canceled current run", "", false) - case agentruntime.EventError: - a.state.StatusText = statusError - a.state.IsAgentRunning = false - a.state.StreamingReply = false + case tuistate.ToolLifecycleSucceeded, tuistate.ToolLifecycleFailed: a.state.CurrentTool = "" - a.clearRunProgress() - if payload, ok := event.Payload.(string); ok { - a.state.ExecutionError = payload - a.state.StatusText = payload - a.appendActivity("run", "Runtime error", payload, true) - } - case agentruntime.EventProviderRetry: - if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { - a.state.StatusText = statusThinking - a.runProgressKnown = false - a.appendActivity("provider", "Retrying provider call", payload, false) - } - case agentruntime.EventCompactDone: - payload, ok := event.Payload.(agentruntime.CompactDonePayload) - if !ok { - return transcriptDirty - } - a.state.ExecutionError = "" - a.state.StatusText = fmt.Sprintf("Compact(%s) saved %.1f%% context", payload.TriggerMode, payload.SavedRatio*100) - a.appendInlineMessage( - roleSystem, - fmt.Sprintf( - "[System] Compact(%s) %s (before=%d, after=%d, saved=%.1f%%, transcript=%s)", - payload.TriggerMode, - map[bool]string{true: "applied", false: "checked"}[payload.Applied], - payload.BeforeChars, - payload.AfterChars, - payload.SavedRatio*100, - payload.TranscriptPath, - ), - ) - transcriptDirty = true - case agentruntime.EventCompactError: - payload, ok := event.Payload.(agentruntime.CompactErrorPayload) - if !ok { - return transcriptDirty - } - message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) - a.state.ExecutionError = message - a.state.StatusText = message - a.appendInlineMessage(roleError, message) - transcriptDirty = true } + return false +} + +// runtimeEventUsageHandler 处理 token 使用量更新。 +func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := tuiservices.ParseUsagePayload(event.Payload) + if !ok { + return false + } + a.state.TokenUsage = tuiservices.MapUsagePayload(payload) + return false +} - return transcriptDirty +// runtimeEventToolCallThinkingHandler 处理工具规划阶段事件。 +func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent) bool { + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.CurrentTool = payload + a.setRunProgress(0.35, "Planning") + a.appendActivity("tool", "Planning tool call", payload, false) + } + return false +} + +// runtimeEventToolStartHandler 处理工具开始执行事件。 +func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.StatusText = statusRunningTool + a.state.StreamingReply = false + if payload, ok := event.Payload.(provider.ToolCall); ok { + a.state.CurrentTool = payload.Name + a.setRunProgress(0.6, "Running tool") + a.appendActivity("tool", "Running tool", payload.Name, false) + } + return false +} + +// runtimeEventToolResultHandler 处理工具执行结果并决定是否刷新对话区。 +func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.setRunProgress(0.8, "Integrating result") + payload, ok := event.Payload.(tools.ToolResult) + if !ok { + return false + } + a.activeMessages = append(a.activeMessages, provider.Message{ + Role: roleTool, + Content: payload.Content, + IsError: payload.IsError, + }) + if payload.IsError { + a.state.ExecutionError = payload.Content + a.state.StatusText = statusToolError + a.appendActivity("tool", "Tool error", preview(payload.Content, 88, 4), true) + } else if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusToolFinished + a.appendActivity("tool", "Completed tool", payload.Name, false) + } + return true } +// runtimeEventAgentChunkHandler 处理模型流式增量输出。 +func runtimeEventAgentChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(string) + if !ok { + return false + } + a.appendAssistantChunk(payload) + if !a.runProgressKnown { + a.setRunProgress(0.72, "Generating") + } + return true +} + +// runtimeEventToolChunkHandler 处理工具流式输出片段。 +func runtimeEventToolChunkHandler(a *App, event agentruntime.RuntimeEvent) bool { + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.StatusText = statusRunningTool + a.appendActivity("tool", "Tool output", preview(payload, 88, 4), false) + } + return false +} + +// runtimeEventAgentDoneHandler 处理运行完成事件。 +func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.clearRunProgress() + if strings.TrimSpace(a.state.ExecutionError) == "" { + a.state.StatusText = statusReady + } + 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}) + return true + } + return false +} + +// runtimeEventRunCanceledHandler 处理运行取消事件。 +func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.state.ExecutionError = "" + a.state.StatusText = statusCanceled + a.clearRunProgress() + a.appendActivity("run", "Canceled current run", "", false) + return false +} + +// runtimeEventErrorHandler 处理运行时错误事件。 +func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { + a.state.StatusText = statusError + a.state.IsAgentRunning = false + a.state.StreamingReply = false + a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.clearRunProgress() + if payload, ok := event.Payload.(string); ok { + a.state.ExecutionError = payload + a.state.StatusText = payload + a.appendActivity("run", "Runtime error", payload, true) + } + return false +} + +// runtimeEventProviderRetryHandler 处理 provider 重试提示事件。 +func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) bool { + if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { + a.state.StatusText = statusThinking + a.runProgressKnown = false + a.appendActivity("provider", "Retrying provider call", payload, false) + } + return false +} + +// runtimeEventCompactDoneHandler 处理 compact 完成事件。 +func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.CompactDonePayload) + if !ok { + return false + } + a.state.ExecutionError = "" + a.state.StatusText = fmt.Sprintf("Compact(%s) saved %.1f%% context", payload.TriggerMode, payload.SavedRatio*100) + a.appendInlineMessage( + roleSystem, + fmt.Sprintf( + "[System] Compact(%s) %s (before=%d, after=%d, saved=%.1f%%, transcript=%s)", + payload.TriggerMode, + map[bool]string{true: "applied", false: "checked"}[payload.Applied], + payload.BeforeChars, + payload.AfterChars, + payload.SavedRatio*100, + payload.TranscriptPath, + ), + ) + return true +} + +// runtimeEventCompactErrorHandler 处理 compact 异常事件。 +func runtimeEventCompactErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.CompactErrorPayload) + if !ok { + return false + } + message := fmt.Sprintf("Compact(%s) failed: %s", payload.TriggerMode, payload.Message) + a.state.ExecutionError = message + a.state.StatusText = message + a.appendInlineMessage(roleError, message) + return true +} func (a *App) appendAssistantChunk(chunk string) { if chunk == "" { return @@ -828,7 +1002,7 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b detail = "" } - a.activities = append(a.activities, activityEntry{ + a.activities = append(a.activities, tuistate.ActivityEntry{ Time: time.Now(), Kind: strings.TrimSpace(kind), Title: title, @@ -836,7 +1010,7 @@ func (a *App) appendActivity(kind string, title string, detail string, isError b IsError: isError, }) if len(a.activities) > maxActivityEntries { - a.activities = append([]activityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) + a.activities = append([]tuistate.ActivityEntry(nil), a.activities[len(a.activities)-maxActivityEntries:]...) } a.syncActivityViewport(previousCount) } @@ -947,7 +1121,7 @@ func (a App) transcriptBounds() (int, int, int, int) { lay := a.computeLayout() contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() - headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + headerHeight := headerBarHeight bodyY := contentY + headerHeight streamX := contentX @@ -973,7 +1147,7 @@ func (a App) inputBounds() (int, int, int, int) { lay := a.computeLayout() contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() - headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + headerHeight := headerBarHeight bodyY := contentY + headerHeight streamX := contentX @@ -993,7 +1167,7 @@ func (a App) activityBounds() (int, int, int, int) { lay := a.computeLayout() contentX := a.styles.doc.GetPaddingLeft() contentY := a.styles.doc.GetPaddingTop() - headerHeight := lipgloss.Height(a.renderHeader(lay.contentWidth)) + headerHeight := headerBarHeight bodyY := contentY + headerHeight streamX := contentX @@ -1101,7 +1275,7 @@ func (a App) shouldHandleTabAsInput(typed tea.KeyMsg) bool { } func (a *App) focusNext() { - order := []panel{panelSessions, panelTranscript, panelActivity, panelInput} + order := panelOrder current := 0 for i, item := range order { if item == a.focus { @@ -1115,7 +1289,7 @@ func (a *App) focusNext() { } func (a *App) focusPrev() { - order := []panel{panelSessions, panelTranscript, panelActivity, panelInput} + order := panelOrder current := 0 for i, item := range order { if item == a.focus { @@ -1177,9 +1351,9 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.activity.Height = 0 } - 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.fileBrowser.SetHeight(max(6, clamp(lay.rightHeight-8, 8, 16))) + a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.rightHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() } else if a.transcript.AtBottom() || a.isBusy() { @@ -1199,18 +1373,18 @@ func (a App) composerInnerWidth(totalWidth int) int { } func (a App) composerHeight() int { - return clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) + return tuiutils.Clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) } func (a *App) growComposerForNewline() { - nextHeight := clamp(a.input.LineCount()+1, composerMinHeight, composerMaxHeight) + nextHeight := tuiutils.Clamp(a.input.LineCount()+1, composerMinHeight, composerMaxHeight) if nextHeight > a.input.Height() { a.input.SetHeight(nextHeight) } } func (a *App) normalizeComposerHeight() { - targetHeight := clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) + targetHeight := tuiutils.Clamp(a.input.LineCount(), composerMinHeight, composerMaxHeight) if targetHeight != a.input.Height() { a.input.SetHeight(targetHeight) } @@ -1326,31 +1500,13 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } -func (a App) currentStatusSnapshot() statusSnapshot { - picker := "none" - switch a.state.ActivePicker { - case pickerProvider: - picker = "provider" - case pickerModel: - picker = "model" - case pickerFile: - picker = "file" - } - - return statusSnapshot{ - ActiveSessionID: a.state.ActiveSessionID, - ActiveSessionTitle: a.state.ActiveSessionTitle, - IsAgentRunning: a.state.IsAgentRunning, - IsCompacting: a.state.IsCompacting, - CurrentProvider: a.state.CurrentProvider, - CurrentModel: a.state.CurrentModel, - CurrentWorkdir: a.state.CurrentWorkdir, - CurrentTool: a.state.CurrentTool, - ExecutionError: a.state.ExecutionError, - FocusLabel: a.focusLabel(), - PickerLabel: picker, - MessageCount: len(a.activeMessages), - } +func (a App) currentStatusSnapshot() tuistatus.Snapshot { + return tuistatus.BuildFromUIState( + a.state, + len(a.activeMessages), + a.focusLabel(), + tuiutils.PickerLabelFromMode(a.state.ActivePicker), + ) } func (a *App) startDraftSession() { @@ -1362,6 +1518,10 @@ func (a *App) startDraftSession() { a.state.StatusText = statusDraft a.state.ExecutionError = "" a.state.CurrentTool = "" + a.state.ActiveRunID = "" + a.state.ToolStates = nil + a.state.RunContext = tuistate.ContextWindowState{} + a.state.TokenUsage = tuistate.TokenUsageState{} a.clearRunProgress() a.input.Reset() a.state.InputText = "" @@ -1387,24 +1547,24 @@ func (a *App) requestModelCatalogRefresh(providerID string) tea.Cmd { } func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { - return func() tea.Msg { - event, ok := <-sub - if !ok { - return RuntimeClosedMsg{} - } - return RuntimeMsg{Event: event} - } + return tuiservices.ListenForRuntimeEventCmd( + sub, + func(event agentruntime.RuntimeEvent) tea.Msg { return RuntimeMsg{Event: event} }, + func() tea.Msg { return RuntimeClosedMsg{} }, + ) } -func runAgent(runtime agentruntime.Runtime, sessionID string, workdir string, content string) tea.Cmd { - return func() tea.Msg { - err := runtime.Run(context.Background(), agentruntime.UserInput{ +func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, workdir string, content string) tea.Cmd { + return tuiservices.RunAgentCmd( + runtime, + agentruntime.UserInput{ SessionID: sessionID, + RunID: strings.TrimSpace(runID), Content: content, Workdir: workdir, - }) - return runFinishedMsg{err: err} - } + }, + func(err error) tea.Msg { return runFinishedMsg{Err: err} }, + ) } func runSessionWorkdirCommand( @@ -1414,96 +1574,33 @@ func runSessionWorkdirCommand( raw string, ) tea.Cmd { return func() tea.Msg { - requested, err := parseWorkspaceSlashCommand(raw) - if err != nil { - return sessionWorkdirResultMsg{err: err} - } - if strings.TrimSpace(requested) == "" { - workdir := strings.TrimSpace(currentWorkdir) - if workdir == "" { - return sessionWorkdirResultMsg{err: fmt.Errorf("usage: /cwd ")} - } - return sessionWorkdirResultMsg{ - notice: fmt.Sprintf("[System] Current workspace is %s.", workdir), - workdir: workdir, - } - } - - if strings.TrimSpace(sessionID) == "" { - workdir, err := resolveWorkspacePath(currentWorkdir, requested) - if err != nil { - return sessionWorkdirResultMsg{err: err} - } - return sessionWorkdirResultMsg{ - notice: fmt.Sprintf("[System] Draft workspace switched to %s.", workdir), - workdir: workdir, - } - } - - session, err := runtime.SetSessionWorkdir(context.Background(), sessionID, requested) - if err != nil { - return sessionWorkdirResultMsg{err: err} - } - workdir := strings.TrimSpace(session.Workdir) - if workdir == "" { - workdir = strings.TrimSpace(currentWorkdir) - } + result := tuicommands.ExecuteSessionWorkdirCommand( + runtime, + sessionID, + currentWorkdir, + raw, + parseWorkspaceSlashCommand, + tuiworkspace.ResolveWorkspacePath, + tuiworkspace.SelectSessionWorkdir, + ) return sessionWorkdirResultMsg{ - notice: fmt.Sprintf("[System] Session workspace switched to %s.", workdir), - workdir: workdir, + Notice: result.Notice, + Workdir: result.Workdir, + Err: result.Err, } } } -func resolveWorkspacePath(base string, requested string) (string, error) { - base = strings.TrimSpace(base) - if base == "" { - workingDir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("workspace: resolve current directory: %w", err) - } - base = workingDir - } - - target := strings.TrimSpace(requested) - if target == "" { - target = "." - } - if !filepath.IsAbs(target) { - target = filepath.Join(base, target) - } - - absolute, err := filepath.Abs(target) - if err != nil { - return "", fmt.Errorf("workspace: resolve path: %w", err) - } - info, err := os.Stat(absolute) - if err != nil { - return "", fmt.Errorf("workspace: resolve path: %w", err) - } - if !info.IsDir() { - return "", fmt.Errorf("workspace: %q is not a directory", absolute) - } - return filepath.Clean(absolute), nil -} - -func selectSessionWorkdir(sessionWorkdir string, defaultWorkdir string) string { - workdir := strings.TrimSpace(sessionWorkdir) - if workdir != "" { - return workdir - } - return strings.TrimSpace(defaultWorkdir) -} - -// runCompact 在独立命令中触发 runtime compact,并把结果回传给 TUI。 +// runCompact 鍦ㄧ嫭绔嬪懡浠や腑瑙﹀彂 runtime compact锛屽苟鎶婄粨鏋滃洖浼犵粰 TUI銆 func runCompact(runtime agentruntime.Runtime, sessionID string) tea.Cmd { - return func() tea.Msg { - _, err := runtime.Compact(context.Background(), agentruntime.CompactInput{SessionID: sessionID}) - return compactFinishedMsg{err: err} - } + return tuiservices.RunCompactCmd( + runtime, + agentruntime.CompactInput{SessionID: sessionID}, + func(err error) tea.Msg { return compactFinishedMsg{Err: err} }, + ) } -// isBusy 统一判断当前界面是否存在进行中的 agent 或 compact 操作。 +// isBusy 缁熶竴鍒ゆ柇褰撳墠鐣岄潰鏄惁瀛樺湪杩涜涓殑 agent 鎴?compact 鎿嶄綔銆 func (a App) isBusy() bool { - return a.state.IsAgentRunning || a.state.IsCompacting + return tuiutils.IsBusy(a.state.IsAgentRunning, a.state.IsCompacting) } diff --git a/internal/tui/update_test.go b/internal/tui/core/app/update_test.go similarity index 95% rename from internal/tui/update_test.go rename to internal/tui/core/app/update_test.go index ed307cc0..0f0a31ab 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -22,6 +22,10 @@ import ( providercatalog "neo-code/internal/provider/catalog" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" + tuiutils "neo-code/internal/tui/core/utils" + tuiworkspace "neo-code/internal/tui/core/workspace" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" ) type stubRuntime struct { @@ -282,7 +286,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { if !ok { t.Fatalf("expected sessionWorkdirResultMsg, got %T", msg) } - if result.err == nil || !strings.Contains(result.err.Error(), "unknown command") { + if result.Err == nil || !strings.Contains(result.Err.Error(), "unknown command") { t.Fatalf("expected unknown command error, got %+v", result) } }) @@ -290,7 +294,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { t.Run("empty workspace query without current workdir returns usage", func(t *testing.T) { msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/cwd")() result := msg.(sessionWorkdirResultMsg) - if result.err == nil || !strings.Contains(result.err.Error(), "usage: /cwd ") { + if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { t.Fatalf("expected usage error, got %+v", result) } }) @@ -299,10 +303,10 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { current := t.TempDir() msg := runSessionWorkdirCommand(newStubRuntime(), "", current, "/cwd")() result := msg.(sessionWorkdirResultMsg) - if result.err != nil { - t.Fatalf("unexpected error: %v", result.err) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) } - if result.workdir != current || !strings.Contains(result.notice, "Current workspace is") { + if result.Workdir != current || !strings.Contains(result.Notice, "Current workspace is") { t.Fatalf("expected current workspace message, got %+v", result) } }) @@ -312,7 +316,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { runtime.setWorkdirErr = errors.New("set workdir failed") msg := runSessionWorkdirCommand(runtime, "session-1", t.TempDir(), "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) - if result.err == nil || !strings.Contains(result.err.Error(), "set workdir failed") { + if result.Err == nil || !strings.Contains(result.Err.Error(), "set workdir failed") { t.Fatalf("expected set workdir error, got %+v", result) } }) @@ -323,11 +327,11 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: ""} msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) - if result.err != nil { - t.Fatalf("unexpected error: %v", result.err) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) } - if result.workdir != current { - t.Fatalf("expected fallback workdir %q, got %q", current, result.workdir) + if result.Workdir != current { + t.Fatalf("expected fallback workdir %q, got %q", current, result.Workdir) } }) @@ -338,10 +342,10 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { runtime.setResult = &agentruntime.Session{ID: "session-1", Workdir: target} msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() result := msg.(sessionWorkdirResultMsg) - if result.err != nil { - t.Fatalf("unexpected error: %v", result.err) + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) } - if result.workdir != target || !strings.Contains(result.notice, "Session workspace switched") { + if result.Workdir != target || !strings.Contains(result.Notice, "Session workspace switched") { t.Fatalf("expected runtime returned workdir %q, got %+v", target, result) } }) @@ -349,7 +353,7 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { t.Run("draft workspace change returns resolve error for missing path", func(t *testing.T) { msg := runSessionWorkdirCommand(newStubRuntime(), "", t.TempDir(), "/cwd ./missing-path")() result := msg.(sessionWorkdirResultMsg) - if result.err == nil || !strings.Contains(strings.ToLower(result.err.Error()), "resolve path") { + if result.Err == nil || !strings.Contains(strings.ToLower(result.Err.Error()), "resolve path") { t.Fatalf("expected resolve path error, got %+v", result) } }) @@ -357,9 +361,9 @@ func TestRunSessionWorkdirCommandBranches(t *testing.T) { func TestResolveWorkspacePathAndSelector(t *testing.T) { t.Run("resolve from empty base falls back to process cwd", func(t *testing.T) { - resolved, err := resolveWorkspacePath("", ".") + resolved, err := tuiworkspace.ResolveWorkspacePath("", ".") if err != nil { - t.Fatalf("resolveWorkspacePath() error = %v", err) + t.Fatalf("ResolveWorkspacePath() error = %v", err) } expected, err := filepath.Abs(".") if err != nil { @@ -376,9 +380,9 @@ func TestResolveWorkspacePathAndSelector(t *testing.T) { if err := os.MkdirAll(target, 0o755); err != nil { t.Fatalf("mkdir target: %v", err) } - resolved, err := resolveWorkspacePath(base, "sub") + resolved, err := tuiworkspace.ResolveWorkspacePath(base, "sub") if err != nil { - t.Fatalf("resolveWorkspacePath() error = %v", err) + t.Fatalf("ResolveWorkspacePath() error = %v", err) } if resolved != filepath.Clean(target) { t.Fatalf("expected %q, got %q", filepath.Clean(target), resolved) @@ -391,7 +395,7 @@ func TestResolveWorkspacePathAndSelector(t *testing.T) { if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { t.Fatalf("write file: %v", err) } - _, err := resolveWorkspacePath(base, "note.txt") + _, err := tuiworkspace.ResolveWorkspacePath(base, "note.txt") if err == nil || !strings.Contains(err.Error(), "is not a directory") { t.Fatalf("expected non-directory error, got %v", err) } @@ -399,17 +403,17 @@ func TestResolveWorkspacePathAndSelector(t *testing.T) { t.Run("resolve workspace path returns error for missing target", func(t *testing.T) { base := t.TempDir() - _, err := resolveWorkspacePath(base, "missing-dir") + _, err := tuiworkspace.ResolveWorkspacePath(base, "missing-dir") if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve path") { t.Fatalf("expected missing path error, got %v", err) } }) t.Run("select session workdir prefers session value", func(t *testing.T) { - if got := selectSessionWorkdir(" /session ", "/default"); got != "/session" { + if got := tuiworkspace.SelectSessionWorkdir(" /session ", "/default"); got != "/session" { t.Fatalf("expected trimmed session workdir, got %q", got) } - if got := selectSessionWorkdir("", " /default "); got != "/default" { + if got := tuiworkspace.SelectSessionWorkdir("", " /default "); got != "/default" { t.Fatalf("expected fallback default workdir, got %q", got) } }) @@ -424,8 +428,8 @@ func TestAppUpdateSessionWorkdirResultMessage(t *testing.T) { } model, cmd := app.Update(sessionWorkdirResultMsg{ - notice: "ok", - workdir: filepath.Join(t.TempDir(), "missing-dir"), + Notice: "ok", + Workdir: filepath.Join(t.TempDir(), "missing-dir"), }) app = model.(App) _ = collectTeaMessages(cmd) @@ -840,16 +844,16 @@ func TestTUIStandaloneHelpers(t *testing.T) { t.Fatalf("expected key help bindings") } - if wrapPlain("abcdef", 3) == "" || trimRunes("abcdef", 4) == "" || trimMiddle("abcdefgh", 5) == "" { + if wrapPlain("abcdef", 3) == "" || tuiutils.TrimRunes("abcdef", 4) == "" || tuiutils.TrimMiddle("abcdefgh", 5) == "" { t.Fatalf("expected string helpers to return content") } - if fallback("", "x") != "x" { + if tuiutils.Fallback("", "x") != "x" { t.Fatalf("expected fallback to use replacement") } if preview("line1\nline2\nline3", 8, 2) == "" { t.Fatalf("expected preview output") } - if clamp(10, 0, 5) != 5 || max(2, 3) != 3 { + if tuiutils.Clamp(10, 0, 5) != 5 || max(2, 3) != 3 { t.Fatalf("expected numeric helpers to work") } @@ -890,7 +894,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { } runtime := newStubRuntime() - runMsg := runAgent(runtime, "session-x", "", "hello")() + runMsg := runAgent(runtime, "run-x", "session-x", "", "hello")() if _, ok := runMsg.(runFinishedMsg); !ok { t.Fatalf("expected runFinishedMsg") } @@ -900,7 +904,7 @@ func TestTUIStandaloneHelpers(t *testing.T) { manager := newTestConfigManager(t) msg := runModelSelection(newTestProviderService(t, manager), config.OpenAIDefaultModel)() - if result, ok := msg.(localCommandResultMsg); !ok || result.err != nil { + if result, ok := msg.(localCommandResultMsg); !ok || result.Err != nil { t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) } @@ -944,7 +948,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { app.state.IsAgentRunning = true app.state.StatusText = statusCanceling }, - msg: runFinishedMsg{err: context.Canceled}, + 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 { @@ -957,7 +961,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { app.state.IsAgentRunning = true }, - msg: runFinishedMsg{err: context.DeadlineExceeded}, + msg: runFinishedMsg{Err: context.DeadlineExceeded}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if app.state.IsAgentRunning || app.state.ExecutionError == "" { @@ -967,7 +971,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, { name: "model selection success updates state", - msg: localCommandResultMsg{notice: "[System] ok"}, + msg: localCommandResultMsg{Notice: "[System] ok"}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if app.state.StatusText != "[System] ok" { @@ -977,7 +981,7 @@ func TestAppUpdateAdditionalTransitions(t *testing.T) { }, { name: "model selection error updates state", - msg: localCommandResultMsg{err: context.Canceled}, + msg: localCommandResultMsg{Err: context.Canceled}, assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { t.Helper() if app.state.ExecutionError == "" || app.state.StatusText == "" { @@ -1993,7 +1997,7 @@ func TestCompactEventAndBusyBranches(t *testing.T) { t.Fatalf("expected new-session shortcut to be blocked while compacting") } - model, cmd = app.Update(compactFinishedMsg{err: nil}) + model, cmd = app.Update(compactFinishedMsg{Err: nil}) app = model.(App) _ = collectTeaMessages(cmd) if app.state.IsCompacting { @@ -2236,7 +2240,7 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { } fixed := time.Date(2026, 4, 2, 9, 30, 0, 0, time.UTC) - app.activities = []activityEntry{ + app.activities = []tuistate.ActivityEntry{ {Time: fixed, Kind: "tool", Title: "first", Detail: "alpha"}, {Time: fixed, Kind: "", Title: "second", Detail: ""}, {Time: fixed, Kind: "provider", Title: "third", Detail: "retry"}, @@ -2260,7 +2264,7 @@ func TestViewActivityPreviewAndStatusHelpers(t *testing.T) { t.Fatalf("expected oldest activity row to be clipped from current viewport, got %q", preview) } - line := app.renderActivityLine(activityEntry{Time: fixed, Kind: "", Title: "single line", Detail: ""}, 80) + line := app.renderActivityLine(tuistate.ActivityEntry{Time: fixed, Kind: "", Title: "single line", Detail: ""}, 80) if !strings.Contains(line, "EVENT") || strings.Contains(line, "single line:") { t.Fatalf("expected fallback kind without detail suffix, got %q", line) } @@ -2575,6 +2579,64 @@ func TestActivityMouseFilePickerAndProgressRendering(t *testing.T) { } } +func TestRuntimeSourceEventsUpdateState(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) + } + + runID := "run-source-state" + sessionID := "session-source-state" + + model, _ := app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventType(tuiservices.RuntimeEventRunContext), + RunID: runID, + SessionID: sessionID, + Payload: tuiservices.RuntimeRunContextPayload{ + Provider: "openai", + Model: "gpt-5.4", + Workdir: "D:/repo", + Mode: "act", + }, + }}) + app = model.(App) + if app.state.ActiveRunID != runID || app.state.RunContext.Provider != "openai" { + t.Fatalf("expected run context to be mapped, got runID=%q context=%+v", app.state.ActiveRunID, app.state.RunContext) + } + + model, _ = app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventType(tuiservices.RuntimeEventToolStatus), + RunID: runID, + SessionID: sessionID, + Payload: tuiservices.RuntimeToolStatusPayload{ + ToolCallID: "call-1", + ToolName: "filesystem_edit", + Status: string(tuistate.ToolLifecycleRunning), + }, + }}) + app = model.(App) + if len(app.state.ToolStates) != 1 || app.state.ToolStates[0].ToolName != "filesystem_edit" { + t.Fatalf("expected tool status to be tracked, got %+v", app.state.ToolStates) + } + + model, _ = app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventType(tuiservices.RuntimeEventUsage), + RunID: runID, + SessionID: sessionID, + Payload: tuiservices.RuntimeUsagePayload{ + Run: tuiservices.RuntimeUsageSnapshot{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}, + Session: tuiservices.RuntimeUsageSnapshot{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, + }, + }}) + app = model.(App) + if app.state.TokenUsage.RunTotalTokens != 3 || app.state.TokenUsage.SessionTotalTokens != 30 { + t.Fatalf("expected usage to be mapped, got %+v", app.state.TokenUsage) + } + +} + func newTestConfigManager(t *testing.T) *config.Manager { t.Helper() manager := config.NewManager(config.NewLoader(t.TempDir(), config.DefaultConfig())) diff --git a/internal/tui/view.go b/internal/tui/core/app/view.go similarity index 83% rename from internal/tui/view.go rename to internal/tui/core/app/view.go index f248f0a7..aa27e1b6 100644 --- a/internal/tui/view.go +++ b/internal/tui/core/app/view.go @@ -1,4 +1,4 @@ -package tui +package tui import ( "fmt" @@ -8,6 +8,9 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/provider" + tuicomponents "neo-code/internal/tui/components" + tuiutils "neo-code/internal/tui/core/utils" + tuistate "neo-code/internal/tui/state" ) type layout struct { @@ -21,6 +24,8 @@ type layout struct { bodyGap int } +const headerBarHeight = 1 + func (a App) View() string { docWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) docHeight := max(0, a.height-a.styles.doc.GetVerticalFrameSize()) @@ -44,15 +49,15 @@ func (a App) View() string { } func (a App) renderHeader(width int) string { - status := compactStatusText(a.state.StatusText, max(18, width/3)) + status := tuicomponents.CompactStatusText(a.state.StatusText, max(18, width/3)) if a.state.IsAgentRunning { if a.runProgressKnown { progressBar := a.progress - progressBar.Width = clamp(width/7, 12, 26) - progressLabel := fallback(strings.TrimSpace(a.runProgressLabel), fallback(status, statusRunning)) + progressBar.Width = tuiutils.Clamp(width/7, 12, 26) + progressLabel := tuiutils.Fallback(strings.TrimSpace(a.runProgressLabel), tuiutils.Fallback(status, statusRunning)) status = progressBar.ViewAs(a.runProgressValue) + " " + progressLabel } else { - status = a.spinner.View() + " " + fallback(status, statusRunning) + status = a.spinner.View() + " " + tuiutils.Fallback(status, statusRunning) } } @@ -76,7 +81,7 @@ func (a App) renderHeader(width int) string { lipgloss.Center, workdirLabel, " ", - a.styles.headerPath.Render(trimMiddle(a.state.CurrentWorkdir, workdirWidth)), + a.styles.headerPath.Render(tuiutils.TrimMiddle(a.state.CurrentWorkdir, workdirWidth)), ) header := lipgloss.JoinHorizontal( @@ -131,7 +136,7 @@ func (a App) renderWaterfall(width int, height int) string { height, lipgloss.Center, lipgloss.Center, - a.renderPicker(clamp(width-10, 36, 56), clamp(height-6, 10, 14)), + a.renderPicker(tuiutils.Clamp(width-10, 36, 56), tuiutils.Clamp(height-6, 10, 14)), ) } @@ -196,9 +201,9 @@ func (a App) renderSidebarHeader(width int) string { lipgloss.Center, title, lipgloss.NewStyle().Width(1).Render(""), - a.styles.panelSubtitle.Render(trimRunes(sidebarFilterHint, filterWidth)), + a.styles.panelSubtitle.Render(tuiutils.TrimRunes(sidebarFilterHint, filterWidth)), ) - openRow := a.styles.panelSubtitle.Render(trimRunes(sidebarOpenHint, width)) + openRow := a.styles.panelSubtitle.Render(tuiutils.TrimRunes(sidebarOpenHint, width)) return lipgloss.JoinVertical( lipgloss.Left, lipgloss.Place(width, 1, lipgloss.Left, lipgloss.Top, titleRow), @@ -239,7 +244,7 @@ func (a App) renderMessageBlockWithCopy(message provider.Message, width int, sta return a.styles.inlineSystem.Width(width).Render(" - " + wrapPlain(message.Content, max(16, width-6))), nil } - maxMessageWidth := clamp(int(float64(width)*0.84), 24, width) + maxMessageWidth := tuiutils.Clamp(int(float64(width)*0.84), 24, width) tag := messageTagAgent tagStyle := a.styles.messageAgentTag bodyStyle := a.styles.messageBody @@ -247,7 +252,7 @@ func (a App) renderMessageBlockWithCopy(message provider.Message, width int, sta switch message.Role { case roleUser: - maxMessageWidth = clamp(int(float64(width)*0.68), 24, width) + maxMessageWidth = tuiutils.Clamp(int(float64(width)*0.68), 24, width) tag = messageTagUser tagStyle = a.styles.messageUserTag bodyStyle = a.styles.messageUserBody @@ -300,13 +305,13 @@ func (a App) renderCommandMenu(width int) string { if body == "" { return "" } - return a.styles.commandMenu.Width(width).Render( - lipgloss.JoinVertical( - lipgloss.Left, - a.styles.commandMenuTitle.Render(title), - body, - ), - ) + return tuicomponents.RenderCommandMenu(tuicomponents.CommandMenuData{ + Title: title, + Body: body, + Width: width, + ContainerStyle: a.styles.commandMenu, + TitleStyle: a.styles.commandMenuTitle, + }) } func (a App) commandMenuHeight(width int) int { @@ -385,31 +390,11 @@ func (a App) renderMessageContentWithCopy(content string, width int, bodyStyle l } func normalizeBlockRightEdge(content string, maxWidth int) string { - if strings.TrimSpace(content) == "" { - return content - } - - lines := strings.Split(content, "\n") - targetWidth := 0 - for _, line := range lines { - targetWidth = max(targetWidth, lipgloss.Width(line)) - } - targetWidth = clamp(targetWidth, 1, maxWidth) - - padStyle := lipgloss.NewStyle().Width(targetWidth) - normalized := make([]string, 0, len(lines)) - for _, line := range lines { - normalized = append(normalized, padStyle.Render(line)) - } - return strings.Join(normalized, "\n") + return tuicomponents.NormalizeBlockRightEdge(content, maxWidth) } func trimRenderedTrailingWhitespace(content string) string { - lines := strings.Split(content, "\n") - for i := range lines { - lines[i] = strings.TrimRight(lines[i], " \t") - } - return strings.Join(lines, "\n") + return tuicomponents.TrimRenderedTrailingWhitespace(content) } func (a App) statusBadge(text string) string { @@ -427,41 +412,21 @@ func (a App) statusBadge(text string) string { } func compactStatusText(text string, limit int) string { - text = strings.ReplaceAll(text, "\r\n", "\n") - text = strings.ReplaceAll(text, "\r", "\n") - lines := strings.Split(text, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - line = strings.Join(strings.Fields(line), " ") - if limit > 0 { - return trimMiddle(line, limit) - } - return line - } - return "" + return tuicomponents.CompactStatusText(text, limit) } func (a App) focusLabel() string { - switch a.focus { - case panelSessions: - return focusLabelSessions - case panelTranscript: - return focusLabelTranscript - case panelActivity: - return focusLabelActivity - default: - return focusLabelComposer - } + return tuiutils.FocusLabelFromPanel( + a.focus, + focusLabelSessions, + focusLabelTranscript, + focusLabelActivity, + focusLabelComposer, + ) } func (a App) activityPreviewHeight() int { - if len(a.activities) == 0 { - return 0 - } - return 6 + return tuicomponents.ActivityPreviewHeight(len(a.activities)) } func (a App) renderActivityPreview(width int) string { @@ -480,28 +445,20 @@ func (a App) renderActivityPreview(width int) string { ) } -func (a App) renderActivityLine(entry activityEntry, width int) string { - timeLabel := entry.Time.Format("15:04:05") - kindLabel := strings.ToUpper(fallback(strings.TrimSpace(entry.Kind), "event")) - - text := entry.Title - if strings.TrimSpace(entry.Detail) != "" { - text = text + ": " + entry.Detail - } - - return trimMiddle(timeLabel+" "+kindLabel+" "+strings.Join(strings.Fields(text), " "), max(12, width)) +func (a App) renderActivityLine(entry tuistate.ActivityEntry, width int) string { + return tuicomponents.RenderActivityLine(entry, width) } func (a App) computeLayout() layout { contentWidth := max(0, a.width-a.styles.doc.GetHorizontalFrameSize()) - headerHeight := lipgloss.Height(a.renderHeader(contentWidth)) - helpHeight := lipgloss.Height(a.renderHelp(contentWidth)) + helpHeight := a.helpHeight(contentWidth) + headerHeight := headerBarHeight contentHeight := max(1, a.height-a.styles.doc.GetVerticalFrameSize()-headerHeight-helpHeight) lay := layout{contentWidth: contentWidth, contentHeight: contentHeight} if contentWidth < 110 { lay.stacked = true lay.sidebarWidth = contentWidth - lay.sidebarHeight = clamp(contentHeight/3, 9, 13) + lay.sidebarHeight = tuiutils.Clamp(contentHeight/3, 9, 13) lay.rightWidth = contentWidth lay.rightHeight = max(10, contentHeight-lay.sidebarHeight) return lay @@ -515,6 +472,13 @@ func (a App) computeLayout() layout { return lay } +// helpHeight 仅计算帮助区高度,避免在 layout 计算阶段触发完整渲染。 +func (a App) helpHeight(width int) int { + a.help.ShowAll = a.state.ShowHelp + return lipgloss.Height(a.styles.footer.Width(width).Render(a.help.View(a.keys))) +} + func (a App) isFilteringSessions() bool { return a.sessions.FilterState() != list.Unfiltered } + diff --git a/internal/tui/markdown_renderer.go b/internal/tui/markdown_renderer.go deleted file mode 100644 index 17fcbeaf..00000000 --- a/internal/tui/markdown_renderer.go +++ /dev/null @@ -1,100 +0,0 @@ -package tui - -import ( - "fmt" - "regexp" - "strings" - - "github.com/charmbracelet/glamour" -) - -const ( - defaultMarkdownStyle = "dark" - defaultMarkdownCacheMax = 128 -) - -var markdownANSIPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -type markdownContentRenderer interface { - Render(content string, width int) (string, error) -} - -type glamourMarkdownRenderer struct { - renderers map[int]*glamour.TermRenderer - cache map[string]string - cacheOrder []string - maxCacheEntries int -} - -func newMarkdownRenderer() (markdownContentRenderer, error) { - return &glamourMarkdownRenderer{ - renderers: make(map[int]*glamour.TermRenderer), - cache: make(map[string]string), - cacheOrder: make([]string, 0, defaultMarkdownCacheMax), - maxCacheEntries: defaultMarkdownCacheMax, - }, nil -} - -func (r *glamourMarkdownRenderer) Render(content string, width int) (string, error) { - if strings.TrimSpace(content) == "" { - return emptyMessageText, nil - } - - renderWidth := max(16, width) - cacheKey := fmt.Sprintf("%d:%s", renderWidth, content) - if cached, ok := r.cache[cacheKey]; ok { - return cached, nil - } - - termRenderer, err := r.rendererForWidth(renderWidth) - if err != nil { - return "", err - } - - rendered, err := termRenderer.Render(content) - if err != nil { - return "", err - } - rendered = strings.TrimRight(rendered, "\n") - visible := markdownANSIPattern.ReplaceAllString(rendered, "") - if strings.TrimSpace(visible) == "" { - rendered = emptyMessageText - } - - r.cacheResult(cacheKey, rendered) - return rendered, nil -} - -func (r *glamourMarkdownRenderer) rendererForWidth(width int) (*glamour.TermRenderer, error) { - if renderer, ok := r.renderers[width]; ok { - return renderer, nil - } - - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle(defaultMarkdownStyle), - glamour.WithWordWrap(width), - ) - if err != nil { - return nil, err - } - - r.renderers[width] = renderer - return renderer, nil -} - -func (r *glamourMarkdownRenderer) cacheResult(key string, value string) { - if r.maxCacheEntries <= 0 { - return - } - if _, exists := r.cache[key]; exists { - r.cache[key] = value - return - } - if len(r.cacheOrder) >= r.maxCacheEntries { - oldest := r.cacheOrder[0] - r.cacheOrder = r.cacheOrder[1:] - delete(r.cache, oldest) - } - r.cacheOrder = append(r.cacheOrder, key) - r.cache[key] = value -} diff --git a/internal/tui/provider_service.go b/internal/tui/provider_service.go deleted file mode 100644 index 8893c139..00000000 --- a/internal/tui/provider_service.go +++ /dev/null @@ -1,15 +0,0 @@ -package tui - -import ( - "context" - - "neo-code/internal/config" -) - -type ProviderController interface { - ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) - SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) - ListModels(ctx context.Context) ([]config.ModelDescriptor, error) - ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) - SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) -} diff --git a/internal/tui/state.go b/internal/tui/state.go deleted file mode 100644 index a4200e1c..00000000 --- a/internal/tui/state.go +++ /dev/null @@ -1,214 +0,0 @@ -package tui - -import ( - "fmt" - "io" - "strings" - "time" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - agentruntime "neo-code/internal/runtime" -) - -type panel int - -const ( - panelSessions panel = iota - panelTranscript - panelActivity - panelInput -) - -type pickerMode int - -const ( - pickerNone pickerMode = iota - pickerProvider - pickerModel - pickerFile -) - -type UIState struct { - Sessions []agentruntime.SessionSummary - ActiveSessionID string - ActiveSessionTitle string - InputText string - IsAgentRunning bool - IsCompacting bool - StreamingReply bool - CurrentTool string - ExecutionError string - StatusText string - CurrentProvider string - CurrentModel string - CurrentWorkdir string - ShowHelp bool - ActivePicker pickerMode - Focus panel -} - -type activityEntry struct { - Time time.Time - Kind string - Title string - Detail string - IsError bool -} - -type commandMenuMeta struct { - Title string -} - -type commandMenuItem struct { - title string - description string - filter string - highlight bool - replacement string - useReplaceRange bool - replaceStart int - replaceEnd int - openFileBrowser bool -} - -func (c commandMenuItem) Title() string { - return c.title -} - -func (c commandMenuItem) Description() string { - return c.description -} - -func (c commandMenuItem) FilterValue() string { - base := strings.TrimSpace(c.filter) - if base != "" { - return strings.ToLower(base) - } - return strings.ToLower(c.title + " " + c.description) -} - -type commandMenuDelegate struct { - styles styles -} - -func (d commandMenuDelegate) Height() int { - return 1 -} - -func (d commandMenuDelegate) Spacing() int { - return 0 -} - -func (d commandMenuDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - return nil -} - -func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - entry, ok := item.(commandMenuItem) - if !ok { - return - } - - contentWidth := max(12, m.Width()-2) - usageStyle := d.styles.commandUsage - if entry.highlight || index == m.Index() { - usageStyle = d.styles.commandUsageMatch - } - - line := usageStyle.Render(entry.title) - if description := strings.TrimSpace(entry.description); description != "" { - descWidth := max(8, contentWidth-lipgloss.Width(entry.title)-2) - line = lipgloss.JoinHorizontal( - lipgloss.Top, - line, - lipgloss.NewStyle().Width(2).Render(""), - d.styles.commandDesc.Render(trimMiddle(description, descWidth)), - ) - } - - fmt.Fprint(w, lipgloss.NewStyle().Width(contentWidth).Render(line)) -} - -type sessionItem struct { - Summary agentruntime.SessionSummary - Active bool -} - -func (s sessionItem) FilterValue() string { - return strings.ToLower(s.Summary.Title) -} - -type selectionItem struct { - id string - name string - description string -} - -func (s selectionItem) Title() string { - return s.name -} - -func (s selectionItem) Description() string { - return s.description -} - -func (s selectionItem) FilterValue() string { - return strings.ToLower(s.id + " " + s.name + " " + s.description) -} - -type sessionDelegate struct { - styles styles -} - -func (d sessionDelegate) Height() int { - return 3 -} - -func (d sessionDelegate) Spacing() int { - return 1 -} - -func (d sessionDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { - return nil -} - -func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { - session, ok := item.(sessionItem) - if !ok { - return - } - - width := max(18, m.Width()-2) - title := trimRunes(session.Summary.Title, max(8, width-10)) - meta := session.Summary.UpdatedAt.Format("01-02 15:04") - - prefix := "o" - if session.Active { - prefix = "*" - } - if index == m.Index() { - prefix = ">" - } - - 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/tui.go b/internal/tui/tui.go new file mode 100644 index 00000000..64690b5e --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,21 @@ +package tui + +import ( + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuiapp "neo-code/internal/tui/core/app" +) + +type App = tuiapp.App +type ProviderController = tuiapp.ProviderController + +// New 保留 internal/tui 对外入口,内部实现转发到分层后的 core/app。 +func New(cfg *config.Config, configManager *config.Manager, runtime agentruntime.Runtime, providerSvc ProviderController) (App, error) { + return tuiapp.New(cfg, configManager, runtime, providerSvc) +} + +// NewWithBootstrap 保留对外注入入口,内部转发到 core/app。 +func NewWithBootstrap(options tuibootstrap.Options) (App, error) { + return tuiapp.NewWithBootstrap(options) +} From aed0dce4c0fa7ba3ebe2c15fe44be8a958b23593 Mon Sep 17 00:00:00 2001 From: creatang Date: Tue, 7 Apr 2026 10:40:20 +0800 Subject: [PATCH 03/54] fix(tui): strip BOM in runtime bridge and status snapshot --- internal/tui/core/status/snapshot.go | 2 +- internal/tui/services/runtime_bridge.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/core/status/snapshot.go b/internal/tui/core/status/snapshot.go index 6444f86c..3f75baf9 100644 --- a/internal/tui/core/status/snapshot.go +++ b/internal/tui/core/status/snapshot.go @@ -1,4 +1,4 @@ -package status +package status import ( "fmt" diff --git a/internal/tui/services/runtime_bridge.go b/internal/tui/services/runtime_bridge.go index c64a68ce..95b09034 100644 --- a/internal/tui/services/runtime_bridge.go +++ b/internal/tui/services/runtime_bridge.go @@ -1,4 +1,4 @@ -package services +package services import ( "fmt" From dab961a2bd149785276ba07d33f41d02d1652ac3 Mon Sep 17 00:00:00 2001 From: creatang Date: Tue, 7 Apr 2026 10:46:16 +0800 Subject: [PATCH 04/54] fix(tui/core/app): strip BOM from core app sources --- internal/tui/core/app/command_menu.go | 2 +- internal/tui/core/app/styles.go | 2 +- internal/tui/core/app/view.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/core/app/command_menu.go b/internal/tui/core/app/command_menu.go index 2effe828..2b5d03b0 100644 --- a/internal/tui/core/app/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -1,4 +1,4 @@ -package tui +package tui import ( "fmt" diff --git a/internal/tui/core/app/styles.go b/internal/tui/core/app/styles.go index 6c0e8467..15262a71 100644 --- a/internal/tui/core/app/styles.go +++ b/internal/tui/core/app/styles.go @@ -1,4 +1,4 @@ -package tui +package tui import ( "strings" diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index aa27e1b6..5cbdb115 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -1,4 +1,4 @@ -package tui +package tui import ( "fmt" From 376a11fdd638aee108c2e094844e77252c3fd367 Mon Sep 17 00:00:00 2001 From: creatang Date: Tue, 7 Apr 2026 20:43:03 +0800 Subject: [PATCH 05/54] fix(tui): fix CI failures and boost test coverage above 80% - Fix NUL byte check in services/file_service.go (Linux filepath.Abs does not error on NUL) - Set explicit width/height in TestAppHelpersAndRenderingSmoke to prevent header wrap - Relax shell menu newline assertion on Windows for CJK path wrapping - Increase workspace command executor timeout from 5s to 15s - Add comprehensive tests for runtime_bridge parsing functions (services: 48% -> 95%) - Add DefaultWorkspaceCommandExecutor and edge case tests (infra: 65% -> 87%) --- internal/tui/core/app/input_features_test.go | 2 +- internal/tui/core/app/update_test.go | 14 +- internal/tui/infra/infra_test.go | 256 ++++++++++ internal/tui/services/file_service.go | 3 + internal/tui/services/services_test.go | 475 +++++++++++++++++++ 5 files changed, 748 insertions(+), 2 deletions(-) diff --git a/internal/tui/core/app/input_features_test.go b/internal/tui/core/app/input_features_test.go index 6e5428fa..1e83c4f9 100644 --- a/internal/tui/core/app/input_features_test.go +++ b/internal/tui/core/app/input_features_test.go @@ -91,7 +91,7 @@ func TestWorkspaceCommandHelpers(t *testing.T) { workdir := t.TempDir() cfg := config.Config{ Workdir: workdir, - ToolTimeoutSec: 5, + ToolTimeoutSec: 15, } command := "pwd" if goruntime.GOOS == "windows" { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 0f0a31ab..f61dc98b 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "regexp" + goruntime "runtime" "strings" "sync" "testing" @@ -731,6 +732,10 @@ func TestAppHelpersAndRenderingSmoke(t *testing.T) { t.Fatalf("expected prompt and help output") } app.state.StatusText = "Status:\nSession: Draft\nProvider: openll" + // Ensure a reasonable width so the header does not wrap on narrow terminals. + app.width = 160 + app.height = 48 + app.applyComponentLayout(false) if lipgloss.Height(app.renderHeader(app.computeLayout().contentWidth)) != 1 { t.Fatalf("expected header to remain a single line even with multiline status text") } @@ -2485,7 +2490,14 @@ func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { if !strings.Contains(menu, shellMenuTitle) || !strings.Contains(menu, workspaceCommandUsage) { t.Fatalf("expected shell hint menu, got %q", menu) } - if strings.Count(menu, "\n") > 3 { + // Shell menu should stay reasonably compact (title + one item row + padding). + // Allow extra newlines on Windows where long paths with non-ASCII characters + // may cause lipgloss to wrap the description line. + maxShellMenuLines := 4 + if goruntime.GOOS == "windows" { + maxShellMenuLines = 6 + } + if strings.Count(menu, "\n") > maxShellMenuLines { t.Fatalf("expected compact shell menu, got %q", menu) } } diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go index b677f89c..0e157dc4 100644 --- a/internal/tui/infra/infra_test.go +++ b/internal/tui/infra/infra_test.go @@ -1,14 +1,85 @@ package infra import ( + "context" "encoding/binary" "os" "path/filepath" + goruntime "runtime" "strings" "testing" "unicode/utf16" + + "neo-code/internal/config" ) +func TestDefaultWorkspaceCommandExecutor(t *testing.T) { + workdir := t.TempDir() + cfg := config.Config{ + Workdir: workdir, + ToolTimeoutSec: 15, + } + + command := "pwd" + if goruntime.GOOS == "windows" { + cfg.Shell = "powershell" + command = "$PWD.Path" + } else { + cfg.Shell = "sh" + } + + output, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", command) + if err != nil { + t.Fatalf("DefaultWorkspaceCommandExecutor() error = %v", err) + } + normalizedOutput := strings.ToLower(filepath.Clean(strings.TrimSpace(output))) + normalizedWorkdir := strings.ToLower(filepath.Clean(workdir)) + if !strings.Contains(normalizedOutput, normalizedWorkdir) { + t.Fatalf("expected output %q to contain resolved workdir %q", output, workdir) + } + + // Empty command rejected. + if _, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", " "); err == nil { + t.Fatalf("expected empty command error") + } + + // Default timeout used when ToolTimeoutSec <= 0. + cfg.ToolTimeoutSec = 0 + output, err = DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", command) + if err != nil { + t.Fatalf("DefaultWorkspaceCommandExecutor() with default timeout error = %v", err) + } + if strings.TrimSpace(output) == "" { + t.Fatalf("expected non-empty output with default timeout") + } +} + +func TestDefaultWorkspaceCommandExecutorUsesDefaultTimeout(t *testing.T) { + workdir := t.TempDir() + cfg := config.Config{ + Workdir: workdir, + ToolTimeoutSec: 0, + } + if goruntime.GOOS == "windows" { + cfg.Shell = "powershell" + } else { + cfg.Shell = "sh" + } + + command := "echo hello" + if goruntime.GOOS == "windows" { + command = "Write-Output hello" + } + + output, err := DefaultWorkspaceCommandExecutor(context.Background(), cfg, "", command) + if err != nil { + t.Fatalf("DefaultWorkspaceCommandExecutor() error = %v", err) + } + if !strings.Contains(strings.ToLower(output), "hello") { + t.Fatalf("expected output to contain hello, got %q", output) + } +} + func TestShellArgs(t *testing.T) { if got := ShellArgs("bash", "pwd"); len(got) != 3 || got[0] != "bash" || got[2] != "pwd" { t.Fatalf("unexpected bash args: %+v", got) @@ -130,3 +201,188 @@ func TestCachedMarkdownRendererCacheEviction(t *testing.T) { t.Fatalf("expected single cache entry after eviction, got order=%d cache=%d", renderer.CacheOrderCount(), renderer.CacheCount()) } } + +func TestCachedMarkdownRendererEdgeCases(t *testing.T) { + // Zero max entries means no caching. + renderer := NewCachedMarkdownRenderer("dark", 0, "(empty)") + if _, err := renderer.Render("# test", 20); err != nil { + t.Fatalf("Render error = %v", err) + } + if renderer.CacheCount() != 0 { + t.Fatalf("expected no cache entries with max=0, got %d", renderer.CacheCount()) + } + + // Negative max entries clamped to 0. + renderer2 := NewCachedMarkdownRenderer("dark", -5, "(empty)") + if _, err := renderer2.Render("# test", 20); err != nil { + t.Fatalf("Render error = %v", err) + } + if renderer2.CacheCount() != 0 { + t.Fatalf("expected no cache entries with max=-5, got %d", renderer2.CacheCount()) + } + + // Cache hit returns cached value. + renderer3 := NewCachedMarkdownRenderer("dark", 4, "(empty)") + out1, _ := renderer3.Render("# hello", 30) + out2, _ := renderer3.Render("# hello", 30) + if out1 != out2 { + t.Fatalf("expected cache hit to return same result") + } + if renderer3.RendererCount() != 1 { + t.Fatalf("expected only one render call, got %d", renderer3.RendererCount()) + } + + // SetMaxCacheEntries shrinks and evicts. + renderer4 := NewCachedMarkdownRenderer("dark", 10, "(empty)") + for i := 0; i < 5; i++ { + _, _ = renderer4.Render("item"+string(rune('a'+i)), 20) + } + if renderer4.CacheCount() != 5 { + t.Fatalf("expected 5 entries, got %d", renderer4.CacheCount()) + } + renderer4.SetMaxCacheEntries(2) + if renderer4.CacheCount() != 2 { + t.Fatalf("expected 2 entries after shrink, got %d", renderer4.CacheCount()) + } +} + +func TestDecodeWorkspaceOutputEdgeCases(t *testing.T) { + // Empty input returns empty. + if got := DecodeWorkspaceOutput(nil); got != "" { + t.Fatalf("expected empty for nil input, got %q", got) + } + if got := DecodeWorkspaceOutput([]byte{}); got != "" { + t.Fatalf("expected empty for empty slice, got %q", got) + } + + // UTF-16 BE BOM. + utf16Data := utf16.Encode([]rune("hello")) + buf := make([]byte, 2+len(utf16Data)*2) + buf[0], buf[1] = 0xFE, 0xFF + for i, word := range utf16Data { + buf[2+i*2] = byte(word >> 8) + buf[2+i*2+1] = byte(word & 0xFF) + } + if got := DecodeWorkspaceOutput(buf); !strings.Contains(got, "hello") { + t.Fatalf("expected BE BOM decode, got %q", got) + } + + // Odd-length raw bytes falls back to string. + if got := DecodeWorkspaceOutput([]byte{0x61, 0x62, 0x63}); got != "abc" { + t.Fatalf("expected odd-length fallback to string, got %q", got) + } +} + +func TestDecodeUTF16EdgeCases(t *testing.T) { + if got := decodeUTF16(nil, true); got != "" { + t.Fatalf("expected empty for nil, got %q", got) + } + if got := decodeUTF16([]byte{0x61}, true); got != "a" { + t.Fatalf("expected single byte handling, got %q", got) + } +} + +func TestSanitizeWorkspaceOutputEdgeCases(t *testing.T) { + // Empty input. + if got := SanitizeWorkspaceOutput(nil); got != "" { + t.Fatalf("expected empty for nil, got %q", got) + } + + // \r-only line endings. + if got := SanitizeWorkspaceOutput([]byte("a\r\rb")); !strings.Contains(got, "a") { + t.Fatalf("expected content preserved with \\r, got %q", got) + } + + // Control characters below 0x20 (except \n and \t) are stripped. + got := SanitizeWorkspaceOutput([]byte("hello\x01world")) + if !strings.Contains(got, "hello") || !strings.Contains(got, "world") { + t.Fatalf("expected control chars removed but content preserved, got %q", got) + } + if strings.Contains(got, "\x01") { + t.Fatalf("expected \\x01 stripped, got %q", got) + } +} + +func TestShellArgsPowerShell(t *testing.T) { + args := ShellArgs("powershell", "echo hi") + if len(args) != 4 || args[0] != "powershell" || args[1] != "-NoProfile" { + t.Fatalf("unexpected powershell args: %+v", args) + } + args = ShellArgs("pwsh", "echo hi") + if len(args) != 4 || args[0] != "powershell" { + t.Fatalf("unexpected pwsh args: %+v", args) + } +} + +func TestPowerShellUTF8Command(t *testing.T) { + cmd := PowerShellUTF8Command("echo hi") + if !strings.Contains(cmd, "chcp 65001") || !strings.Contains(cmd, "echo hi") { + t.Fatalf("unexpected powershell UTF-8 command: %q", cmd) + } +} + +func TestDecodedTextScore(t *testing.T) { + if got := decodedTextScore(""); got != 0 { + t.Fatalf("expected 0 for empty, got %d", got) + } + if got := decodedTextScore("ab"); got <= 0 { + t.Fatalf("expected positive score for printable, got %d", got) + } + if got := decodedTextScore("\ufffd"); got >= 0 { + t.Fatalf("expected negative score for replacement char, got %d", got) + } +} + +func TestCollectWorkspaceFilesEdgeCases(t *testing.T) { + root := t.TempDir() + mustWrite := func(rel string) { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + mustWrite(".gocache/test.go") + mustWrite("src/main.go") + + // .gocache should be skipped. + files, _ := CollectWorkspaceFiles(root, 10) + got := strings.Join(files, ",") + if strings.Contains(got, ".gocache") { + t.Fatalf("expected .gocache skipped, got %v", files) + } + + // Zero limit means no cap. + mustWrite("a.txt") + mustWrite("b.txt") + files, _ = CollectWorkspaceFiles(root, 0) + if len(files) < 3 { + t.Fatalf("expected no cap with limit=0, got %d files", len(files)) + } +} + +func TestNewGlamourTermRenderer(t *testing.T) { + r, err := NewGlamourTermRenderer("dark", 80) + if err != nil { + t.Fatalf("NewGlamourTermRenderer() error = %v", err) + } + if r == nil { + t.Fatalf("expected non-nil renderer") + } +} + +func TestClipboardError(t *testing.T) { + original := clipboardWriteAll + t.Cleanup(func() { clipboardWriteAll = original }) + + clipboardWriteAll = func(text string) error { + return os.ErrPermission + } + if err := CopyText("hello"); err == nil { + t.Fatalf("expected error from clipboard write") + } +} diff --git a/internal/tui/services/file_service.go b/internal/tui/services/file_service.go index 21b59900..530f04ca 100644 --- a/internal/tui/services/file_service.go +++ b/internal/tui/services/file_service.go @@ -45,6 +45,9 @@ func ResolveWorkspaceDirectory(workdir string) string { if workdir == "" { return "" } + if strings.ContainsRune(workdir, '\x00') { + return "" + } absolute, err := filepath.Abs(workdir) if err != nil { return "" diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index 83b7a4ab..2895c572 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -184,3 +185,477 @@ func TestFileServices(t *testing.T) { t.Fatalf("expected empty resolved path for blank input, got %q", resolved) } } + +func TestFileServiceEdgeCases(t *testing.T) { + if got := SuggestFileMatches("x", nil, 2); got != nil { + t.Fatalf("expected nil for nil candidates, got %v", got) + } + if got := SuggestFileMatches("x", []string{"a"}, 0); got != nil { + t.Fatalf("expected nil for zero limit, got %v", got) + } + if got := SuggestFileMatches("", []string{"a", "b"}, 2); len(got) != 2 { + t.Fatalf("expected empty query to match all as prefix, got %v", got) + } + if got := SuggestFileMatches("mid", []string{"abc_mid_def"}, 2); len(got) != 1 { + t.Fatalf("expected contains match, got %v", got) + } + + if resolved := ResolveWorkspaceDirectory("\x00"); resolved != "" { + t.Fatalf("expected empty for NUL path, got %q", resolved) + } + if resolved := ResolveWorkspaceDirectory(" "); resolved != "" { + t.Fatalf("expected empty for whitespace-only path, got %q", resolved) + } +} + +func TestParseRunContextPayload(t *testing.T) { + if _, ok := ParseRunContextPayload(42); ok { + t.Fatalf("expected unknown type to fail") + } + if _, ok := ParseRunContextPayload((*RuntimeRunContextPayload)(nil)); ok { + t.Fatalf("expected nil pointer to fail") + } + + out, ok := ParseRunContextPayload(RuntimeRunContextPayload{Provider: "openai", Model: "gpt-4"}) + if !ok || out.Provider != "openai" || out.Model != "gpt-4" { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + ptr := &RuntimeRunContextPayload{Provider: "anthropic", Model: "claude"} + out, ok = ParseRunContextPayload(ptr) + if !ok || out.Provider != "anthropic" { + t.Fatalf("expected pointer parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{"Provider": "openai", "Model": "gpt-5", "Workdir": "/tmp", "Mode": "agent"} + out, ok = ParseRunContextPayload(m) + if !ok || out.Provider != "openai" || out.Model != "gpt-5" || out.Workdir != "/tmp" || out.Mode != "agent" { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } + + empty, ok := ParseRunContextPayload(map[string]any{}) + if ok { + t.Fatalf("expected all-empty fields to fail, got %+v", empty) + } +} + +func TestParseToolStatusPayload(t *testing.T) { + if _, ok := ParseToolStatusPayload(42); ok { + t.Fatalf("expected unknown type to fail") + } + if _, ok := ParseToolStatusPayload((*RuntimeToolStatusPayload)(nil)); ok { + t.Fatalf("expected nil pointer to fail") + } + + out, ok := ParseToolStatusPayload(RuntimeToolStatusPayload{ToolCallID: "tc1", ToolName: "bash", Status: "succeeded"}) + if !ok || out.ToolCallID != "tc1" || out.ToolName != "bash" { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + ptr := &RuntimeToolStatusPayload{ToolCallID: "tc2", ToolName: "read", Status: "running"} + out, ok = ParseToolStatusPayload(ptr) + if !ok || out.ToolCallID != "tc2" { + t.Fatalf("expected pointer parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{"ToolCallID": "tc3", "ToolName": "write", "Status": "planned", "Message": "msg", "DurationMS": int64(100)} + out, ok = ParseToolStatusPayload(m) + if !ok || out.ToolCallID != "tc3" || out.ToolName != "write" || out.Status != "planned" || out.DurationMS != 100 { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } + + empty, ok := ParseToolStatusPayload(map[string]any{"Status": "running"}) + if ok { + t.Fatalf("expected empty ToolCallID+ToolName to fail, got %+v", empty) + } +} + +func TestParseUsagePayload(t *testing.T) { + if _, ok := ParseUsagePayload(42); ok { + t.Fatalf("expected unknown type to fail") + } + if _, ok := ParseUsagePayload((*RuntimeUsagePayload)(nil)); ok { + t.Fatalf("expected nil pointer to fail") + } + + out, ok := ParseUsagePayload(RuntimeUsagePayload{Delta: RuntimeUsageSnapshot{InputTokens: 10}}) + if !ok || out.Delta.InputTokens != 10 { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{ + "Delta": map[string]any{"InputTokens": 5, "OutputTokens": 3, "TotalTokens": 8}, + "Run": RuntimeUsageSnapshot{InputTokens: 100}, + "Session": &RuntimeUsageSnapshot{InputTokens: 200}, + } + out, ok = ParseUsagePayload(m) + if !ok || out.Delta.InputTokens != 5 || out.Run.InputTokens != 100 || out.Session.InputTokens != 200 { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } + + empty, ok := ParseUsagePayload(map[string]any{}) + if ok { + t.Fatalf("expected all-empty to fail, got %+v", empty) + } +} + +func TestParseSessionContextSnapshot(t *testing.T) { + if _, ok := ParseSessionContextSnapshot(42); ok { + t.Fatalf("expected unknown type to fail") + } + if _, ok := ParseSessionContextSnapshot((*RuntimeSessionContextSnapshot)(nil)); ok { + t.Fatalf("expected nil pointer to fail") + } + + out, ok := ParseSessionContextSnapshot(RuntimeSessionContextSnapshot{SessionID: "s1", Provider: "openai"}) + if !ok || out.SessionID != "s1" { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + ptr := &RuntimeSessionContextSnapshot{SessionID: "s2", Model: "gpt-4"} + out, ok = ParseSessionContextSnapshot(ptr) + if !ok || out.SessionID != "s2" { + t.Fatalf("expected pointer parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{"SessionID": "s3", "Provider": "anthropic", "Model": "claude", "Workdir": "/tmp", "Mode": "agent"} + out, ok = ParseSessionContextSnapshot(m) + if !ok || out.SessionID != "s3" || out.Provider != "anthropic" { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } + + empty, ok := ParseSessionContextSnapshot(map[string]any{"Mode": "agent"}) + if ok { + t.Fatalf("expected empty SessionID+Provider+Workdir to fail, got %+v", empty) + } +} + +func TestParseRunSnapshot(t *testing.T) { + if _, ok := ParseRunSnapshot(42); ok { + t.Fatalf("expected unknown type to fail") + } + if _, ok := ParseRunSnapshot((*RuntimeRunSnapshot)(nil)); ok { + t.Fatalf("expected nil pointer to fail") + } + + out, ok := ParseRunSnapshot(RuntimeRunSnapshot{RunID: "r1", SessionID: "s1"}) + if !ok || out.RunID != "r1" { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{ + "RunID": "r2", + "SessionID": "s2", + "Context": map[string]any{"RunID": "cr1", "SessionID": "cs1", "Provider": "openai", "Model": "gpt-4", "Workdir": "/tmp", "Mode": "agent"}, + "ToolStates": []any{ + map[string]any{"ToolCallID": "tc1", "ToolName": "bash", "Status": "succeeded", "Message": "ok", "DurationMS": int64(50)}, + }, + "Usage": map[string]any{"InputTokens": 10, "OutputTokens": 20, "TotalTokens": 30}, + "SessionUsage": RuntimeUsageSnapshot{InputTokens: 100}, + } + out, ok = ParseRunSnapshot(m) + if !ok || out.RunID != "r2" || out.SessionID != "s2" { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } + if out.Context.Provider != "openai" || out.Context.Model != "gpt-4" { + t.Fatalf("expected context parsed, got %+v", out.Context) + } + if len(out.ToolStates) != 1 || out.ToolStates[0].ToolCallID != "tc1" { + t.Fatalf("expected tool states parsed, got %v", out.ToolStates) + } + if out.Usage.InputTokens != 10 || out.SessionUsage.InputTokens != 100 { + t.Fatalf("expected usage parsed, got %+v %+v", out.Usage, out.SessionUsage) + } + + empty, ok := ParseRunSnapshot(map[string]any{}) + if ok { + t.Fatalf("expected empty RunID+SessionID to fail, got %+v", empty) + } +} + +func TestParseUsageSnapshot(t *testing.T) { + out, ok := ParseUsageSnapshot(RuntimeUsageSnapshot{InputTokens: 42}) + if !ok || out.InputTokens != 42 { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + ptr := &RuntimeUsageSnapshot{OutputTokens: 99} + out, ok = ParseUsageSnapshot(ptr) + if !ok || out.OutputTokens != 99 { + t.Fatalf("expected pointer parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{"InputTokens": 10, "OutputTokens": 20, "TotalTokens": 30} + out, ok = ParseUsageSnapshot(m) + if !ok || out.InputTokens != 10 || out.TotalTokens != 30 { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } + + if _, ok := ParseUsageSnapshot(map[string]any{}); ok { + t.Fatalf("expected empty to fail") + } + if _, ok := ParseUsageSnapshot(42); ok { + t.Fatalf("expected unknown type to fail") + } +} + +func TestMapFunctions(t *testing.T) { + ctx := MapRunContextPayload("r1", "s1", RuntimeRunContextPayload{Provider: "openai", Model: "gpt-4", Workdir: "/tmp", Mode: "agent"}) + if ctx.RunID != "r1" || ctx.SessionID != "s1" || ctx.Provider != "openai" { + t.Fatalf("unexpected context: %+v", ctx) + } + + snap := RuntimeSessionContextSnapshot{SessionID: "s1", Provider: "anthropic", Model: "claude", Workdir: "/home", Mode: "plan"} + ctx = MapSessionContextSnapshot(snap) + if ctx.SessionID != "s1" || ctx.Provider != "anthropic" { + t.Fatalf("unexpected session context: %+v", ctx) + } + + tool := MapToolStatusPayload(RuntimeToolStatusPayload{ToolCallID: "tc1", ToolName: "bash", Status: "succeeded", Message: "done", DurationMS: 100}) + if tool.ToolCallID != "tc1" || tool.ToolName != "bash" { + t.Fatalf("unexpected tool: %+v", tool) + } + + usage := MapUsagePayload(RuntimeUsagePayload{ + Run: RuntimeUsageSnapshot{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, + Session: RuntimeUsageSnapshot{InputTokens: 100, OutputTokens: 200, TotalTokens: 300}, + }) + if usage.RunInputTokens != 10 || usage.SessionInputTokens != 100 { + t.Fatalf("unexpected usage: %+v", usage) + } + + current := TokenUsageVM{RunInputTokens: 5, SessionInputTokens: 50} + updated := MapUsageSnapshot(RuntimeUsageSnapshot{InputTokens: 999, OutputTokens: 888, TotalTokens: 777}, current) + if updated.SessionInputTokens != 999 || updated.RunInputTokens != 5 { + t.Fatalf("expected session updated but run preserved, got %+v", updated) + } +} + +func TestMapRunSnapshotDetailed(t *testing.T) { + snap := RuntimeRunSnapshot{ + RunID: "r1", + SessionID: "s1", + Context: RuntimeRunContextSnapshot{Provider: "openai", Model: "gpt-4", Workdir: "/tmp", Mode: "agent"}, + ToolStates: []RuntimeToolStateSnapshot{ + {ToolCallID: "tc1", ToolName: "bash", Status: "succeeded", Message: "ok", DurationMS: 50}, + }, + Usage: RuntimeUsageSnapshot{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, + SessionUsage: RuntimeUsageSnapshot{InputTokens: 100, OutputTokens: 200, TotalTokens: 300}, + } + ctx, tools, usage := MapRunSnapshot(snap) + if ctx.RunID != "r1" || ctx.Provider != "openai" { + t.Fatalf("unexpected context: %+v", ctx) + } + if len(tools) != 1 || tools[0].ToolCallID != "tc1" { + t.Fatalf("unexpected tools: %+v", tools) + } + if usage.RunInputTokens != 10 || usage.SessionInputTokens != 100 { + t.Fatalf("unexpected usage: %+v", usage) + } +} + +func TestMapToolLifecycleStatus(t *testing.T) { + for _, tc := range []struct { + input string + expected string + }{ + {"planned", "planned"}, + {"running", "running"}, + {"succeeded", "succeeded"}, + {"failed", "failed"}, + {"", "running"}, + {"unknown", "running"}, + {" SUCCEEDED ", "succeeded"}, + } { + payload := RuntimeToolStatusPayload{ToolCallID: "tc1", ToolName: "bash", Status: tc.input} + result := MapToolStatusPayload(payload) + if string(result.Status) != tc.expected { + t.Fatalf("status %q -> expected %q, got %q", tc.input, tc.expected, result.Status) + } + } +} + +func TestMergeToolStates(t *testing.T) { + existing := []ToolStateVM{{ToolCallID: "tc1", ToolName: "bash", Status: "running"}} + incoming := ToolStateVM{ToolCallID: "tc1", ToolName: "bash", Status: "succeeded"} + merged := MergeToolStates(existing, incoming, 10) + if len(merged) != 1 || merged[0].Status != "succeeded" { + t.Fatalf("expected update, got %+v", merged) + } + + // Append new tool. + incoming2 := ToolStateVM{ToolCallID: "tc2", ToolName: "read", Status: "planned"} + merged = MergeToolStates(merged, incoming2, 10) + if len(merged) != 2 { + t.Fatalf("expected 2 tools, got %d", len(merged)) + } + + // Limit enforcement. + merged = MergeToolStates(merged, ToolStateVM{ToolCallID: "tc3", ToolName: "write", Status: "running"}, 2) + if len(merged) != 2 { + t.Fatalf("expected limit enforcement, got %d", len(merged)) + } + + // Default limit. + merged = MergeToolStates(nil, incoming, 0) + if len(merged) != 1 { + t.Fatalf("expected default limit to work, got %d", len(merged)) + } +} + +func TestReadMapHelpers(t *testing.T) { + m := map[string]any{ + "IntVal": 42, + "Int64Val": int64(99), + "FloatVal": float64(3.14), + "StrInt": "123", + "StrBad": "not-a-number", + "TimeVal": time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + + if got := readMapInt(m, "IntVal"); got != 42 { + t.Fatalf("expected 42, got %d", got) + } + if got := readMapInt(m, "Int64Val"); got != 99 { + t.Fatalf("expected 99, got %d", got) + } + if got := readMapInt(m, "FloatVal"); got != 3 { + t.Fatalf("expected 3, got %d", got) + } + if got := readMapInt(m, "StrInt"); got != 123 { + t.Fatalf("expected 123, got %d", got) + } + if got := readMapInt(m, "StrBad"); got != 0 { + t.Fatalf("expected 0 for bad string, got %d", got) + } + if got := readMapInt(m, "Missing"); got != 0 { + t.Fatalf("expected 0 for missing key, got %d", got) + } + + if got := readMapInt64(m, "IntVal"); got != 42 { + t.Fatalf("expected 42, got %d", got) + } + if got := readMapInt64(m, "Int64Val"); got != 99 { + t.Fatalf("expected 99, got %d", got) + } + if got := readMapInt64(m, "StrInt"); got != 123 { + t.Fatalf("expected 123, got %d", got) + } + if got := readMapInt64(m, "StrBad"); got != 0 { + t.Fatalf("expected 0 for bad string, got %d", got) + } + + if got := readMapTime(m, "TimeVal"); got.Year() != 2026 { + t.Fatalf("expected 2026, got %v", got) + } + if got := readMapTime(m, "Missing"); !got.IsZero() { + t.Fatalf("expected zero time for missing key, got %v", got) + } + if got := readMapTime(m, "IntVal"); !got.IsZero() { + t.Fatalf("expected zero time for non-time type, got %v", got) + } +} + +func TestParseToolStatesFromAny(t *testing.T) { + states := []RuntimeToolStateSnapshot{ + {ToolCallID: "tc1", ToolName: "bash", Status: "succeeded"}, + } + got := parseToolStatesFromAny(states) + if len(got) != 1 || got[0].ToolCallID != "tc1" { + t.Fatalf("expected slice parse, got %v", got) + } + + anySlice := []any{ + map[string]any{"ToolCallID": "tc2", "ToolName": "read", "Status": "running"}, + 42, + } + got = parseToolStatesFromAny(anySlice) + if len(got) != 1 || got[0].ToolCallID != "tc2" { + t.Fatalf("expected []any parse with invalid items skipped, got %v", got) + } + + if got := parseToolStatesFromAny(42); got != nil { + t.Fatalf("expected nil for unknown type, got %v", got) + } +} + +func TestParseToolStateFromAny(t *testing.T) { + if _, ok := parseToolStateFromAny(42); ok { + t.Fatalf("expected unknown type to fail") + } + if _, ok := parseToolStateFromAny((*RuntimeToolStateSnapshot)(nil)); ok { + t.Fatalf("expected nil pointer to fail") + } + + snap := RuntimeToolStateSnapshot{ToolCallID: "tc1", ToolName: "bash", Status: "succeeded"} + out, ok := parseToolStateFromAny(snap) + if !ok || out.ToolCallID != "tc1" { + t.Fatalf("expected struct parse, got %+v ok=%v", out, ok) + } + + ptr := &RuntimeToolStateSnapshot{ToolCallID: "tc2", ToolName: "read"} + out, ok = parseToolStateFromAny(ptr) + if !ok || out.ToolCallID != "tc2" { + t.Fatalf("expected pointer parse, got %+v ok=%v", out, ok) + } + + m := map[string]any{"ToolCallID": "tc3", "ToolName": "write", "Status": "planned", "Message": "msg", "DurationMS": int64(75)} + out, ok = parseToolStateFromAny(m) + if !ok || out.ToolCallID != "tc3" || out.Status != "planned" || out.DurationMS != 75 { + t.Fatalf("expected map parse, got %+v ok=%v", out, ok) + } +} + +func TestParseRunContextSnapshotFromAny(t *testing.T) { + if got := parseRunContextSnapshotFromAny(42); got != (RuntimeRunContextSnapshot{}) { + t.Fatalf("expected zero for unknown type, got %+v", got) + } + if got := parseRunContextSnapshotFromAny((*RuntimeRunContextSnapshot)(nil)); got != (RuntimeRunContextSnapshot{}) { + t.Fatalf("expected zero for nil pointer, got %+v", got) + } + + snap := RuntimeRunContextSnapshot{RunID: "r1", SessionID: "s1", Provider: "openai"} + got := parseRunContextSnapshotFromAny(snap) + if got.RunID != "r1" { + t.Fatalf("expected struct parse, got %+v", got) + } + + ptr := &RuntimeRunContextSnapshot{RunID: "r2"} + got = parseRunContextSnapshotFromAny(ptr) + if got.RunID != "r2" { + t.Fatalf("expected pointer parse, got %+v", got) + } + + m := map[string]any{"RunID": "r3", "SessionID": "s3", "Provider": "anthropic", "Model": "claude", "Workdir": "/tmp", "Mode": "agent"} + got = parseRunContextSnapshotFromAny(m) + if got.RunID != "r3" || got.Provider != "anthropic" { + t.Fatalf("expected map parse, got %+v", got) + } +} + +func TestReadUsageFromAny(t *testing.T) { + if got := readUsageFromAny(42); got != (RuntimeUsageSnapshot{}) { + t.Fatalf("expected zero for unknown type, got %+v", got) + } + if got := readUsageFromAny((*RuntimeUsageSnapshot)(nil)); got != (RuntimeUsageSnapshot{}) { + t.Fatalf("expected zero for nil pointer, got %+v", got) + } + + snap := RuntimeUsageSnapshot{InputTokens: 42} + got := readUsageFromAny(snap) + if got.InputTokens != 42 { + t.Fatalf("expected struct parse, got %+v", got) + } + + ptr := &RuntimeUsageSnapshot{OutputTokens: 99} + got = readUsageFromAny(ptr) + if got.OutputTokens != 99 { + t.Fatalf("expected pointer parse, got %+v", got) + } + + m := map[string]any{"InputTokens": 10, "OutputTokens": 20, "TotalTokens": 30} + got = readUsageFromAny(m) + if got.InputTokens != 10 || got.TotalTokens != 30 { + t.Fatalf("expected map parse, got %+v", got) + } +} From ada183370d972dc639c833a1115c3419ec981815 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Tue, 7 Apr 2026 22:59:55 +0800 Subject: [PATCH 06/54] =?UTF-8?q?fix(provider):=E6=A8=A1=E5=9E=8B=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=BE=93=E5=87=BA=E4=B8=AD=E9=80=94=E6=88=AA=E6=96=AD?= =?UTF-8?q?=E4=BD=86=E7=95=8C=E9=9D=A2=E6=97=A0=E9=94=99=E8=AF=AF=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/openai/openai_test.go | 24 ++++++---- internal/provider/openai/response.go | 12 +++++ internal/runtime/runtime.go | 17 ++++++- internal/runtime/runtime_test.go | 61 ++++++++++++++++++++++++- 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 33179a2e..d16edc56 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -37,12 +37,12 @@ func TestWithTransport(t *testing.T) { customTransport := &http.Transport{} cfg := resolvedConfig("", "") - provider, err := New(cfg, withTransport(customTransport)) + p, err := New(cfg, withTransport(customTransport)) if err != nil { t.Fatalf("New() error = %v", err) } - if provider.client.Transport != customTransport { + if p.client.Transport != customTransport { t.Fatal("expected custom transport to be set") } } @@ -95,11 +95,11 @@ func TestNewDefaultTransportWhenNoOption(t *testing.T) { t.Parallel() cfg := resolvedConfig("", "") - provider, err := New(cfg) + p, err := New(cfg) if err != nil { t.Fatalf("New() error = %v", err) } - if provider.client.Transport == nil { + if p.client.Transport == nil { t.Fatal("expected default transport to be set") } } @@ -130,13 +130,13 @@ func TestDiscoverModels(t *testing.T) { })) defer server.Close() - provider, err := New(resolvedConfig(server.URL, "")) + p, err := New(resolvedConfig(server.URL, "")) if err != nil { t.Fatalf("New() error = %v", err) } - provider.client = server.Client() + p.client = server.Client() - models, err := provider.DiscoverModels(context.Background()) + models, err := p.DiscoverModels(context.Background()) if err != nil { t.Fatalf("DiscoverModels() error = %v", err) } @@ -490,6 +490,7 @@ func TestConsumeStream_MultiLineDataPayload(t *testing.T) { sseData := `data: {"id":"a","choices":[{"delta":{"content":"part1"},"finish_reason":""}]} data: {"id":"b","choices":[{"delta":{"content":"part2"},"finish_reason":"stop"}]} +data: [DONE] ` events := make(chan providertypes.StreamEvent, 8) @@ -503,7 +504,7 @@ data: {"id":"b","choices":[{"delta":{"content":"part2"},"finish_reason":"stop"}] } } -func TestConsumeStream_EOFWithoutDone(t *testing.T) { +func TestConsumeStream_EOFWithoutDoneReturnsInterrupted(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") p, err := New(resolvedConfig("", "")) @@ -515,8 +516,11 @@ func TestConsumeStream_EOFWithoutDone(t *testing.T) { ` events := make(chan providertypes.StreamEvent, 4) err = p.consumeStream(context.Background(), strings.NewReader(sseData), events) - if err != nil { - t.Fatalf("consumeStream() error = %v", err) + if err == nil { + t.Fatal("expected interrupted error for EOF without [DONE]") + } + if !errors.Is(err, provider.ErrStreamInterrupted) { + t.Fatalf("expected ErrStreamInterrupted, got %v", err) } drained := drainStreamEvents(events) var foundText bool diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 02b7b9b5..5026a15d 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log" "strings" "neo-code/internal/provider" @@ -67,6 +68,7 @@ func (p *Provider) consumeStream( // finishStream 统一的流结束处理:发送 message_done 事件。 finishStream := func() error { + log.Printf("[DEBUG-STREAM] finishStream called: finishReason=%q, done=%v", finishReason, done) return emitMessageDone(ctx, events, finishReason, &usage) } @@ -114,6 +116,16 @@ func (p *Provider) consumeStream( } if errors.Is(err, io.EOF) { + // [DEBUG] 流 EOF 时打印关键状态,用于诊断截断原因 + log.Printf("[DEBUG-STREAM] EOF reached: done=%v, finishReason=%q, totalRead=%d, toolCallCount=%d", + done, finishReason, reader.totalRead, len(toolCalls)) + if !done { + log.Printf("[DEBUG-STREAM] WARNING: stream ended WITHOUT [DONE] marker — treating as interruption") + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + return fmt.Errorf("%w: missing [DONE] marker before EOF", provider.ErrStreamInterrupted) + } if flushErr := flushPendingData(); flushErr != nil { return flushErr } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7b727e08..0ea4361b 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -34,8 +34,9 @@ const ( // streamAccumulator 在流式事件处理过程中累积本轮对话需要持久化的助手消息状态, // 包括文本内容和工具调用列表。 type streamAccumulator struct { - content strings.Builder - toolCalls map[int]*providertypes.ToolCall + content strings.Builder + toolCalls map[int]*providertypes.ToolCall + messageDone bool } // newStreamAccumulator 创建并初始化一个空的流式事件累积器。 @@ -294,6 +295,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { return s.handleRunError(ctx, input.RunID, session.ID, err) } if len(assistant.ToolCalls) == 0 { + log.Printf("[DEBUG-RUNTIME] No tool calls in assistant response, content length=%d, emitting EventAgentDone", + len(assistant.Content)) s.emit(ctx, EventAgentDone, input.RunID, session.ID, assistant) return nil } @@ -537,6 +540,9 @@ func handleProviderStreamEvent( if _, err := event.MessageDoneValue(); err != nil { return err } + if acc != nil { + acc.messageDone = true + } default: return fmt.Errorf("runtime: unsupported provider stream event type %q", event.Type) } @@ -679,6 +685,10 @@ func (s *Service) callProviderWithRetry( err = modelProvider.Chat(ctx, req, streamEvents) close(streamEvents) forwardErr := <-streamDone + + // [DEBUG] 打印 provider.Chat() 返回状态和转发错误 + log.Printf("[DEBUG-RUNTIME] provider.Chat() returned: err=%v, forwardErr=%v", err, forwardErr) + if forwardErr != nil { if err != nil { return nil, fmt.Errorf("runtime: provider stream handling failed after provider error: %v: %w", err, forwardErr) @@ -687,6 +697,9 @@ func (s *Service) callProviderWithRetry( } if err == nil { + if !acc.messageDone { + return nil, fmt.Errorf("%w: provider stream ended without message_done event", provider.ErrStreamInterrupted) + } return acc, nil } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 0fe9f86a..b899eb56 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -121,6 +121,13 @@ func (p *scriptedProvider) Chat(ctx context.Context, req providertypes.ChatReque return ctx.Err() } } + if callIndex >= len(p.responses) && !streamContainsMessageDone(p.streams[callIndex]) { + select { + case events <- providertypes.NewMessageDoneStreamEvent("", nil): + case <-ctx.Done(): + return ctx.Err() + } + } } if callIndex < len(p.responses) { response := p.responses[callIndex] @@ -153,6 +160,16 @@ func (p *scriptedProvider) Chat(ctx context.Context, req providertypes.ChatReque return nil } +// streamContainsMessageDone 判断测试流中是否已显式包含结束事件,避免辅助 provider 重复补发 message_done。 +func streamContainsMessageDone(events []providertypes.StreamEvent) bool { + for _, event := range events { + if event.Type == providertypes.StreamEventMessageDone { + return true + } + } + return false +} + type scriptedProviderFactory struct { provider provider.Provider calls int @@ -469,7 +486,7 @@ func TestServiceRunMergesLateToolCallMetadata(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) if err := service.Run(context.Background(), UserInput{RunID: "run-late-tool-metadata", Content: "edit"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -520,7 +537,7 @@ func TestServiceRunRejectsToolCallWithoutID(t *testing.T) { }, } - service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, nil) + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) err := service.Run(context.Background(), UserInput{RunID: "run-missing-tool-id", Content: "edit"}) if err == nil || !containsError(err, "without id") { t.Fatalf("expected missing tool id error, got %v", err) @@ -553,6 +570,41 @@ func TestServiceRunRejectsMalformedProviderStreamEvent(t *testing.T) { } } +func TestServiceRunRejectsProviderCompletionWithoutMessageDone(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + select { + case events <- providertypes.NewTextDeltaStreamEvent("partial"): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + err := service.Run(context.Background(), UserInput{RunID: "run-missing-message-done", Content: "hello"}) + if err == nil || !containsError(err, "without message_done") { + t.Fatalf("expected missing message_done error, got %v", err) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{EventUserMessage, EventAgentChunk, EventError}) + assertNoEventType(t, events, EventAgentDone) + + session := onlySession(t, store) + if len(session.Messages) != 1 || session.Messages[0].Role != providertypes.RoleUser { + t.Fatalf("expected only user message to persist after missing message_done, got %+v", session.Messages) + } +} + func TestServiceRunMalformedProviderStreamEventDoesNotDeadlock(t *testing.T) { t.Parallel() @@ -1414,6 +1466,7 @@ func TestServiceRunErrorPaths(t *testing.T) { } } events <- providertypes.NewTextDeltaStreamEvent("recovered") + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) return nil }, } @@ -2213,6 +2266,7 @@ func TestServiceSerializesRunAndCompact(t *testing.T) { } <-unblockProvider events <- providertypes.NewTextDeltaStreamEvent("done") + events <- providertypes.NewMessageDoneStreamEvent("stop", nil) return nil }, } @@ -2462,6 +2516,7 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri t.Helper() apiKeyEnv := runtimeTestAPIKeyEnv(t) + defaultWorkdir := t.TempDir() restoreRuntimeEnv(t, apiKeyEnv) if err := os.Setenv(apiKeyEnv, "test-key"); err != nil { t.Fatalf("set env: %v", err) @@ -2491,6 +2546,7 @@ func newRuntimeConfigManagerWithProviderEnvs(t *testing.T, providerEnvs map[stri if err := manager.Update(context.Background(), func(cfg *config.Config) error { cfg.ToolTimeoutSec = 1 cfg.MaxLoops = 4 + cfg.Workdir = defaultWorkdir return nil }); err != nil { t.Fatalf("update config: %v", err) @@ -2730,6 +2786,7 @@ func TestIsRetryableProviderError(t *testing.T) { {"non-retryable provider error", &provider.ProviderError{Retryable: false}, false}, {"plain error", errors.New("something failed"), false}, {"wrapped retryable", fmt.Errorf("wrapped: %w", &provider.ProviderError{Retryable: true}), true}, + {"stream interrupted sentinel", provider.ErrStreamInterrupted, false}, } for _, tt := range tests { From 17d24073323da6aa75e4d3e40908daa4d3560411 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 10:13:27 +0800 Subject: [PATCH 07/54] =?UTF-8?q?feat=EF=BC=9A=E5=8A=A0=E4=B8=8Atoken?= =?UTF-8?q?=E8=AE=A1=E9=87=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/loader.go | 17 ++++++- internal/config/model.go | 49 +++++++++++++++++-- internal/context/builder.go | 8 +++- internal/context/metadata.go | 10 ++-- internal/context/types.go | 8 ++-- internal/runtime/compact_generator.go | 2 +- internal/runtime/events.go | 10 ++++ internal/runtime/runtime.go | 69 ++++++++++++++++++++++++--- internal/runtime/runtime_test.go | 3 ++ internal/session/store.go | 4 +- 10 files changed, 158 insertions(+), 22 deletions(-) diff --git a/internal/config/loader.go b/internal/config/loader.go index ea3a6c3e..2cf6e693 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -34,7 +34,8 @@ type persistedConfig struct { } type persistedContextConfig struct { - Compact persistedCompactConfig `yaml:"compact,omitempty"` + Compact persistedCompactConfig `yaml:"compact,omitempty"` + AutoCompact persistedAutoCompactConfig `yaml:"auto_compact,omitempty"` } type persistedCompactConfig struct { @@ -44,6 +45,11 @@ type persistedCompactConfig struct { MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` } +type persistedAutoCompactConfig struct { + Enabled bool `yaml:"enabled"` + InputTokenThreshold int `yaml:"input_token_threshold,omitempty"` +} + func NewLoader(baseDir string, defaults *Config) *Loader { if defaults == nil { panic("config: loader defaults are nil") @@ -220,6 +226,10 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { MaxSummaryChars: cfg.Compact.MaxSummaryChars, MicroCompactDisabled: cfg.Compact.MicroCompactDisabled, }, + AutoCompact: persistedAutoCompactConfig{ + Enabled: cfg.AutoCompact.Enabled, + InputTokenThreshold: cfg.AutoCompact.InputTokenThreshold, + }, } } @@ -232,8 +242,13 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon MaxSummaryChars: file.Compact.MaxSummaryChars, MicroCompactDisabled: file.Compact.MicroCompactDisabled, }, + AutoCompact: AutoCompactConfig{ + Enabled: file.AutoCompact.Enabled, + InputTokenThreshold: file.AutoCompact.InputTokenThreshold, + }, } out.Compact.ApplyDefaults(defaults.Compact) + out.AutoCompact.ApplyDefaults(defaults.AutoCompact) return out } diff --git a/internal/config/model.go b/internal/config/model.go index 75992d1c..ff7b4ee9 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -17,6 +17,7 @@ const ( DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 DefaultCompactManualKeepRecentMessages = 10 DefaultCompactMaxSummaryChars = 1200 + DefaultAutoCompactInputTokenThreshold = 100000 ) const ( @@ -63,7 +64,14 @@ type ToolsConfig struct { } type ContextConfig struct { - Compact CompactConfig `yaml:"compact,omitempty"` + Compact CompactConfig `yaml:"compact,omitempty"` + AutoCompact AutoCompactConfig `yaml:"auto_compact,omitempty"` +} + +// AutoCompactConfig controls automatic context compression triggered by token thresholds. +type AutoCompactConfig struct { + Enabled bool `yaml:"enabled"` + InputTokenThreshold int `yaml:"input_token_threshold,omitempty"` } type CompactConfig struct { @@ -333,7 +341,14 @@ func defaultWebFetchConfig() WebFetchConfig { // defaultContextConfig 返回上下文压缩相关配置的默认值。 func defaultContextConfig() ContextConfig { return ContextConfig{ - Compact: defaultCompactConfig(), + Compact: defaultCompactConfig(), + AutoCompact: defaultAutoCompactConfig(), + } +} + +func defaultAutoCompactConfig() AutoCompactConfig { + return AutoCompactConfig{ + InputTokenThreshold: DefaultAutoCompactInputTokenThreshold, } } @@ -355,7 +370,8 @@ func (c ToolsConfig) Clone() ToolsConfig { // Clone 返回上下文配置的独立副本,避免后续修改污染原值。 func (c ContextConfig) Clone() ContextConfig { return ContextConfig{ - Compact: c.Compact.Clone(), + Compact: c.Compact.Clone(), + AutoCompact: c.AutoCompact.Clone(), } } @@ -374,6 +390,7 @@ func (c *ContextConfig) ApplyDefaults(defaults ContextConfig) { } c.Compact.ApplyDefaults(defaults.Compact) + c.AutoCompact.ApplyDefaults(defaults.AutoCompact) } func (c ToolsConfig) Validate() error { @@ -388,6 +405,9 @@ func (c ContextConfig) Validate() error { if err := c.Compact.Validate(); err != nil { return fmt.Errorf("compact: %w", err) } + if err := c.AutoCompact.Validate(); err != nil { + return fmt.Errorf("auto_compact: %w", err) + } return nil } @@ -402,6 +422,29 @@ func (c CompactConfig) Clone() CompactConfig { return c } +// Clone 返回 auto_compact 配置的值副本。 +func (c AutoCompactConfig) Clone() AutoCompactConfig { + return c +} + +// ApplyDefaults 为 auto_compact 配置填充缺省阈值。 +func (c *AutoCompactConfig) ApplyDefaults(defaults AutoCompactConfig) { + if c == nil { + return + } + if c.InputTokenThreshold <= 0 { + c.InputTokenThreshold = defaults.InputTokenThreshold + } +} + +// Validate 校验 auto_compact 配置是否合法。 +func (c AutoCompactConfig) Validate() error { + if c.Enabled && c.InputTokenThreshold <= 0 { + return errors.New("input_token_threshold must be greater than 0 when enabled") + } + return nil +} + func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { if c == nil { return diff --git a/internal/context/builder.go b/internal/context/builder.go index 96e3c437..8a17ba36 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -52,9 +52,13 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu trimPolicy = spanMessageTrimPolicy{} } + shouldAutoCompact := input.Compact.AutoCompactThreshold > 0 && + input.Metadata.SessionInputTokens >= input.Compact.AutoCompactThreshold + return BuildResult{ - SystemPrompt: composeSystemPrompt(sections...), - Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies), + SystemPrompt: composeSystemPrompt(sections...), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies), + ShouldAutoCompact: shouldAutoCompact, }, nil } diff --git a/internal/context/metadata.go b/internal/context/metadata.go index bec1f559..14eaa6c0 100644 --- a/internal/context/metadata.go +++ b/internal/context/metadata.go @@ -2,10 +2,12 @@ package context // Metadata contains the non-message runtime state needed by context sources. type Metadata struct { - Workdir string - Shell string - Provider string - Model string + Workdir string + Shell string + Provider string + Model string + SessionInputTokens int + SessionOutputTokens int } // GitState is the summarized git metadata exposed to the prompt builder. diff --git a/internal/context/types.go b/internal/context/types.go index 53af2bfa..6fadc524 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -21,8 +21,9 @@ type BuildInput struct { // BuildResult is the provider-facing context produced for a single round. type BuildResult struct { - SystemPrompt string - Messages []providertypes.Message + SystemPrompt string + Messages []providertypes.Message + ShouldAutoCompact bool } // MicroCompactPolicySource 定义 context 读取工具 micro compact 策略的最小依赖。 @@ -32,5 +33,6 @@ type MicroCompactPolicySource interface { // CompactOptions controls read-time compact behavior inside the context builder. type CompactOptions struct { - DisableMicroCompact bool + DisableMicroCompact bool + AutoCompactThreshold int } diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index af62c13a..ed6ce60e 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -74,7 +74,7 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom if !ok { return } - if err := handleProviderStreamEvent(event, acc, nil, nil); err != nil && streamErr == nil { + if err := handleProviderStreamEvent(event, acc, nil, nil, nil); err != nil && streamErr == nil { // 记录首个协议错误后继续排空事件通道,避免 provider 在后续发送时阻塞。 streamErr = err } diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 704ff14a..80117aec 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -79,4 +79,14 @@ const ( EventCompactDone EventType = "compact_done" // EventCompactError is emitted when compact fails. EventCompactError EventType = "compact_error" + // EventTokenUsage is emitted after each provider response with token statistics. + EventTokenUsage EventType = "token_usage" ) + +// TokenUsagePayload carries token usage statistics for a single provider turn. +type TokenUsagePayload struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + SessionInputTokens int `json:"session_input_tokens"` + SessionOutputTokens int `json:"session_output_tokens"` +} \ No newline at end of file diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index f124ea9b..fa0087ec 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -143,7 +143,9 @@ type Service struct { runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 - activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 + activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 + sessionInputTokens int // 当前会话累计输入 token。 + sessionOutputTokens int // 当前会话累计输出 token。 } func NewWithFactory( @@ -194,6 +196,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } + s.restoreSessionTokens(session) userMessage := providertypes.Message{ Role: providertypes.RoleUser, @@ -206,6 +209,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) + autoCompacted := false + for attempt := 0; ; attempt++ { if err := ctx.Err(); err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) @@ -226,19 +231,34 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ Messages: session.Messages, Metadata: agentcontext.Metadata{ - Workdir: activeWorkdir, - Shell: cfg.Shell, - Provider: cfg.SelectedProvider, - Model: cfg.CurrentModel, + Workdir: activeWorkdir, + Shell: cfg.Shell, + Provider: cfg.SelectedProvider, + Model: cfg.CurrentModel, + SessionInputTokens: s.sessionInputTokens, + SessionOutputTokens: s.sessionOutputTokens, }, Compact: agentcontext.CompactOptions{ - DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, + DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, + AutoCompactThreshold: s.autoCompactThreshold(cfg), }, }) if err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) } + if builtContext.ShouldAutoCompact && !autoCompacted { + autoCompacted = true + var compactResult contextcompact.Result + session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, false) + if compactResult.Applied { + s.sessionInputTokens = 0 + s.sessionOutputTokens = 0 + session.TokenInputTotal = 0 + session.TokenOutputTotal = 0 + } + } + toolSpecs, err := s.toolManager.ListAvailableSpecs(ctx, tools.SpecListInput{ SessionID: session.ID, }) @@ -262,6 +282,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { metadataChanged := session.Provider != cfg.SelectedProvider || session.Model != cfg.CurrentModel session.Provider = cfg.SelectedProvider session.Model = cfg.CurrentModel + session.TokenInputTotal = s.sessionInputTokens + session.TokenOutputTotal = s.sessionOutputTokens assistant, err := acc.buildMessage() if err != nil { @@ -475,6 +497,7 @@ func handleProviderStreamEvent( acc *streamAccumulator, onTextDelta func(string), onToolCallStart func(providertypes.ToolCallStartPayload), + onMessageDone func(providertypes.MessageDonePayload), ) error { switch event.Type { case providertypes.StreamEventTextDelta: @@ -508,9 +531,13 @@ func handleProviderStreamEvent( acc.accumulateToolCallDelta(payload.Index, payload.ID, payload.ArgumentsDelta) } case providertypes.StreamEventMessageDone: - if _, err := event.MessageDoneValue(); err != nil { + payload, err := event.MessageDoneValue() + if err != nil { return err } + if onMessageDone != nil { + onMessageDone(payload) + } default: return fmt.Errorf("runtime: unsupported provider stream event type %q", event.Type) } @@ -547,6 +574,18 @@ func (s *Service) forwardProviderEvents( func(payload providertypes.ToolCallStartPayload) { s.emit(ctx, EventToolCallThinking, runID, sessionID, payload.Name) }, + func(done providertypes.MessageDonePayload) { + if done.Usage != nil { + s.sessionInputTokens += done.Usage.InputTokens + s.sessionOutputTokens += done.Usage.OutputTokens + s.emit(ctx, EventTokenUsage, runID, sessionID, TokenUsagePayload{ + InputTokens: done.Usage.InputTokens, + OutputTokens: done.Usage.OutputTokens, + SessionInputTokens: s.sessionInputTokens, + SessionOutputTokens: s.sessionOutputTokens, + }) + } + }, ) if err != nil && forwardErr == nil { // 记录首个协议错误后继续排空事件通道,避免 provider 在后续发送时阻塞。 @@ -558,6 +597,22 @@ func (s *Service) forwardProviderEvents( } } +// restoreSessionTokens restores token counters from session persistence, +// or resets them to zero for new sessions. +func (s *Service) restoreSessionTokens(session agentsession.Session) { + s.sessionInputTokens = session.TokenInputTotal + s.sessionOutputTokens = session.TokenOutputTotal +} + +// autoCompactThreshold returns the configured auto-compact input token threshold, +// or 0 if auto-compact is disabled. +func (s *Service) autoCompactThreshold(cfg config.Config) int { + if cfg.Context.AutoCompact.Enabled && cfg.Context.AutoCompact.InputTokenThreshold > 0 { + return cfg.Context.AutoCompact.InputTokenThreshold + } + return 0 +} + func (s *Service) startRun(cancel context.CancelFunc) uint64 { s.runMu.Lock() defer s.runMu.Unlock() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 988f6201..de2c533c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2954,6 +2954,7 @@ func TestHandleProviderStreamEventErrorBranches(t *testing.T) { acc, nil, nil, + nil, ) if err == nil || !containsError(err, "tool_call_start event payload is nil") { t.Fatalf("expected tool_call_start payload error, got %v", err) @@ -2964,6 +2965,7 @@ func TestHandleProviderStreamEventErrorBranches(t *testing.T) { acc, nil, nil, + nil, ) if err == nil || !containsError(err, "tool_call_delta event payload is nil") { t.Fatalf("expected tool_call_delta payload error, got %v", err) @@ -2974,6 +2976,7 @@ func TestHandleProviderStreamEventErrorBranches(t *testing.T) { acc, nil, nil, + nil, ) if err == nil || !containsError(err, "message_done event payload is nil") { t.Fatalf("expected message_done payload error, got %v", err) diff --git a/internal/session/store.go b/internal/session/store.go index 57639374..fb660dd2 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -29,7 +29,9 @@ type Session struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Workdir string `json:"workdir,omitempty"` - Messages []providertypes.Message `json:"messages"` + Messages []providertypes.Message `json:"messages"` + TokenInputTotal int `json:"token_input_total,omitempty"` + TokenOutputTotal int `json:"token_output_total,omitempty"` } // Summary 表示会话列表视图所需的轻量摘要信息。 From 547f75793ec8abc8002abdedbf7b5204ccac327b Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 10:13:27 +0800 Subject: [PATCH 08/54] =?UTF-8?q?feat=EF=BC=9A=E5=8A=A0=E4=B8=8Atoken?= =?UTF-8?q?=E8=AE=A1=E9=87=8F=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/loader.go | 17 ++++++- internal/config/model.go | 49 +++++++++++++++++-- internal/context/builder.go | 8 +++- internal/context/metadata.go | 10 ++-- internal/context/types.go | 8 ++-- internal/runtime/compact_generator.go | 2 +- internal/runtime/events.go | 10 ++++ internal/runtime/runtime.go | 69 ++++++++++++++++++++++++--- internal/runtime/runtime_test.go | 3 ++ internal/session/store.go | 4 +- 10 files changed, 158 insertions(+), 22 deletions(-) diff --git a/internal/config/loader.go b/internal/config/loader.go index ea3a6c3e..2cf6e693 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -34,7 +34,8 @@ type persistedConfig struct { } type persistedContextConfig struct { - Compact persistedCompactConfig `yaml:"compact,omitempty"` + Compact persistedCompactConfig `yaml:"compact,omitempty"` + AutoCompact persistedAutoCompactConfig `yaml:"auto_compact,omitempty"` } type persistedCompactConfig struct { @@ -44,6 +45,11 @@ type persistedCompactConfig struct { MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"` } +type persistedAutoCompactConfig struct { + Enabled bool `yaml:"enabled"` + InputTokenThreshold int `yaml:"input_token_threshold,omitempty"` +} + func NewLoader(baseDir string, defaults *Config) *Loader { if defaults == nil { panic("config: loader defaults are nil") @@ -220,6 +226,10 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig { MaxSummaryChars: cfg.Compact.MaxSummaryChars, MicroCompactDisabled: cfg.Compact.MicroCompactDisabled, }, + AutoCompact: persistedAutoCompactConfig{ + Enabled: cfg.AutoCompact.Enabled, + InputTokenThreshold: cfg.AutoCompact.InputTokenThreshold, + }, } } @@ -232,8 +242,13 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon MaxSummaryChars: file.Compact.MaxSummaryChars, MicroCompactDisabled: file.Compact.MicroCompactDisabled, }, + AutoCompact: AutoCompactConfig{ + Enabled: file.AutoCompact.Enabled, + InputTokenThreshold: file.AutoCompact.InputTokenThreshold, + }, } out.Compact.ApplyDefaults(defaults.Compact) + out.AutoCompact.ApplyDefaults(defaults.AutoCompact) return out } diff --git a/internal/config/model.go b/internal/config/model.go index 3296dc20..9b8d80f1 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -17,6 +17,7 @@ const ( DefaultWebFetchMaxResponseBytes int64 = 256 * 1024 DefaultCompactManualKeepRecentMessages = 10 DefaultCompactMaxSummaryChars = 1200 + DefaultAutoCompactInputTokenThreshold = 100000 ) const ( @@ -64,7 +65,14 @@ type ToolsConfig struct { } type ContextConfig struct { - Compact CompactConfig `yaml:"compact,omitempty"` + Compact CompactConfig `yaml:"compact,omitempty"` + AutoCompact AutoCompactConfig `yaml:"auto_compact,omitempty"` +} + +// AutoCompactConfig controls automatic context compression triggered by token thresholds. +type AutoCompactConfig struct { + Enabled bool `yaml:"enabled"` + InputTokenThreshold int `yaml:"input_token_threshold,omitempty"` } type CompactConfig struct { @@ -370,7 +378,14 @@ func defaultMCPConfig() MCPConfig { // defaultContextConfig 返回上下文压缩相关配置的默认值。 func defaultContextConfig() ContextConfig { return ContextConfig{ - Compact: defaultCompactConfig(), + Compact: defaultCompactConfig(), + AutoCompact: defaultAutoCompactConfig(), + } +} + +func defaultAutoCompactConfig() AutoCompactConfig { + return AutoCompactConfig{ + InputTokenThreshold: DefaultAutoCompactInputTokenThreshold, } } @@ -393,7 +408,8 @@ func (c ToolsConfig) Clone() ToolsConfig { // Clone 返回上下文配置的独立副本,避免后续修改污染原值。 func (c ContextConfig) Clone() ContextConfig { return ContextConfig{ - Compact: c.Compact.Clone(), + Compact: c.Compact.Clone(), + AutoCompact: c.AutoCompact.Clone(), } } @@ -413,6 +429,7 @@ func (c *ContextConfig) ApplyDefaults(defaults ContextConfig) { } c.Compact.ApplyDefaults(defaults.Compact) + c.AutoCompact.ApplyDefaults(defaults.AutoCompact) } func (c ToolsConfig) Validate() error { @@ -514,6 +531,9 @@ func (c ContextConfig) Validate() error { if err := c.Compact.Validate(); err != nil { return fmt.Errorf("compact: %w", err) } + if err := c.AutoCompact.Validate(); err != nil { + return fmt.Errorf("auto_compact: %w", err) + } return nil } @@ -528,6 +548,29 @@ func (c CompactConfig) Clone() CompactConfig { return c } +// Clone 返回 auto_compact 配置的值副本。 +func (c AutoCompactConfig) Clone() AutoCompactConfig { + return c +} + +// ApplyDefaults 为 auto_compact 配置填充缺省阈值。 +func (c *AutoCompactConfig) ApplyDefaults(defaults AutoCompactConfig) { + if c == nil { + return + } + if c.InputTokenThreshold <= 0 { + c.InputTokenThreshold = defaults.InputTokenThreshold + } +} + +// Validate 校验 auto_compact 配置是否合法。 +func (c AutoCompactConfig) Validate() error { + if c.Enabled && c.InputTokenThreshold <= 0 { + return errors.New("input_token_threshold must be greater than 0 when enabled") + } + return nil +} + func (c *WebFetchConfig) ApplyDefaults(defaults WebFetchConfig) { if c == nil { return diff --git a/internal/context/builder.go b/internal/context/builder.go index 96e3c437..8a17ba36 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -52,9 +52,13 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu trimPolicy = spanMessageTrimPolicy{} } + shouldAutoCompact := input.Compact.AutoCompactThreshold > 0 && + input.Metadata.SessionInputTokens >= input.Compact.AutoCompactThreshold + return BuildResult{ - SystemPrompt: composeSystemPrompt(sections...), - Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies), + SystemPrompt: composeSystemPrompt(sections...), + Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies), + ShouldAutoCompact: shouldAutoCompact, }, nil } diff --git a/internal/context/metadata.go b/internal/context/metadata.go index bec1f559..14eaa6c0 100644 --- a/internal/context/metadata.go +++ b/internal/context/metadata.go @@ -2,10 +2,12 @@ package context // Metadata contains the non-message runtime state needed by context sources. type Metadata struct { - Workdir string - Shell string - Provider string - Model string + Workdir string + Shell string + Provider string + Model string + SessionInputTokens int + SessionOutputTokens int } // GitState is the summarized git metadata exposed to the prompt builder. diff --git a/internal/context/types.go b/internal/context/types.go index 53af2bfa..6fadc524 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -21,8 +21,9 @@ type BuildInput struct { // BuildResult is the provider-facing context produced for a single round. type BuildResult struct { - SystemPrompt string - Messages []providertypes.Message + SystemPrompt string + Messages []providertypes.Message + ShouldAutoCompact bool } // MicroCompactPolicySource 定义 context 读取工具 micro compact 策略的最小依赖。 @@ -32,5 +33,6 @@ type MicroCompactPolicySource interface { // CompactOptions controls read-time compact behavior inside the context builder. type CompactOptions struct { - DisableMicroCompact bool + DisableMicroCompact bool + AutoCompactThreshold int } diff --git a/internal/runtime/compact_generator.go b/internal/runtime/compact_generator.go index af62c13a..ed6ce60e 100644 --- a/internal/runtime/compact_generator.go +++ b/internal/runtime/compact_generator.go @@ -74,7 +74,7 @@ func (g *compactSummaryGenerator) Generate(ctx context.Context, input contextcom if !ok { return } - if err := handleProviderStreamEvent(event, acc, nil, nil); err != nil && streamErr == nil { + if err := handleProviderStreamEvent(event, acc, nil, nil, nil); err != nil && streamErr == nil { // 记录首个协议错误后继续排空事件通道,避免 provider 在后续发送时阻塞。 streamErr = err } diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 704ff14a..80117aec 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -79,4 +79,14 @@ const ( EventCompactDone EventType = "compact_done" // EventCompactError is emitted when compact fails. EventCompactError EventType = "compact_error" + // EventTokenUsage is emitted after each provider response with token statistics. + EventTokenUsage EventType = "token_usage" ) + +// TokenUsagePayload carries token usage statistics for a single provider turn. +type TokenUsagePayload struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + SessionInputTokens int `json:"session_input_tokens"` + SessionOutputTokens int `json:"session_output_tokens"` +} \ No newline at end of file diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index f124ea9b..fa0087ec 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -143,7 +143,9 @@ type Service struct { runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 - activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 + activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 + sessionInputTokens int // 当前会话累计输入 token。 + sessionOutputTokens int // 当前会话累计输出 token。 } func NewWithFactory( @@ -194,6 +196,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { if err != nil { return s.handleRunError(ctx, input.RunID, input.SessionID, err) } + s.restoreSessionTokens(session) userMessage := providertypes.Message{ Role: providertypes.RoleUser, @@ -206,6 +209,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) + autoCompacted := false + for attempt := 0; ; attempt++ { if err := ctx.Err(); err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) @@ -226,19 +231,34 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { builtContext, err := s.contextBuilder.Build(ctx, agentcontext.BuildInput{ Messages: session.Messages, Metadata: agentcontext.Metadata{ - Workdir: activeWorkdir, - Shell: cfg.Shell, - Provider: cfg.SelectedProvider, - Model: cfg.CurrentModel, + Workdir: activeWorkdir, + Shell: cfg.Shell, + Provider: cfg.SelectedProvider, + Model: cfg.CurrentModel, + SessionInputTokens: s.sessionInputTokens, + SessionOutputTokens: s.sessionOutputTokens, }, Compact: agentcontext.CompactOptions{ - DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, + DisableMicroCompact: cfg.Context.Compact.MicroCompactDisabled, + AutoCompactThreshold: s.autoCompactThreshold(cfg), }, }) if err != nil { return s.handleRunError(ctx, input.RunID, session.ID, err) } + if builtContext.ShouldAutoCompact && !autoCompacted { + autoCompacted = true + var compactResult contextcompact.Result + session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, false) + if compactResult.Applied { + s.sessionInputTokens = 0 + s.sessionOutputTokens = 0 + session.TokenInputTotal = 0 + session.TokenOutputTotal = 0 + } + } + toolSpecs, err := s.toolManager.ListAvailableSpecs(ctx, tools.SpecListInput{ SessionID: session.ID, }) @@ -262,6 +282,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { metadataChanged := session.Provider != cfg.SelectedProvider || session.Model != cfg.CurrentModel session.Provider = cfg.SelectedProvider session.Model = cfg.CurrentModel + session.TokenInputTotal = s.sessionInputTokens + session.TokenOutputTotal = s.sessionOutputTokens assistant, err := acc.buildMessage() if err != nil { @@ -475,6 +497,7 @@ func handleProviderStreamEvent( acc *streamAccumulator, onTextDelta func(string), onToolCallStart func(providertypes.ToolCallStartPayload), + onMessageDone func(providertypes.MessageDonePayload), ) error { switch event.Type { case providertypes.StreamEventTextDelta: @@ -508,9 +531,13 @@ func handleProviderStreamEvent( acc.accumulateToolCallDelta(payload.Index, payload.ID, payload.ArgumentsDelta) } case providertypes.StreamEventMessageDone: - if _, err := event.MessageDoneValue(); err != nil { + payload, err := event.MessageDoneValue() + if err != nil { return err } + if onMessageDone != nil { + onMessageDone(payload) + } default: return fmt.Errorf("runtime: unsupported provider stream event type %q", event.Type) } @@ -547,6 +574,18 @@ func (s *Service) forwardProviderEvents( func(payload providertypes.ToolCallStartPayload) { s.emit(ctx, EventToolCallThinking, runID, sessionID, payload.Name) }, + func(done providertypes.MessageDonePayload) { + if done.Usage != nil { + s.sessionInputTokens += done.Usage.InputTokens + s.sessionOutputTokens += done.Usage.OutputTokens + s.emit(ctx, EventTokenUsage, runID, sessionID, TokenUsagePayload{ + InputTokens: done.Usage.InputTokens, + OutputTokens: done.Usage.OutputTokens, + SessionInputTokens: s.sessionInputTokens, + SessionOutputTokens: s.sessionOutputTokens, + }) + } + }, ) if err != nil && forwardErr == nil { // 记录首个协议错误后继续排空事件通道,避免 provider 在后续发送时阻塞。 @@ -558,6 +597,22 @@ func (s *Service) forwardProviderEvents( } } +// restoreSessionTokens restores token counters from session persistence, +// or resets them to zero for new sessions. +func (s *Service) restoreSessionTokens(session agentsession.Session) { + s.sessionInputTokens = session.TokenInputTotal + s.sessionOutputTokens = session.TokenOutputTotal +} + +// autoCompactThreshold returns the configured auto-compact input token threshold, +// or 0 if auto-compact is disabled. +func (s *Service) autoCompactThreshold(cfg config.Config) int { + if cfg.Context.AutoCompact.Enabled && cfg.Context.AutoCompact.InputTokenThreshold > 0 { + return cfg.Context.AutoCompact.InputTokenThreshold + } + return 0 +} + func (s *Service) startRun(cancel context.CancelFunc) uint64 { s.runMu.Lock() defer s.runMu.Unlock() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 988f6201..de2c533c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2954,6 +2954,7 @@ func TestHandleProviderStreamEventErrorBranches(t *testing.T) { acc, nil, nil, + nil, ) if err == nil || !containsError(err, "tool_call_start event payload is nil") { t.Fatalf("expected tool_call_start payload error, got %v", err) @@ -2964,6 +2965,7 @@ func TestHandleProviderStreamEventErrorBranches(t *testing.T) { acc, nil, nil, + nil, ) if err == nil || !containsError(err, "tool_call_delta event payload is nil") { t.Fatalf("expected tool_call_delta payload error, got %v", err) @@ -2974,6 +2976,7 @@ func TestHandleProviderStreamEventErrorBranches(t *testing.T) { acc, nil, nil, + nil, ) if err == nil || !containsError(err, "message_done event payload is nil") { t.Fatalf("expected message_done payload error, got %v", err) diff --git a/internal/session/store.go b/internal/session/store.go index 57639374..fb660dd2 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -29,7 +29,9 @@ type Session struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Workdir string `json:"workdir,omitempty"` - Messages []providertypes.Message `json:"messages"` + Messages []providertypes.Message `json:"messages"` + TokenInputTotal int `json:"token_input_total,omitempty"` + TokenOutputTotal int `json:"token_output_total,omitempty"` } // Summary 表示会话列表视图所需的轻量摘要信息。 From e069b506c592680dec808b5f620ff1ae334b972a Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 11:25:07 +0800 Subject: [PATCH 09/54] =?UTF-8?q?docs=EF=BC=9A=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 22 +++++++++++++++++----- docs/context-compact.md | 16 ++++++++++++++++ docs/runtime-provider-event-flow.md | 16 ++++++++++++++++ docs/session-persistence-design.md | 9 ++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 011cb19e..d3f8d794 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ - 不要在 `runtime` 或 `tui` 里直接写工具执行逻辑;所有可被模型调用的能力必须进入 `internal/tools`。 - 不要把会话状态、消息历史、工具调用记录散落到 UI;这些状态优先由 `runtime` 管理。 - 不要把明文 API Key 写入 YAML、样例配置、测试快照或提交内容。 -- 修改 `config`、`provider`、`runtime`、`tools` 时,默认应同时评估并补充测试。 +- 修改 `config`、`provider`、`runtime`、`tools`、`context` 时,默认应同时评估并补充测试。 ## 3. 代码结构与职责边界 @@ -24,16 +24,19 @@ - `internal/app`:应用装配与 bootstrap,负责连接 config、provider、tools、runtime、tui。 - `internal/config`:配置模型、YAML 加载、环境变量管理、配置校验和并发安全访问。 - `internal/provider`:provider 抽象、领域模型和各厂商适配器。 -- `internal/runtime`:ReAct 主循环、事件流、Prompt 编排、Session 持久化。 +- `internal/runtime`:ReAct 主循环、事件流、Prompt 编排、token 累积与自动压缩触发。 +- `internal/session`:会话领域模型、存储抽象与 JSON 持久化实现。 - `internal/tools`:工具契约、注册表、参数校验和具体工具实现。 - `internal/tui`:Bubble Tea 状态机、渲染层、Slash Command 和事件桥接。 - `docs`:架构、配置、事件流、会话持久化等说明文档。 ### 3.2 模块职责 - `app` 只负责装配和依赖注入,不承载业务规则。 -- `config` 负责 provider 列表、当前 provider、当前 model、workdir、shell 的管理和校验。 +- `config` 负责 provider 列表、当前 provider、当前 model、workdir、shell、context 压缩策略(含自动压缩阈值)的管理和校验。 - `provider` 只处理模型协议差异、请求组装、响应解析、流式输出、超时与重试。 -- `runtime` 负责会话、消息上下文、tool schema 传递、tool call 识别、tool result 回灌和停止条件。 +- `context` 负责上下文构建(system prompt 组装、micro compact、消息裁剪)和自动压缩决策(基于 token 阈值判断是否需要压缩)。 +- `runtime` 负责会话编排、消息上下文流转、tool schema 传递、tool call 识别、tool result 回灌、token 累积、事件派发和停止条件。runtime 不替 context 做压缩决策。 +- `session` 负责会话领域模型与 JSON 持久化,包括 token 累计值的持久化。 - `tools` 负责统一的 `schema + execute + result` 协议,以及参数校验、错误包装和输出格式收敛。 - `tui` 只消费 runtime 事件并负责展示,不直接调用 provider,不直接执行 tools。 @@ -58,7 +61,16 @@ - tool result 回灌 - 最终响应输出 - 错误事件派发 -- 修改 `tools` 时,重点覆盖: + - token 累积记录与事件发射 + - 自动压缩触发与重置逻辑 +- 修改 `context` 时,重点覆盖: + - Build 输入输出契约(含 Metadata 新字段、ShouldAutoCompact 决策) + - micro compact 策略 + - 消息裁剪边界 + - AGENTS.md 加载与截断 +- 修改 `config` 时,重点覆盖: + - 新增配置项的校验、默认值和序列化/反序列化 + - 配置加载向后兼容(新字段 omitempty) - schema 校验 - 超时控制 - 错误包装 diff --git a/docs/context-compact.md b/docs/context-compact.md index 43e43fa1..7f6fec7a 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -20,6 +20,9 @@ context: manual_keep_recent_messages: 10 max_summary_chars: 1200 micro_compact_disabled: false + auto_compact: + enabled: false + input_token_threshold: 100000 ``` - `manual_strategy` @@ -30,6 +33,19 @@ context: 控制 compact summary 的最大字符数。 - `micro_compact_disabled` 控制是否关闭默认启用的读时 micro compact;设为 `true` 时会回退为仅 trim、不清理旧 tool result。 +- `auto_compact.enabled` + 控制是否启用基于 token 阈值的自动压缩;默认关闭。 +- `auto_compact.input_token_threshold` + 当会话累计输入 token 数达到此阈值时触发自动压缩;默认 100000。 + +## 自动压缩 + +当 `auto_compact.enabled` 为 `true` 时,runtime 在每次调用 `context.Builder.Build()` 时将当前 token 累计值传入 Metadata,context 模块通过比较累计值与阈值在 `BuildResult.ShouldAutoCompact` 中返回压缩建议。runtime 读取建议后调用现有 compact 管线执行压缩,并在成功后重置 token 计数器。 + +设计原则: +- **context 拥有压缩决策权**,runtime 只做编排执行。 +- 每次 `Run()` 调用最多触发一次自动压缩,避免无限循环。 +- 压缩成功后 token 计数器重置为零,下一轮不会立即重复触发。 新增工具时,micro compact 策略不再由 `context` 层静态白名单维护,而是由 `internal/tools` 中的工具实现声明。 默认情况下,已注册工具都会参与 micro compact;只有显式声明保留历史结果的工具才会跳过旧结果清理。 diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index e9e007e2..f8944d62 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -9,6 +9,7 @@ - `tool_start` - `tool_result` - `error` +- `token_usage` ## ReAct 主循环 @@ -30,11 +31,15 @@ - `shell` - 当前 `provider` - 当前 `model` + - 会话累计输入 token 数(`SessionInputTokens`) + - 会话累计输出 token 数(`SessionOutputTokens`) + - 自动压缩阈值(`AutoCompactThreshold`) - `context.Builder` 负责统一组装: - 固定核心 system prompt sections - 从 `workdir` 向上发现的 `AGENTS.md` - 系统状态摘要(`workdir` / `shell` / `provider` / `model` / git branch / git dirty) - 裁剪后的历史消息 + - 自动压缩决策(`BuildResult.ShouldAutoCompact`) - `runtime` 不直接读取规则文件,也不直接查询 git 状态。 - `provider` 只消费最终生成的 `SystemPrompt`、消息列表和工具 schema,不感知上下文来源。 @@ -59,6 +64,17 @@ - runtime 将其转换成 `RuntimeEvent` - TUI 使用 Bubble Tea `Cmd` 监听事件,并在处理完成后继续订阅 +## Token 计量 + +runtime 在转发 provider 流式事件时,从 `MessageDone` 事件中提取 `Usage`(`InputTokens`、`OutputTokens`),累积到会话级计数器,并发出 `token_usage` 事件供 TUI 消费。 + +`token_usage` payload 包含: + +- `input_tokens`:本次调用输入 token +- `output_tokens`:本次调用输出 token +- `session_input_tokens`:会话累计输入 token +- `session_output_tokens`:会话累计输出 token + ## 持久化时机 - 用户消息提交后保存 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index e66f7800..a3f40511 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -9,9 +9,16 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优先、易于调试和跨平台可移植。 ## 数据模型 -- `Session`:完整消息历史以及 `id`、`title`、`updated_at` 等元信息 +- `Session`:完整消息历史以及 `id`、`title`、`updated_at`、`token_input_total`、`token_output_total` 等元信息 - `Summary`:用于侧边栏的轻量摘要结构(原 `SessionSummary` 命名已统一收口为 `Summary`) +### Token 持久化 +- `token_input_total` 和 `token_output_total` 分别记录会话累计输入和输出 token。 +- 使用 `omitempty` 标签,确保旧版 JSON 文件正常加载(零值不序列化)。 +- runtime 在每次 provider 调用后更新 session 的 token 字段,随 session save 一起持久化。 +- 会话加载时,runtime 从 session 恢复 token 计数器;新建会话时计数器清零。 +- 自动压缩成功后 token 计数器重置为零,并持久化到 session。 + ## 加载策略 - `ListSummaries` 只读取渲染侧边栏所需的基础信息 - `Load` 仅在用户真正进入某个会话时读取完整消息历史 From 287b559bfff18abc0a9532797b0fd9595f4e08bb Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 11:25:25 +0800 Subject: [PATCH 10/54] =?UTF-8?q?test:=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 140 ++++++++++++++++++++++++ internal/context/builder_test.go | 80 ++++++++++++++ internal/runtime/runtime_test.go | 180 +++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 29b11b52..4f8105f1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1057,3 +1057,143 @@ func restoreEnv(t *testing.T, key string) { _ = os.Setenv(key, value) }) } + +func TestAutoCompactConfigDefaults(t *testing.T) { + t.Parallel() + + cfg := Default() + + if cfg.Context.AutoCompact.InputTokenThreshold != DefaultAutoCompactInputTokenThreshold { + t.Fatalf("expected input_token_threshold=%d, got %d", + DefaultAutoCompactInputTokenThreshold, cfg.Context.AutoCompact.InputTokenThreshold) + } + + if cfg.Context.AutoCompact.Enabled != false { + t.Fatalf("expected enabled=false, got %v", cfg.Context.AutoCompact.Enabled) + } +} + +func TestAutoCompactConfigApplyDefaults(t *testing.T) { + t.Parallel() + + cfg := AutoCompactConfig{} + defaults := AutoCompactConfig{ + InputTokenThreshold: 50000, + } + + cfg.ApplyDefaults(defaults) + + if cfg.InputTokenThreshold != 50000 { + t.Fatalf("expected threshold=50000, got %d", cfg.InputTokenThreshold) + } +} + +func TestAutoCompactConfigApplyDefaultsPreservesExplicit(t *testing.T) { + t.Parallel() + + cfg := AutoCompactConfig{ + InputTokenThreshold: 200000, + } + defaults := AutoCompactConfig{ + InputTokenThreshold: 50000, + } + + cfg.ApplyDefaults(defaults) + + if cfg.InputTokenThreshold != 200000 { + t.Fatalf("expected explicit threshold=200000 to be preserved, got %d", cfg.InputTokenThreshold) + } +} + +func TestAutoCompactConfigValidateEnabledWithoutThreshold(t *testing.T) { + t.Parallel() + + cfg := AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 0, + } + + err := cfg.Validate() + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "input_token_threshold") { + t.Fatalf("expected error about input_token_threshold, got %v", err) + } +} + +func TestAutoCompactConfigValidateDisabledWithoutThreshold(t *testing.T) { + t.Parallel() + + cfg := AutoCompactConfig{ + Enabled: false, + InputTokenThreshold: 0, + } + + err := cfg.Validate() + if err != nil { + t.Fatalf("expected no error for disabled auto compact, got %v", err) + } +} + +func TestAutoCompactConfigValidateEnabledWithThreshold(t *testing.T) { + t.Parallel() + + cfg := AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 50000, + } + + err := cfg.Validate() + if err != nil { + t.Fatalf("expected validation to pass, got %v", err) + } +} + +func TestAutoCompactConfigClone(t *testing.T) { + t.Parallel() + + cfg := AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 75000, + } + + cloned := cfg.Clone() + + if cfg.Enabled != cloned.Enabled { + t.Fatalf("expected enabled=%v to be cloned, got %v", cfg.Enabled, cloned.Enabled) + } + if cfg.InputTokenThreshold != cloned.InputTokenThreshold { + t.Fatalf("expected threshold=%d to be cloned, got %d", + cfg.InputTokenThreshold, cloned.InputTokenThreshold) + } + + cloned.InputTokenThreshold = 100000 + if cfg.InputTokenThreshold == cloned.InputTokenThreshold { + t.Fatalf("clone should be independent from source") + } +} + +func TestAutoCompactConfigContextConfigValidate(t *testing.T) { + t.Parallel() + + ctx := ContextConfig{ + AutoCompact: AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 0, + }, + Compact: CompactConfig{ + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + } + + err := ctx.Validate() + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "auto_compact") { + t.Fatalf("expected error to contain 'auto_compact', got %v", err) + } +} diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index dfe2b838..23dfa8c6 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -550,3 +550,83 @@ func TestTrimMessagesBoundaries(t *testing.T) { }) } } + +func TestBuildShouldAutoCompactDisabled(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + input := BuildInput{ + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Metadata: testMetadata(t.TempDir()), + Compact: CompactOptions{AutoCompactThreshold: 0}, + } + input.Metadata.SessionInputTokens = 100 + + result, err := builder.Build(stdcontext.Background(), input) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if result.ShouldAutoCompact { + t.Fatalf("expected ShouldAutoCompact false when threshold is 0") + } +} + +func TestBuildShouldAutoCompactBelowThreshold(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + input := BuildInput{ + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Metadata: testMetadata(t.TempDir()), + Compact: CompactOptions{AutoCompactThreshold: 100}, + } + input.Metadata.SessionInputTokens = 99 + + result, err := builder.Build(stdcontext.Background(), input) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if result.ShouldAutoCompact { + t.Fatalf("expected ShouldAutoCompact false when tokens below threshold") + } +} + +func TestBuildShouldAutoCompactAtThreshold(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + input := BuildInput{ + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Metadata: testMetadata(t.TempDir()), + Compact: CompactOptions{AutoCompactThreshold: 100}, + } + input.Metadata.SessionInputTokens = 100 + + result, err := builder.Build(stdcontext.Background(), input) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if !result.ShouldAutoCompact { + t.Fatalf("expected ShouldAutoCompact true when tokens equal threshold") + } +} + +func TestBuildShouldAutoCompactAboveThreshold(t *testing.T) { + t.Parallel() + + builder := NewBuilder() + input := BuildInput{ + Messages: []providertypes.Message{{Role: "user", Content: "hello"}}, + Metadata: testMetadata(t.TempDir()), + Compact: CompactOptions{AutoCompactThreshold: 100}, + } + input.Metadata.SessionInputTokens = 200 + + result, err := builder.Build(stdcontext.Background(), input) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if !result.ShouldAutoCompact { + t.Fatalf("expected ShouldAutoCompact true when tokens above threshold") + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index de2c533c..350f1327 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3037,3 +3037,183 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { t.Fatalf("expected combined forward/provider error, got %v", err) } } + +func TestRestoreSessionTokens(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 128), + } + session := agentsession.Session{ + TokenInputTotal: 500, + TokenOutputTotal: 200, + } + + service.restoreSessionTokens(session) + + if service.sessionInputTokens != 500 { + t.Fatalf("expected sessionInputTokens == 500, got %d", service.sessionInputTokens) + } + if service.sessionOutputTokens != 200 { + t.Fatalf("expected sessionOutputTokens == 200, got %d", service.sessionOutputTokens) + } +} + +func TestRestoreSessionTokensNewSession(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 128), + } + session := agentsession.Session{ + TokenInputTotal: 0, + TokenOutputTotal: 0, + } + + service.restoreSessionTokens(session) + + if service.sessionInputTokens != 0 { + t.Fatalf("expected sessionInputTokens == 0, got %d", service.sessionInputTokens) + } + if service.sessionOutputTokens != 0 { + t.Fatalf("expected sessionOutputTokens == 0, got %d", service.sessionOutputTokens) + } +} + +func TestAutoCompactThresholdEnabled(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 128), + } + cfg := config.Config{ + Context: config.ContextConfig{ + AutoCompact: config.AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 50000, + }, + }, + } + + threshold := service.autoCompactThreshold(cfg) + if threshold != 50000 { + t.Fatalf("expected threshold == 50000, got %d", threshold) + } +} + +func TestAutoCompactThresholdDisabled(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 128), + } + cfg := config.Config{ + Context: config.ContextConfig{ + AutoCompact: config.AutoCompactConfig{ + Enabled: false, + InputTokenThreshold: 50000, + }, + }, + } + + threshold := service.autoCompactThreshold(cfg) + if threshold != 0 { + t.Fatalf("expected threshold == 0, got %d", threshold) + } +} + +func TestAutoCompactThresholdZeroValue(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 128), + } + cfg := config.Config{ + Context: config.ContextConfig{ + AutoCompact: config.AutoCompactConfig{ + Enabled: true, + InputTokenThreshold: 0, + }, + }, + } + + threshold := service.autoCompactThreshold(cfg) + if threshold != 0 { + t.Fatalf("expected threshold == 0, got %d", threshold) + } +} + +func TestTokenUsageRecordedOnMessageDone(t *testing.T) { + t.Parallel() + + service := &Service{ + events: make(chan RuntimeEvent, 128), + sessionInputTokens: 0, + sessionOutputTokens: 0, + } + + events := collectRuntimeEvents(service.Events()) + + // Create a MessageDone stream event with token usage + messageDoneEvent := providertypes.NewMessageDoneStreamEvent("stop", &providertypes.Usage{ + InputTokens: 100, + OutputTokens: 50, + }) + + // Handle the event with an onMessageDone callback that mimics forwardProviderEvents + err := handleProviderStreamEvent( + messageDoneEvent, + nil, + nil, + nil, + func(payload providertypes.MessageDonePayload) { + if payload.Usage != nil { + service.sessionInputTokens += payload.Usage.InputTokens + service.sessionOutputTokens += payload.Usage.OutputTokens + service.emit(context.Background(), EventTokenUsage, "test-run-id", "test-session-id", TokenUsagePayload{ + InputTokens: payload.Usage.InputTokens, + OutputTokens: payload.Usage.OutputTokens, + SessionInputTokens: service.sessionInputTokens, + SessionOutputTokens: service.sessionOutputTokens, + }) + } + }, + ) + if err != nil { + t.Fatalf("handleProviderStreamEvent error = %v", err) + } + + // Verify the service counters are updated + if service.sessionInputTokens != 100 { + t.Fatalf("expected sessionInputTokens == 100, got %d", service.sessionInputTokens) + } + if service.sessionOutputTokens != 50 { + t.Fatalf("expected sessionOutputTokens == 50, got %d", service.sessionOutputTokens) + } + + // Verify EventTokenUsage was emitted with correct payload + events = collectRuntimeEvents(service.Events()) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != EventTokenUsage { + t.Fatalf("expected EventTokenUsage, got %s", events[0].Type) + } + + tokenUsagePayload, ok := events[0].Payload.(TokenUsagePayload) + if !ok { + t.Fatalf("expected TokenUsagePayload, got %T", events[0].Payload) + } + if tokenUsagePayload.InputTokens != 100 { + t.Fatalf("expected InputTokens == 100, got %d", tokenUsagePayload.InputTokens) + } + if tokenUsagePayload.OutputTokens != 50 { + t.Fatalf("expected OutputTokens == 50, got %d", tokenUsagePayload.OutputTokens) + } + if tokenUsagePayload.SessionInputTokens != 100 { + t.Fatalf("expected SessionInputTokens == 100, got %d", tokenUsagePayload.SessionInputTokens) + } + if tokenUsagePayload.SessionOutputTokens != 50 { + t.Fatalf("expected SessionOutputTokens == 50, got %d", tokenUsagePayload.SessionOutputTokens) + } +} From 4423abac075211be318cf409f83d4fa8e42c83df Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 11:49:43 +0800 Subject: [PATCH 11/54] =?UTF-8?q?test:=E5=86=8D=E6=AC=A1=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 27 +++ internal/runtime/runtime_test.go | 284 ++++++++++++++++++++++++++++++- 2 files changed, 305 insertions(+), 6 deletions(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4f8105f1..021dece0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1105,6 +1105,33 @@ func TestAutoCompactConfigApplyDefaultsPreservesExplicit(t *testing.T) { } } +func TestAutoCompactConfigApplyDefaultsNilReceiver(t *testing.T) { + t.Parallel() + + var cfg *AutoCompactConfig + cfg.ApplyDefaults(AutoCompactConfig{InputTokenThreshold: 50000}) +} + +func TestContextConfigApplyDefaultsPropagatesAutoCompactDefaults(t *testing.T) { + t.Parallel() + + cfg := ContextConfig{} + cfg.ApplyDefaults(ContextConfig{ + AutoCompact: AutoCompactConfig{ + InputTokenThreshold: 50000, + }, + Compact: CompactConfig{ + ManualStrategy: CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 10, + MaxSummaryChars: 1200, + }, + }) + + if cfg.AutoCompact.InputTokenThreshold != 50000 { + t.Fatalf("expected auto compact threshold=50000, got %d", cfg.AutoCompact.InputTokenThreshold) + } +} + func TestAutoCompactConfigValidateEnabledWithoutThreshold(t *testing.T) { t.Parallel() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 350f1327..97be69ed 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3038,6 +3038,278 @@ func TestCallProviderWithRetryReturnsCombinedForwardError(t *testing.T) { } } +func TestServiceRunPersistsAndRestoresTokenUsage(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{} + scripted := &scriptedProvider{} + scripted.chatFn = func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + usage := &providertypes.Usage{} + if scripted.callCount == 1 { + usage.InputTokens = 100 + usage.OutputTokens = 50 + } else { + usage.InputTokens = 25 + usage.OutputTokens = 10 + } + + select { + case events <- providertypes.NewTextDeltaStreamEvent("assistant reply"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", usage): + case <-ctx.Done(): + return ctx.Err() + } + return nil + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + + if err := service.Run(context.Background(), UserInput{ + RunID: "run-token-usage-first", + Content: "hello", + }); err != nil { + t.Fatalf("first Run() error = %v", err) + } + + firstSession := onlySession(t, store) + if firstSession.TokenInputTotal != 100 { + t.Fatalf("expected first session input total 100, got %d", firstSession.TokenInputTotal) + } + if firstSession.TokenOutputTotal != 50 { + t.Fatalf("expected first session output total 50, got %d", firstSession.TokenOutputTotal) + } + if len(builder.builds) != 1 { + t.Fatalf("expected 1 build after first run, got %d", len(builder.builds)) + } + if builder.builds[0].Metadata.SessionInputTokens != 0 { + t.Fatalf("expected first build to start from zero input tokens, got %d", builder.builds[0].Metadata.SessionInputTokens) + } + if builder.builds[0].Metadata.SessionOutputTokens != 0 { + t.Fatalf("expected first build to start from zero output tokens, got %d", builder.builds[0].Metadata.SessionOutputTokens) + } + + firstEvents := collectRuntimeEvents(service.Events()) + var firstTokenUsage TokenUsagePayload + foundFirstTokenUsage := false + for _, event := range firstEvents { + if event.Type != EventTokenUsage { + continue + } + payload, ok := event.Payload.(TokenUsagePayload) + if !ok { + t.Fatalf("expected TokenUsagePayload, got %T", event.Payload) + } + firstTokenUsage = payload + foundFirstTokenUsage = true + } + if !foundFirstTokenUsage { + t.Fatalf("expected token usage event in %+v", firstEvents) + } + if firstTokenUsage.InputTokens != 100 || firstTokenUsage.OutputTokens != 50 { + t.Fatalf("unexpected first token usage payload: %+v", firstTokenUsage) + } + if firstTokenUsage.SessionInputTokens != 100 || firstTokenUsage.SessionOutputTokens != 50 { + t.Fatalf("expected first session totals to be accumulated, got %+v", firstTokenUsage) + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: firstSession.ID, + RunID: "run-token-usage-second", + Content: "continue", + }); err != nil { + t.Fatalf("second Run() error = %v", err) + } + + secondSession, err := store.Load(context.Background(), firstSession.ID) + if err != nil { + t.Fatalf("load second session: %v", err) + } + if secondSession.TokenInputTotal != 125 { + t.Fatalf("expected second session input total 125, got %d", secondSession.TokenInputTotal) + } + if secondSession.TokenOutputTotal != 60 { + t.Fatalf("expected second session output total 60, got %d", secondSession.TokenOutputTotal) + } + if len(builder.builds) != 2 { + t.Fatalf("expected 2 builds after second run, got %d", len(builder.builds)) + } + if builder.builds[1].Metadata.SessionInputTokens != 100 { + t.Fatalf("expected restored session input tokens 100, got %d", builder.builds[1].Metadata.SessionInputTokens) + } + if builder.builds[1].Metadata.SessionOutputTokens != 50 { + t.Fatalf("expected restored session output tokens 50, got %d", builder.builds[1].Metadata.SessionOutputTokens) + } + + secondEvents := collectRuntimeEvents(service.Events()) + var secondTokenUsage TokenUsagePayload + foundSecondTokenUsage := false + for _, event := range secondEvents { + if event.Type != EventTokenUsage { + continue + } + payload, ok := event.Payload.(TokenUsagePayload) + if !ok { + t.Fatalf("expected TokenUsagePayload, got %T", event.Payload) + } + secondTokenUsage = payload + foundSecondTokenUsage = true + } + if !foundSecondTokenUsage { + t.Fatalf("expected token usage event in %+v", secondEvents) + } + if secondTokenUsage.InputTokens != 25 || secondTokenUsage.OutputTokens != 10 { + t.Fatalf("unexpected second token usage payload: %+v", secondTokenUsage) + } + if secondTokenUsage.SessionInputTokens != 125 || secondTokenUsage.SessionOutputTokens != 60 { + t.Fatalf("expected second session totals to be accumulated, got %+v", secondTokenUsage) + } +} + +func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.AutoCompact.Enabled = true + cfg.Context.AutoCompact.InputTokenThreshold = 100 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("auto-compact") + session.ID = "session-auto-compact" + session.TokenInputTotal = 100 + session.TokenOutputTotal = 40 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + tool := &stubTool{name: "filesystem_read_file", content: "file content"} + registry.Register(tool) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "auto compact prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), + ShouldAutoCompact: input.Metadata.SessionInputTokens >= input.Compact.AutoCompactThreshold, + }, nil + }, + } + scripted := &scriptedProvider{ + responses: []scriptedResponse{ + { + Message: providertypes.Message{ + ToolCalls: []providertypes.ToolCall{ + {ID: "call-1", Name: "filesystem_read_file", Arguments: `{"path":"main.go"}`}, + }, + }, + FinishReason: "tool_calls", + }, + { + Message: providertypes.Message{Content: "done"}, + FinishReason: "stop", + }, + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + compactRunner := &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleAssistant, Content: "latest answer"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 60, + AfterChars: 24, + SavedRatio: 0.6, + TriggerMode: string(contextcompact.ModeManual), + }, + TranscriptID: "transcript_auto", + TranscriptPath: "/tmp/auto.jsonl", + }, + } + service.compactRunner = compactRunner + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-auto-compact", + Content: "continue", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(compactRunner.calls) != 1 { + t.Fatalf("expected auto compact to run once, got %d", len(compactRunner.calls)) + } + if len(builder.builds) != 2 { + t.Fatalf("expected 2 build attempts, got %d", len(builder.builds)) + } + if builder.builds[0].Metadata.SessionInputTokens != 100 { + t.Fatalf("expected first build to see pre-compact tokens, got %d", builder.builds[0].Metadata.SessionInputTokens) + } + if builder.builds[0].Metadata.SessionOutputTokens != 40 { + t.Fatalf("expected first build to see pre-compact output tokens, got %d", builder.builds[0].Metadata.SessionOutputTokens) + } + if builder.builds[0].Compact.AutoCompactThreshold != 100 { + t.Fatalf("expected auto compact threshold 100, got %d", builder.builds[0].Compact.AutoCompactThreshold) + } + if builder.builds[1].Metadata.SessionInputTokens != 0 { + t.Fatalf("expected second build to see reset input tokens, got %d", builder.builds[1].Metadata.SessionInputTokens) + } + if builder.builds[1].Metadata.SessionOutputTokens != 0 { + t.Fatalf("expected second build to see reset output tokens, got %d", builder.builds[1].Metadata.SessionOutputTokens) + } + + if service.sessionInputTokens != 0 { + t.Fatalf("expected service input tokens to reset, got %d", service.sessionInputTokens) + } + if service.sessionOutputTokens != 0 { + t.Fatalf("expected service output tokens to reset, got %d", service.sessionOutputTokens) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load compacted session: %v", err) + } + if saved.TokenInputTotal != 0 { + t.Fatalf("expected persisted input tokens to reset, got %d", saved.TokenInputTotal) + } + if saved.TokenOutputTotal != 0 { + t.Fatalf("expected persisted output tokens to reset, got %d", saved.TokenOutputTotal) + } + if tool.callCount != 1 { + t.Fatalf("expected tool to execute once, got %d", tool.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactDone, + EventToolStart, + EventToolResult, + EventAgentDone, + }) + assertNoEventType(t, events, EventCompactError) +} + func TestRestoreSessionTokens(t *testing.T) { t.Parallel() @@ -3089,7 +3361,7 @@ func TestAutoCompactThresholdEnabled(t *testing.T) { cfg := config.Config{ Context: config.ContextConfig{ AutoCompact: config.AutoCompactConfig{ - Enabled: true, + Enabled: true, InputTokenThreshold: 50000, }, }, @@ -3110,7 +3382,7 @@ func TestAutoCompactThresholdDisabled(t *testing.T) { cfg := config.Config{ Context: config.ContextConfig{ AutoCompact: config.AutoCompactConfig{ - Enabled: false, + Enabled: false, InputTokenThreshold: 50000, }, }, @@ -3131,7 +3403,7 @@ func TestAutoCompactThresholdZeroValue(t *testing.T) { cfg := config.Config{ Context: config.ContextConfig{ AutoCompact: config.AutoCompactConfig{ - Enabled: true, + Enabled: true, InputTokenThreshold: 0, }, }, @@ -3147,7 +3419,7 @@ func TestTokenUsageRecordedOnMessageDone(t *testing.T) { t.Parallel() service := &Service{ - events: make(chan RuntimeEvent, 128), + events: make(chan RuntimeEvent, 128), sessionInputTokens: 0, sessionOutputTokens: 0, } @@ -3171,8 +3443,8 @@ func TestTokenUsageRecordedOnMessageDone(t *testing.T) { service.sessionInputTokens += payload.Usage.InputTokens service.sessionOutputTokens += payload.Usage.OutputTokens service.emit(context.Background(), EventTokenUsage, "test-run-id", "test-session-id", TokenUsagePayload{ - InputTokens: payload.Usage.InputTokens, - OutputTokens: payload.Usage.OutputTokens, + InputTokens: payload.Usage.InputTokens, + OutputTokens: payload.Usage.OutputTokens, SessionInputTokens: service.sessionInputTokens, SessionOutputTokens: service.sessionOutputTokens, }) From 067d529dc6909fdd18d75f3d44b9d27ef09ecbcf Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 11:50:07 +0800 Subject: [PATCH 12/54] =?UTF-8?q?test=EF=BC=9A=E5=86=8D=E6=AC=A1=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 211 +++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 021dece0..c5327347 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1224,3 +1224,214 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) { t.Fatalf("expected error to contain 'auto_compact', got %v", err) } } + +// ---- selection.go 工具函数覆盖 ---- + +func TestDescriptorFromRawModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw map[string]any + want ModelDescriptor + wantOK bool + }{ + { + name: "empty map returns false", + raw: map[string]any{}, + wantOK: false, + }, + { + name: "id from model field", + raw: map[string]any{ + "model": "gpt-4.1", + }, + want: ModelDescriptor{ID: "gpt-4.1", Name: "gpt-4.1"}, + wantOK: true, + }, + { + name: "full descriptor", + raw: map[string]any{ + "id": "gpt-4.1", + "display_name": "GPT-4.1", + "description": "desc", + "context_window": 128000, + "max_output_tokens": 16384, + }, + want: ModelDescriptor{ + ID: "gpt-4.1", + Name: "GPT-4.1", + Description: "desc", + ContextWindow: 128000, + MaxOutputTokens: 16384, + }, + wantOK: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := DescriptorFromRawModel(tt.raw) + if ok != tt.wantOK { + t.Fatalf("expected ok=%v, got ok=%v", tt.wantOK, ok) + } + if !tt.wantOK { + return + } + if got.ID != tt.want.ID { + t.Fatalf("expected ID=%q, got %q", tt.want.ID, got.ID) + } + if got.Name != tt.want.Name { + t.Fatalf("expected Name=%q, got %q", tt.want.Name, got.Name) + } + if got.Description != tt.want.Description { + t.Fatalf("expected Description=%q, got %q", tt.want.Description, got.Description) + } + if got.ContextWindow != tt.want.ContextWindow { + t.Fatalf("expected ContextWindow=%d, got %d", tt.want.ContextWindow, got.ContextWindow) + } + if got.MaxOutputTokens != tt.want.MaxOutputTokens { + t.Fatalf("expected MaxOutputTokens=%d, got %d", tt.want.MaxOutputTokens, got.MaxOutputTokens) + } + }) + } +} + +func TestMergeModelDescriptors(t *testing.T) { + t.Parallel() + + a := []ModelDescriptor{{ID: "m1", Name: "Model1"}} + b := []ModelDescriptor{{ID: "m2", Name: "Model2"}, {ID: "m1", Description: "fallback"}} + + merged := MergeModelDescriptors(a, b) + if len(merged) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(merged)) + } + + // m1 from first source should keep its Name, get description from second + var m1 *ModelDescriptor + for i := range merged { + if merged[i].ID == "m1" { + m1 = &merged[i] + break + } + } + if m1 == nil { + t.Fatalf("expected m1 to be present") + } + if m1.Name != "Model1" { + t.Fatalf("expected Name=Model1 from first source, got %q", m1.Name) + } + if m1.Description != "fallback" { + t.Fatalf("expected Description=fallback from second source, got %q", m1.Description) + } +} + +func TestDescriptorsFromIDs(t *testing.T) { + t.Parallel() + + result := DescriptorsFromIDs("gpt-4.1", "gpt-4.1-mini") + if len(result) != 2 { + t.Fatalf("expected 2 descriptors, got %d", len(result)) + } + if result[0].ID != "gpt-4.1" { + t.Fatalf("expected first ID=gpt-4.1, got %q", result[0].ID) + } + if result[1].Name != "gpt-4.1-mini" { + t.Fatalf("expected second Name=gpt-4.1-mini, got %q", result[1].Name) + } +} + +func TestFirstNonEmptyString(t *testing.T) { + t.Parallel() + + if got := firstNonEmptyString("", " ", "hello", "world"); got != "hello" { + t.Fatalf("expected hello, got %q", got) + } + if got := firstNonEmptyString("", " "); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestFirstPositiveInt(t *testing.T) { + t.Parallel() + + if got := firstPositiveInt(0, -1, 42, 100); got != 42 { + t.Fatalf("expected 42, got %d", got) + } + if got := firstPositiveInt(int32(5)); got != 5 { + t.Fatalf("expected 5, got %d", got) + } + if got := firstPositiveInt(int64(10)); got != 10 { + t.Fatalf("expected 10, got %d", got) + } + if got := firstPositiveInt(float64(3.14)); got != 3 { + t.Fatalf("expected 3, got %d", got) + } + if got := firstPositiveInt(0, -5); got != 0 { + t.Fatalf("expected 0 when none positive, got %d", got) + } +} + +func TestBoolMapValue(t *testing.T) { + t.Parallel() + + result := boolMapValue(map[string]any{"a": true, "b": "notbool", "c": false}) + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + if !result["a"] { + t.Fatalf("expected a=true") + } + if result["c"] { + t.Fatalf("expected c=false") + } + + if result := boolMapValue("not a map"); result != nil { + t.Fatalf("expected nil for non-map, got %v", result) + } +} + +func TestMergeStringBoolMaps(t *testing.T) { + t.Parallel() + + primary := map[string]bool{"a": true} + secondary := map[string]bool{"b": false, "a": false} + + result := mergeStringBoolMaps(primary, secondary) + if !result["a"] { + t.Fatalf("expected a=true (primary should win)") + } + if result["b"] { + t.Fatalf("expected b=false") + } + + if result := mergeStringBoolMaps(nil, nil); result != nil { + t.Fatalf("expected nil for both empty") + } +} + +func TestMergeModelDescriptorFallback(t *testing.T) { + t.Parallel() + + primary := ModelDescriptor{ID: "m1"} + secondary := ModelDescriptor{ + Name: "Fallback", + Description: "desc", + ContextWindow: 8000, + MaxOutputTokens: 4096, + } + + result := mergeModelDescriptor(primary, secondary) + if result.Name != "Fallback" { + t.Fatalf("expected Name=Fallback from secondary, got %q", result.Name) + } + if result.ContextWindow != 8000 { + t.Fatalf("expected ContextWindow=8000 from secondary, got %d", result.ContextWindow) + } + if result.MaxOutputTokens != 4096 { + t.Fatalf("expected MaxOutputTokens=4096 from secondary, got %d", result.MaxOutputTokens) + } +} From de2cbec9d7b4c5e101f4f3d30c53778482fe0ccb Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 11:50:50 +0800 Subject: [PATCH 13/54] =?UTF-8?q?test:=E5=BC=82=E5=B8=B8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=9B=B4=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c5327347..d2d37542 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1332,7 +1332,7 @@ func TestMergeModelDescriptors(t *testing.T) { func TestDescriptorsFromIDs(t *testing.T) { t.Parallel() - result := DescriptorsFromIDs("gpt-4.1", "gpt-4.1-mini") + result := DescriptorsFromIDs([]string{"gpt-4.1", "gpt-4.1-mini"}) if len(result) != 2 { t.Fatalf("expected 2 descriptors, got %d", len(result)) } From dd1424e3fbceb030d4934f73e5960eebfa5e6b6b Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 8 Apr 2026 14:50:30 +0800 Subject: [PATCH 14/54] test(tui/core): add missing tests for status, utils, workspace and fix coverage --- internal/tui/core/status/snapshot_test.go | 188 +++++++++++++++++++ internal/tui/core/utils/view_helpers_test.go | 182 ++++++++++++++++++ internal/tui/core/workspace/resolver_test.go | 84 +++++++++ 3 files changed, 454 insertions(+) create mode 100644 internal/tui/core/status/snapshot_test.go create mode 100644 internal/tui/core/utils/view_helpers_test.go create mode 100644 internal/tui/core/workspace/resolver_test.go diff --git a/internal/tui/core/status/snapshot_test.go b/internal/tui/core/status/snapshot_test.go new file mode 100644 index 00000000..abc3c60f --- /dev/null +++ b/internal/tui/core/status/snapshot_test.go @@ -0,0 +1,188 @@ +package status + +import ( + "testing" + + tuistate "neo-code/internal/tui/state" +) + +func TestBuildFromUIState(t *testing.T) { + state := tuistate.UIState{ + ActiveSessionID: "session-1", + ActiveSessionTitle: "Test Session", + ActiveRunID: "run-1", + IsAgentRunning: true, + IsCompacting: false, + CurrentProvider: "openai", + CurrentModel: "gpt-4", + CurrentWorkdir: "/home/user", + CurrentTool: "bash", + ToolStates: []tuistate.ToolState{}, + TokenUsage: tuistate.TokenUsageState{ + RunTotalTokens: 100, + SessionTotalTokens: 500, + }, + ExecutionError: "", + } + + snapshot := BuildFromUIState(state, 10, "composer", "none") + + if snapshot.ActiveSessionID != "session-1" { + t.Errorf("expected session ID session-1, got %s", snapshot.ActiveSessionID) + } + if snapshot.ActiveSessionTitle != "Test Session" { + t.Errorf("expected session title 'Test Session', got %s", snapshot.ActiveSessionTitle) + } + if snapshot.IsAgentRunning != true { + t.Errorf("expected IsAgentRunning true, got %v", snapshot.IsAgentRunning) + } + if snapshot.CurrentProvider != "openai" { + t.Errorf("expected provider openai, got %s", snapshot.CurrentProvider) + } + if snapshot.MessageCount != 10 { + t.Errorf("expected message count 10, got %d", snapshot.MessageCount) + } + if snapshot.ToolStateCount != 0 { + t.Errorf("expected tool state count 0, got %d", snapshot.ToolStateCount) + } +} + +func TestBuildFromUIStateWithEmptyValues(t *testing.T) { + state := tuistate.UIState{ + ActiveSessionID: "", + ActiveSessionTitle: "", + ActiveRunID: "", + IsAgentRunning: false, + IsCompacting: false, + CurrentProvider: "", + CurrentModel: "", + CurrentWorkdir: "", + CurrentTool: "", + ToolStates: nil, + TokenUsage: tuistate.TokenUsageState{}, + ExecutionError: "", + } + + snapshot := BuildFromUIState(state, 0, "", "") + + if snapshot.ActiveSessionID != "" { + t.Errorf("expected empty session ID, got %s", snapshot.ActiveSessionID) + } + if snapshot.CurrentTool != "" { + t.Errorf("expected empty current tool, got %s", snapshot.CurrentTool) + } +} + +func TestFormat(t *testing.T) { + snapshot := Snapshot{ + ActiveSessionID: "session-123", + ActiveSessionTitle: "My Session", + ActiveRunID: "run-456", + IsAgentRunning: true, + IsCompacting: false, + CurrentProvider: "openai", + CurrentModel: "gpt-4", + CurrentWorkdir: "/home/user", + CurrentTool: "bash", + ToolStateCount: 3, + RunTotalTokens: 150, + SessionTotalTokens: 1000, + ExecutionError: "", + FocusLabel: "composer", + PickerLabel: "none", + MessageCount: 5, + } + + output := Format(snapshot, "Draft Session") + + if !contains(output, "Session: My Session") { + t.Errorf("expected output to contain 'Session: My Session', got %s", output) + } + if !contains(output, "Running: yes") { + t.Errorf("expected output to contain 'Running: yes', got %s", output) + } + if !contains(output, "Provider: openai") { + t.Errorf("expected output to contain 'Provider: openai', got %s", output) + } + if !contains(output, "Current Tool: bash") { + t.Errorf("expected output to contain 'Current Tool: bash', got %s", output) + } +} + +func TestFormatWithEmptySession(t *testing.T) { + snapshot := Snapshot{ + ActiveSessionID: "", + ActiveSessionTitle: "", + ActiveRunID: "", + IsAgentRunning: false, + IsCompacting: false, + CurrentProvider: "openai", + CurrentModel: "gpt-4", + CurrentWorkdir: "/home/user", + CurrentTool: "", + ToolStateCount: 0, + RunTotalTokens: 0, + SessionTotalTokens: 0, + ExecutionError: "some error", + FocusLabel: "composer", + PickerLabel: "none", + MessageCount: 0, + } + + output := Format(snapshot, "Default Draft") + + if !contains(output, "Session: Default Draft") { + t.Errorf("expected output to contain 'Session: Default Draft', got %s", output) + } + if !contains(output, "Session ID: ") { + t.Errorf("expected output to contain 'Session ID: ', got %s", output) + } + if !contains(output, "Running: no") { + t.Errorf("expected output to contain 'Running: no', got %s", output) + } + if !contains(output, "Current Tool: ") { + t.Errorf("expected output to contain 'Current Tool: ', got %s", output) + } + if !contains(output, "Error: some error") { + t.Errorf("expected output to contain 'Error: some error', got %s", output) + } +} + +func TestFormatWithCompacting(t *testing.T) { + snapshot := Snapshot{ + ActiveSessionID: "session-1", + ActiveSessionTitle: "Test", + IsAgentRunning: false, + IsCompacting: true, + CurrentProvider: "openai", + CurrentModel: "gpt-4", + CurrentWorkdir: "/home", + CurrentTool: "", + ToolStateCount: 0, + RunTotalTokens: 0, + SessionTotalTokens: 0, + ExecutionError: "", + FocusLabel: "", + PickerLabel: "", + MessageCount: 0, + } + + output := Format(snapshot, "") + + if !contains(output, "Running: yes") { + t.Errorf("expected output to contain 'Running: yes' when compacting, got %s", output) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr)) +} + +func containsAt(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go new file mode 100644 index 00000000..9a37e084 --- /dev/null +++ b/internal/tui/core/utils/view_helpers_test.go @@ -0,0 +1,182 @@ +package utils + +import ( + "testing" + + tuistate "neo-code/internal/tui/state" +) + +func TestPickerLabelFromMode(t *testing.T) { + tests := []struct { + mode tuistate.PickerMode + want string + }{ + {tuistate.PickerProvider, "provider"}, + {tuistate.PickerModel, "model"}, + {tuistate.PickerFile, "file"}, + {tuistate.PickerMode(999), "none"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := PickerLabelFromMode(tt.mode); got != tt.want { + t.Errorf("PickerLabelFromMode(%v) = %v, want %v", tt.mode, got, tt.want) + } + }) + } +} + +func TestRequestedWorkdirForRun(t *testing.T) { + tests := []struct { + name string + activeSessionID string + currentWorkdir string + want string + }{ + {"empty session returns current", "", "/home/user", "/home/user"}, + {"active session returns empty", "session-1", "/home/user", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RequestedWorkdirForRun(tt.activeSessionID, tt.currentWorkdir); got != tt.want { + t.Errorf("RequestedWorkdirForRun() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsBusy(t *testing.T) { + tests := []struct { + name string + isAgentRunning bool + isCompacting bool + want bool + }{ + {"both false", false, false, false}, + {"agent running", true, false, true}, + {"compacting", false, true, true}, + {"both true", true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsBusy(tt.isAgentRunning, tt.isCompacting); got != tt.want { + t.Errorf("IsBusy() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFocusLabelFromPanel(t *testing.T) { + tests := []struct { + name string + focus tuistate.Panel + want string + }{ + {"sessions", tuistate.PanelSessions, "sessions"}, + {"transcript", tuistate.PanelTranscript, "transcript"}, + {"activity", tuistate.PanelActivity, "activity"}, + {"input falls to default", tuistate.PanelInput, "composer"}, + {"unknown", tuistate.Panel(999), "composer"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FocusLabelFromPanel(tt.focus, "sessions", "transcript", "activity", "composer"); got != tt.want { + t.Errorf("FocusLabelFromPanel() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTrimRunes(t *testing.T) { + tests := []struct { + name string + text string + limit int + want string + }{ + {"short text", "hello", 10, "hello"}, + {"exact limit", "hello", 5, "hello"}, + {"long text", "hello world", 8, "hello..."}, + {"limit too small", "hello", 2, "hello"}, + {"limit 3", "hello", 4, "h..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TrimRunes(tt.text, tt.limit); got != tt.want { + t.Errorf("TrimRunes(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want) + } + }) + } +} + +func TestTrimMiddle(t *testing.T) { + tests := []struct { + name string + text string + limit int + want string + }{ + {"short text", "hello", 10, "hello"}, + {"exact limit", "hello", 5, "hello"}, + {"long text", "abcdefghij", 8, "ab...hij"}, + {"limit too small", "hello", 5, "hello"}, + {"limit 6", "abcdefghij", 7, "ab...ij"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TrimMiddle(tt.text, tt.limit); got != tt.want { + t.Errorf("TrimMiddle(%q, %d) = %q, want %q", tt.text, tt.limit, got, tt.want) + } + }) + } +} + +func TestFallback(t *testing.T) { + tests := []struct { + name string + value string + fallbackValue string + want string + }{ + {"empty value", "", "default", "default"}, + {"whitespace only", " ", "default", "default"}, + {"normal value", "actual", "default", "actual"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Fallback(tt.value, tt.fallbackValue); got != tt.want { + t.Errorf("Fallback(%q, %q) = %q, want %q", tt.value, tt.fallbackValue, got, tt.want) + } + }) + } +} + +func TestClamp(t *testing.T) { + tests := []struct { + name string + value int + minValue int + maxValue int + want int + }{ + {"within range", 5, 0, 10, 5}, + {"below min", -1, 0, 10, 0}, + {"above max", 15, 0, 10, 10}, + {"at min", 0, 0, 10, 0}, + {"at max", 10, 0, 10, 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Clamp(tt.value, tt.minValue, tt.maxValue); got != tt.want { + t.Errorf("Clamp(%d, %d, %d) = %d, want %d", tt.value, tt.minValue, tt.maxValue, got, tt.want) + } + }) + } +} diff --git a/internal/tui/core/workspace/resolver_test.go b/internal/tui/core/workspace/resolver_test.go new file mode 100644 index 00000000..0d1d9621 --- /dev/null +++ b/internal/tui/core/workspace/resolver_test.go @@ -0,0 +1,84 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveWorkspacePath(t *testing.T) { + base := t.TempDir() + subdir := filepath.Join(base, "sub") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + tests := []struct { + name string + base string + requested string + check func(t *testing.T, got string) + wantErr bool + }{ + {"resolve absolute path", base, subdir, func(t *testing.T, got string) { + if got != subdir { + t.Errorf("expected %v, got %v", subdir, got) + } + }, false}, + {"resolve relative path", base, "sub", func(t *testing.T, got string) { + if got != subdir { + t.Errorf("expected %v, got %v", subdir, got) + } + }, false}, + {"empty base uses cwd", "", ".", func(t *testing.T, got string) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if got != cwd { + t.Errorf("expected %v, got %v", cwd, got) + } + }, false}, + {"empty requested uses dot", base, "", func(t *testing.T, got string) { + if got != base { + t.Errorf("expected %v, got %v", base, got) + } + }, false}, + {"non-existent path", base, "nonexistent", func(t *testing.T, got string) {}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveWorkspacePath(tt.base, tt.requested) + if (err != nil) != tt.wantErr { + t.Errorf("ResolveWorkspacePath() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && tt.check != nil { + tt.check(t, got) + } + }) + } +} + +func TestSelectSessionWorkdir(t *testing.T) { + tests := []struct { + name string + sessionWorkdir string + defaultWorkdir string + want string + }{ + {"prefer session workdir", "/session", "/default", "/session"}, + {"fallback to default", "", "/default", "/default"}, + {"both empty", "", "", ""}, + {"session with whitespace", " /session ", "/default", "/session"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SelectSessionWorkdir(tt.sessionWorkdir, tt.defaultWorkdir); got != tt.want { + t.Errorf("SelectSessionWorkdir() = %v, want %v", got, tt.want) + } + }) + } +} From 6aa1d575bb353b8b337bbd75004e4ac2af46466e Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 8 Apr 2026 15:10:09 +0800 Subject: [PATCH 15/54] fix: resolve conflicts with main and fix build errors --- internal/tui/bootstrap/builder_test.go | 236 -- internal/tui/core/app/app.go | 4 +- internal/tui/core/app/command_menu.go | 5 +- internal/tui/core/app/command_menu_test.go | 163 - internal/tui/core/app/commands_test.go | 345 --- internal/tui/core/app/copy_code_test.go | 271 -- internal/tui/core/app/input_features_test.go | 252 -- internal/tui/core/app/update.go | 16 +- internal/tui/core/app/update_test.go | 2882 ------------------ internal/tui/core/app/view.go | 5 +- internal/tui/core/commands/workspace.go | 4 +- internal/tui/core/commands/workspace_test.go | 10 +- 12 files changed, 21 insertions(+), 4172 deletions(-) delete mode 100644 internal/tui/bootstrap/builder_test.go delete mode 100644 internal/tui/core/app/command_menu_test.go delete mode 100644 internal/tui/core/app/commands_test.go delete mode 100644 internal/tui/core/app/copy_code_test.go delete mode 100644 internal/tui/core/app/input_features_test.go delete mode 100644 internal/tui/core/app/update_test.go diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go deleted file mode 100644 index 0957423f..00000000 --- a/internal/tui/bootstrap/builder_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package bootstrap - -import ( - "context" - "errors" - "strings" - "testing" - - "neo-code/internal/config" - agentruntime "neo-code/internal/runtime" -) - -type stubRuntime struct { - events chan agentruntime.RuntimeEvent -} - -func newStubRuntime() *stubRuntime { - return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} -} - -func (s *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { - return nil -} - -func (s *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { - return agentruntime.CompactResult{}, nil -} - -func (s *stubRuntime) CancelActiveRun() bool { - return false -} - -func (s *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { - return s.events -} - -func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentruntime.SessionSummary, error) { - return nil, nil -} - -func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentruntime.Session, error) { - return agentruntime.Session{}, nil -} - -func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { - return agentruntime.Session{}, nil -} - -type stubProviderService struct{} - -func (s *stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { - return nil, nil -} - -func (s *stubProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, nil -} - -func (s *stubProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { - return nil, nil -} - -func (s *stubProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { - return nil, nil -} - -func (s *stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { - return config.ProviderSelection{}, nil -} - -type spyFactory struct { - runtimeOut agentruntime.Runtime - providerOut ProviderService - err error - modeSeen Mode - runtimeHits int - providerHits int -} - -func (s *spyFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { - s.modeSeen = mode - s.runtimeHits++ - if s.err != nil { - return nil, s.err - } - if s.runtimeOut != nil { - return s.runtimeOut, nil - } - return current, nil -} - -func (s *spyFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { - s.modeSeen = mode - s.providerHits++ - if s.err != nil { - return nil, s.err - } - if s.providerOut != nil { - return s.providerOut, nil - } - return current, nil -} - -func TestBuildValidatesDependencies(t *testing.T) { - manager := newTestConfigManager(t) - runtimeSvc := newStubRuntime() - providerSvc := &stubProviderService{} - - cases := []struct { - name string - options Options - wantErr string - }{ - { - name: "nil manager", - options: Options{ - Runtime: runtimeSvc, - ProviderService: providerSvc, - }, - wantErr: "config manager is nil", - }, - { - name: "nil runtime", - options: Options{ - ConfigManager: manager, - ProviderService: providerSvc, - }, - wantErr: "runtime is nil", - }, - { - name: "nil provider", - options: Options{ - ConfigManager: manager, - Runtime: runtimeSvc, - }, - wantErr: "provider service is nil", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - _, err := Build(tc.options) - if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("Build() error = %v, want substring %q", err, tc.wantErr) - } - }) - } -} - -func TestBuildUsesManagerSnapshotWhenConfigNil(t *testing.T) { - manager := newTestConfigManager(t) - runtimeSvc := newStubRuntime() - providerSvc := &stubProviderService{} - - container, err := Build(Options{ - ConfigManager: manager, - Runtime: runtimeSvc, - ProviderService: providerSvc, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - - if container.Mode != ModeLive { - t.Fatalf("expected default mode %q, got %q", ModeLive, container.Mode) - } - if container.Config.SelectedProvider != manager.Get().SelectedProvider { - t.Fatalf("expected config snapshot from manager, got %+v", container.Config) - } -} - -func TestBuildUsesConfigSnapshotAndFactory(t *testing.T) { - manager := newTestConfigManager(t) - runtimeSvc := newStubRuntime() - providerSvc := &stubProviderService{} - - override := manager.Get() - override.CurrentModel = "custom-model" - - altRuntime := newStubRuntime() - altProvider := &stubProviderService{} - factory := &spyFactory{ - runtimeOut: altRuntime, - providerOut: altProvider, - } - - container, err := Build(Options{ - Config: &override, - ConfigManager: manager, - Runtime: runtimeSvc, - ProviderService: providerSvc, - Mode: ModeMock, - Factory: factory, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - - override.CurrentModel = "mutated-after-build" - if container.Config.CurrentModel != "custom-model" { - t.Fatalf("expected config clone snapshot, got %q", container.Config.CurrentModel) - } - if container.Runtime != altRuntime || container.ProviderService != altProvider { - t.Fatalf("expected factory outputs to be injected") - } - if factory.modeSeen != ModeMock || factory.runtimeHits != 1 || factory.providerHits != 1 { - t.Fatalf("factory was not invoked as expected: %+v", factory) - } -} - -func TestBuildFactoryError(t *testing.T) { - manager := newTestConfigManager(t) - factory := &spyFactory{err: errors.New("boom")} - - _, err := Build(Options{ - ConfigManager: manager, - Runtime: newStubRuntime(), - ProviderService: &stubProviderService{}, - Factory: factory, - }) - if err == nil || !strings.Contains(err.Error(), "build runtime") { - t.Fatalf("Build() error = %v, want runtime factory error", err) - } -} - -// newTestConfigManager 创建隔离配置目录,返回可用于 bootstrap 单测的配置管理器。 -func newTestConfigManager(t *testing.T) *config.Manager { - t.Helper() - - loader := config.NewLoader(t.TempDir(), config.DefaultConfig()) - manager := config.NewManager(loader) - if _, err := manager.Load(context.Background()); err != nil { - t.Fatalf("manager.Load() error = %v", err) - } - return manager -} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 4a52095e..f290ce30 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/config" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" tuibootstrap "neo-code/internal/tui/bootstrap" tuistate "neo-code/internal/tui/state" @@ -91,7 +91,7 @@ type appRuntimeState struct { inputBurstStart time.Time inputBurstCount int pasteMode bool - activeMessages []provider.Message + activeMessages []providertypes.Message activities []tuistate.ActivityEntry fileCandidates []string modelRefreshID string diff --git a/internal/tui/core/app/command_menu.go b/internal/tui/core/app/command_menu.go index 2b5d03b0..9e593b79 100644 --- a/internal/tui/core/app/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -9,7 +9,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" tuicomponents "neo-code/internal/tui/components" tuiutils "neo-code/internal/tui/core/utils" tuistate "neo-code/internal/tui/state" @@ -82,7 +82,7 @@ func (d commandMenuDelegate) Render(w io.Writer, m list.Model, index int, item l } type sessionItem struct { - Summary agentruntime.SessionSummary + Summary agentsession.Summary Active bool } @@ -337,4 +337,3 @@ func (a *App) openFileBrowser() { a.input.Blur() a.applyComponentLayout(true) } - diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go deleted file mode 100644 index dfdfcfe3..00000000 --- a/internal/tui/core/app/command_menu_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" -) - -func TestCommandMenuItemAndDelegateHelpers(t *testing.T) { - item := commandMenuItem{ - title: "/status", - description: "show status", - filter: " custom FILTER ", - } - if item.Title() != "/status" || item.Description() != "show status" { - t.Fatalf("unexpected title/description: %+v", item) - } - if got := item.FilterValue(); got != "custom filter" { - t.Fatalf("expected trimmed lowercase filter, got %q", got) - } - - item.filter = "" - if got := item.FilterValue(); got != "/status show status" { - t.Fatalf("expected fallback filter text, got %q", got) - } - - delegate := commandMenuDelegate{styles: newStyles()} - if delegate.Height() != 1 || delegate.Spacing() != 0 { - t.Fatalf("unexpected delegate size: height=%d spacing=%d", delegate.Height(), delegate.Spacing()) - } - if cmd := delegate.Update(nil, nil); cmd != nil { - t.Fatalf("expected nil update cmd, got %v", cmd) - } -} - -func TestCommandMenuBehaviorPaths(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.state.CurrentWorkdir = t.TempDir() - app.fileCandidates = []string{"internal/tui/update.go", "internal/tui/view.go"} - app.input.SetValue("inspect @") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - if !app.commandMenuHasSuggestions() || app.commandMenuMeta.Title != fileMenuTitle { - t.Fatalf("expected file menu suggestions, meta=%+v items=%d", app.commandMenuMeta, len(app.commandMenu.Items())) - } - if !app.applySelectedCommandSuggestion() { - t.Fatalf("expected browse suggestion to open file browser") - } - if app.state.ActivePicker != pickerFile { - t.Fatalf("expected pickerFile after browse suggestion, got %v", app.state.ActivePicker) - } - - app.closePicker() - app.input.SetValue("/") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - if len(app.commandMenu.Items()) == 0 { - t.Fatalf("expected slash command menu items") - } - if len(app.commandMenu.Items()) > 1 { - selectedTitle := app.commandMenu.Items()[1].(commandMenuItem).title - app.commandMenu.Select(1) - app.refreshCommandMenu() - currentTitle := app.commandMenu.SelectedItem().(commandMenuItem).title - if currentTitle != selectedTitle { - t.Fatalf("expected selection retained, got %q want %q", currentTitle, selectedTitle) - } - } - - if _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyDown}); !handled { - t.Fatalf("expected key down to be handled") - } - if _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}); handled { - t.Fatalf("expected rune key not to be handled by command menu") - } - - app.state.ActivePicker = pickerModel - app.refreshCommandMenu() - if app.commandMenuHasSuggestions() || strings.TrimSpace(app.commandMenuMeta.Title) != "" { - t.Fatalf("expected menu cleared while picker active") - } - app.state.ActivePicker = pickerNone - - app.commandMenu.SetItems([]list.Item{selectionItem{id: "x", name: "not command item"}}) - app.commandMenu.Select(0) - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected false when selected item type is invalid") - } - - app.input.SetValue("abc") - app.state.InputText = app.input.Value() - app.commandMenu.SetItems([]list.Item{commandMenuItem{ - replacement: "ignored", - useReplaceRange: true, - replaceStart: -1, - replaceEnd: 1, - }}) - app.commandMenu.Select(0) - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected false for invalid replace range") - } - - app.commandMenu.SetItems([]list.Item{commandMenuItem{replacement: "/status"}}) - app.commandMenu.Select(0) - if !app.applySelectedCommandSuggestion() || app.state.InputText != "/status" { - t.Fatalf("expected direct replacement, got %q", app.state.InputText) - } - app.commandMenu.SetItems([]list.Item{commandMenuItem{replacement: "/status"}}) - app.commandMenu.Select(0) - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected no-op replacement to return false") - } -} - -func TestBuildCommandMenuItemsVariants(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.state.CurrentWorkdir = t.TempDir() - - items, meta := app.buildCommandMenuItems("&", 80) - if len(items) != 1 || meta.Title != shellMenuTitle || !items[0].useReplaceRange { - t.Fatalf("expected bare workspace command helper, got meta=%+v items=%+v", meta, items) - } - - items, meta = app.buildCommandMenuItems("& git status", 80) - if len(items) != 1 || meta.Title != shellMenuTitle || items[0].useReplaceRange { - t.Fatalf("expected concrete workspace command helper, got meta=%+v items=%+v", meta, items) - } - - items, meta = app.buildCommandMenuItems("not-a-command", 80) - if len(items) != 0 || strings.TrimSpace(meta.Title) != "" { - t.Fatalf("expected no suggestions for plain input, got meta=%+v items=%+v", meta, items) - } - - app.fileCandidates = []string{"internal/tui/update.go"} - fileItems := app.fileMenuSuggestions("inspect @") - if len(fileItems) == 0 || !fileItems[0].openFileBrowser { - t.Fatalf("expected browse item for empty file query, got %+v", fileItems) - } - - app.fileCandidates = nil - if suggestions := app.fileMenuSuggestions("inspect @missing"); len(suggestions) != 0 { - t.Fatalf("expected empty suggestions when query misses all candidates, got %+v", suggestions) - } - - app.state.CurrentWorkdir = "" - app.openFileBrowser() - if app.state.ActivePicker != pickerNone { - t.Fatalf("expected openFileBrowser to no-op when workdir is empty") - } -} diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go deleted file mode 100644 index 1bbb7cc0..00000000 --- a/internal/tui/core/app/commands_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package tui - -import ( - "context" - "encoding/binary" - "strings" - "testing" - "unicode/utf16" - - "neo-code/internal/config" - tuistatus "neo-code/internal/tui/core/status" -) - -func TestExecuteLocalCommand(t *testing.T) { - tests := []struct { - name string - command string - expectErr string - assert func(t *testing.T, manager *config.Manager, notice string) - }{ - { - name: "help lists supported slash commands", - command: "/help", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - for _, want := range []string{ - slashUsageHelp, - slashUsageClear, - slashUsageStatus, - slashUsageWorkdir, - slashUsageProvider, - slashUsageModel, - slashUsageExit, - } { - if !strings.Contains(notice, want) { - t.Fatalf("expected help output to contain %q, got %q", want, notice) - } - } - for _, unwanted := range []string{"/run", "/git", "/file", "/plan", "/undo", "/setting", "/set"} { - if strings.Contains(notice, unwanted) { - t.Fatalf("expected help output not to contain %q, got %q", unwanted, notice) - } - } - }, - }, - { - name: "status includes current tui snapshot", - command: "/status", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - for _, want := range []string{ - "Status:", - "Session: Draft", - "Running: no", - "Provider: " + manager.Get().SelectedProvider, - "Model: " + manager.Get().CurrentModel, - "Focus: " + focusLabelComposer, - "Picker: none", - "Messages: 0", - } { - if !strings.Contains(notice, want) { - t.Fatalf("expected status output to contain %q, got %q", want, notice) - } - } - }, - }, - { - name: "provider switches current provider when arg is provided", - command: "/provider gemini", - assert: func(t *testing.T, manager *config.Manager, notice string) { - t.Helper() - cfg := manager.Get() - if cfg.SelectedProvider != config.GeminiName { - t.Fatalf("expected selected provider gemini, got %q", cfg.SelectedProvider) - } - if !strings.Contains(notice, "Current provider switched") { - t.Fatalf("expected provider switch notice, got %q", notice) - } - }, - }, - { - name: "provider without arg returns usage", - command: "/provider", - expectErr: "usage:", - }, - { - name: "unknown command is rejected", - command: "/unknown", - expectErr: `unknown command "/unknown"`, - }, - { - 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) - providerSvc := newTestProviderService(t, manager) - notice, err := executeLocalCommand(context.Background(), manager, providerSvc, defaultTestStatusSnapshot(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) - } - }) - } -} - -func TestMatchingSlashCommands(t *testing.T) { - t.Parallel() - - app := App{} - tests := []struct { - name string - input string - expectCount int - expectUsage string - }{ - { - name: "non slash input returns no suggestions", - input: "hello", - expectCount: 0, - }, - { - name: "bare slash returns supported commands only", - input: "/", - expectCount: len(builtinSlashCommands), - expectUsage: slashUsageHelp, - }, - { - name: "prefix narrows suggestions", - input: "/mo", - expectCount: 1, - expectUsage: slashUsageModel, - }, - { - name: "complete slash command hides suggestions", - input: "/status", - expectCount: 0, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - got := app.matchingSlashCommands(tt.input) - if len(got) != tt.expectCount { - t.Fatalf("expected %d suggestions, got %d", tt.expectCount, len(got)) - } - if tt.expectUsage != "" && (len(got) == 0 || got[0].Command.Usage != tt.expectUsage && !containsUsage(got, tt.expectUsage)) { - t.Fatalf("expected suggestions to contain %q, got %+v", tt.expectUsage, got) - } - }) - } -} - -func containsUsage(suggestions []commandSuggestion, usage string) bool { - for _, suggestion := range suggestions { - if suggestion.Command.Usage == usage { - return true - } - } - return false -} - -func defaultTestStatusSnapshot(manager *config.Manager) tuistatus.Snapshot { - cfg := manager.Get() - return tuistatus.Snapshot{ - ActiveSessionTitle: draftSessionTitle, - CurrentProvider: cfg.SelectedProvider, - CurrentModel: cfg.CurrentModel, - CurrentWorkdir: cfg.Workdir, - FocusLabel: focusLabelComposer, - PickerLabel: "none", - } -} - -func TestCommandHelperFunctions(t *testing.T) { - t.Run("workspace slash parser supports aliases", func(t *testing.T) { - if !isWorkspaceSlashCommand("/cwd ./tmp") { - t.Fatalf("expected /cwd to be recognized") - } - if isWorkspaceSlashCommand("/status") { - t.Fatalf("expected non-workspace slash command to be ignored") - } - args, err := parseWorkspaceSlashCommand("/cwd") - if err != nil || args != "" { - t.Fatalf("expected empty args for /cwd, got %q / %v", args, err) - } - args, err = parseWorkspaceSlashCommand("/cwd ./tmp") - if err != nil || args != "./tmp" { - t.Fatalf("expected ./tmp, got %q / %v", args, err) - } - if _, err := parseWorkspaceSlashCommand("/workspace ./tmp"); err == nil { - t.Fatalf("expected /workspace to be rejected") - } - if _, err := parseWorkspaceSlashCommand("/status"); err == nil { - t.Fatalf("expected unknown slash command to return error") - } - }) - - t.Run("splitFirstWord handles empty and remainder", func(t *testing.T) { - if first, rest := splitFirstWord(" "); first != "" || rest != "" { - t.Fatalf("expected empty split, got %q / %q", first, rest) - } - if first, rest := splitFirstWord("alpha beta gamma"); first != "alpha" || rest != "beta gamma" { - t.Fatalf("unexpected split result %q / %q", first, rest) - } - }) - - t.Run("powershell shell args force utf8 output", func(t *testing.T) { - got := shellArgs("powershell", "git status") - if len(got) != 4 || got[0] != "powershell" { - t.Fatalf("unexpected powershell args %+v", got) - } - if !strings.Contains(got[3], "65001") || !strings.Contains(got[3], "git status") { - t.Fatalf("expected utf8 powershell wrapper, got %q", got[3]) - } - }) - - t.Run("sanitize workspace output strips ansi and invalid bytes", func(t *testing.T) { - raw := "\x1b[31mfatal\x1b[0m:\xff bad\r\nnext\x00line" - got := sanitizeWorkspaceOutput([]byte(raw)) - if strings.Contains(got, "\x1b") || strings.Contains(got, "\x00") { - t.Fatalf("expected control chars to be removed, got %q", got) - } - for _, want := range []string{"fatal", "bad", "nextline"} { - if !strings.Contains(strings.ReplaceAll(got, "\n", ""), want) { - t.Fatalf("expected sanitized output to contain %q, got %q", want, got) - } - } - }) - - t.Run("sanitize workspace output decodes utf16le launcher errors", func(t *testing.T) { - text := "This app needs WSL installed.\r\nRun wsl.exe --list --online" - encoded := utf16.Encode([]rune(text)) - raw := make([]byte, 0, len(encoded)*2) - for _, word := range encoded { - buf := make([]byte, 2) - binary.LittleEndian.PutUint16(buf, word) - raw = append(raw, buf...) - } - - got := sanitizeWorkspaceOutput(raw) - for _, want := range []string{"This app needs WSL installed.", "Run wsl.exe --list --online"} { - if !strings.Contains(got, want) { - t.Fatalf("expected decoded output to contain %q, got %q", want, got) - } - } - }) - - t.Run("decode workspace output prefers utf16 when chinese prefix has no zero bytes", func(t *testing.T) { - text := "Access denied.\r\nError code: Bash/Service/CreateInstance/E_ACCESSDENIED" - encoded := utf16.Encode([]rune(text)) - raw := make([]byte, 0, len(encoded)*2) - for _, word := range encoded { - buf := make([]byte, 2) - binary.LittleEndian.PutUint16(buf, word) - raw = append(raw, buf...) - } - - got := decodeWorkspaceOutput(raw) - for _, want := range []string{"Access denied.", "Error code", "E_ACCESSDENIED"} { - if !strings.Contains(got, want) { - t.Fatalf("expected decoded utf16 output to contain %q, got %q", want, got) - } - } - }) -} - -func TestLocalCommandWrappers(t *testing.T) { - manager := newTestConfigManager(t) - providerSvc := newTestProviderService(t, manager) - - msg := runLocalCommand(manager, providerSvc, defaultTestStatusSnapshot(manager), "/help")() - result, ok := msg.(localCommandResultMsg) - if !ok || result.Err != nil || !strings.Contains(result.Notice, "Available slash commands") { - t.Fatalf("expected help command result, got %+v", msg) - } - - msg = runProviderSelection(providerSvc, "missing-provider")() - result, ok = msg.(localCommandResultMsg) - if !ok || result.Err == nil { - t.Fatalf("expected provider selection error, got %+v", msg) - } -} - -func TestExecuteStatusCommandSnapshot(t *testing.T) { - notice := executeStatusCommand(tuistatus.Snapshot{ - ActiveSessionID: "session-123", - ActiveSessionTitle: "Implement slash UX", - IsAgentRunning: true, - CurrentProvider: "openai", - CurrentModel: "gpt-5.4", - CurrentWorkdir: `D:\repo`, - CurrentTool: "bash", - ExecutionError: "tool failed", - FocusLabel: focusLabelTranscript, - PickerLabel: "model", - MessageCount: 7, - }) - for _, want := range []string{ - "Session: Implement slash UX", - "Session ID: session-123", - "Running: yes", - "Provider: openai", - "Model: gpt-5.4", - "Focus: Transcript", - "Picker: model", - "Current Tool: bash", - "Messages: 7", - "Error: tool failed", - } { - if !strings.Contains(notice, want) { - t.Fatalf("expected status output to contain %q, got %q", want, notice) - } - } -} - -func TestExecuteStatusCommandTreatsCompactingAsRunning(t *testing.T) { - notice := executeStatusCommand(tuistatus.Snapshot{ - ActiveSessionTitle: draftSessionTitle, - IsCompacting: true, - CurrentProvider: "openai", - CurrentModel: "gpt-5.4", - CurrentWorkdir: `D:\repo`, - FocusLabel: focusLabelComposer, - PickerLabel: "none", - }) - if !strings.Contains(notice, "Running: yes") { - t.Fatalf("expected compacting state to be reported as running, got %q", notice) - } -} diff --git a/internal/tui/core/app/copy_code_test.go b/internal/tui/core/app/copy_code_test.go deleted file mode 100644 index 9cadcee0..00000000 --- a/internal/tui/core/app/copy_code_test.go +++ /dev/null @@ -1,271 +0,0 @@ -package tui - -import ( - "errors" - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - - providertypes "neo-code/internal/provider/types" -) - -func TestExtractFencedCodeBlocks(t *testing.T) { - content := "before\n```go\nfmt.Println(1)\n```\nmid\n```bash\necho hi\n```\nafter" - blocks := extractFencedCodeBlocks(content) - if len(blocks) != 2 { - t.Fatalf("expected 2 code blocks, got %d", len(blocks)) - } - if blocks[0] != "fmt.Println(1)" { - t.Fatalf("expected first code block to strip language tag, got %q", blocks[0]) - } - if blocks[1] != "echo hi" { - t.Fatalf("expected second code block to strip language tag, got %q", blocks[1]) - } -} - -func TestExtractFencedCodeBlocksWithoutLanguageKeepsFirstLine(t *testing.T) { - content := "before\n```\nSELECT\nFROM users;\n```\nafter" - blocks := extractFencedCodeBlocks(content) - if len(blocks) != 1 { - t.Fatalf("expected 1 code block, got %d", len(blocks)) - } - if !strings.Contains(blocks[0], "SELECT") || !strings.Contains(blocks[0], "FROM users;") { - t.Fatalf("expected full code block content, got %q", blocks[0]) - } -} - -func TestExtractFencedCodeBlocksFromIndentedMarkdown(t *testing.T) { - content := "intro\n\n package main\n import \"fmt\"\n\nending" - blocks := extractFencedCodeBlocks(content) - if len(blocks) != 1 { - t.Fatalf("expected 1 code block from indented markdown, got %d", len(blocks)) - } - if !strings.Contains(blocks[0], "package main") || !strings.Contains(blocks[0], "import \"fmt\"") { - t.Fatalf("expected extracted indented code block, got %q", blocks[0]) - } -} - -func TestParseCopyCodeButtonID(t *testing.T) { - id, startCol, endCol, ok := parseCopyCodeButton("[Copy code #12]") - if !ok || id != 12 { - t.Fatalf("expected id=12 parse success, got id=%d ok=%v", id, ok) - } - if startCol != 0 || endCol <= startCol { - t.Fatalf("expected valid button range, got start=%d end=%d", startCol, endCol) - } - - if _, _, _, ok := parseCopyCodeButton("no button"); ok { - t.Fatalf("expected parse failure for non-button line") - } -} - -func TestRenderMessageBlockWithCopyAddsButtons(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) - } - - rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, "```go\nfmt.Println(1)\n```"), 80, 1) - if !strings.Contains(rendered, "[Copy code #1]") { - t.Fatalf("expected copy button in rendered message, got %q", rendered) - } - plain := stripANSI(rendered) - if strings.Index(plain, "[Copy code #1]") > strings.Index(plain, "fmt.Println(1)") { - t.Fatalf("expected copy button to render above code block, got %q", plain) - } - if len(bindings) != 1 || bindings[0].ID != 1 || bindings[0].Code != "fmt.Println(1)" { - t.Fatalf("unexpected bindings: %+v", bindings) - } -} - -func TestRenderMessageBlockWithCopyPreservesCodeIndentation(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) - } - - content := "```go\nfunc main() {\n\tif true {\n\t\tprintln(\"ok\")\n\t}\n}\n```" - _, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) - if len(bindings) != 1 { - t.Fatalf("expected one copy binding, got %+v", bindings) - } - if !strings.Contains(bindings[0].Code, "\tif true {") || !strings.Contains(bindings[0].Code, "\t\tprintln(\"ok\")") { - t.Fatalf("expected indentation preserved in copied code, got %q", bindings[0].Code) - } -} - -func TestRenderMessageBlockWithCopyAddsButtonsForIndentedCode(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) - } - - content := "璇存槑锛歕n\n package main\n import \"fmt\"" - rendered, bindings := app.renderMessageBlockWithCopy(providerMessage(roleAssistant, content), 80, 1) - if !strings.Contains(stripANSI(rendered), "[Copy code #1]") { - t.Fatalf("expected copy button for indented markdown code, got %q", rendered) - } - if len(bindings) != 1 || !strings.Contains(bindings[0].Code, "package main") { - t.Fatalf("unexpected bindings for indented markdown code: %+v", bindings) - } -} - -func TestTranscriptMouseClickCopiesCodeBlock(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) - } - - originalClipboardWrite := clipboardWriteAll - t.Cleanup(func() { clipboardWriteAll = originalClipboardWrite }) - - copied := "" - clipboardWriteAll = func(text string) error { - copied = text - return nil - } - - app.width = 128 - app.height = 40 - app.activeMessages = []providertypes.Message{ - {Role: roleAssistant, Content: "```go\nfmt.Println(1)\n```"}, - } - app.applyComponentLayout(true) - app.rebuildTranscript() - - x, y, _, _ := app.transcriptBounds() - lines := strings.Split(stripANSI(app.transcript.View()), "\n") - targetY := -1 - targetX := -1 - for i, line := range lines { - col := strings.Index(line, "[Copy code #1]") - if col >= 0 { - targetY = i - targetX = col - break - } - } - if targetY < 0 || targetX < 0 { - t.Fatalf("expected visible copy button in transcript view, got %q", app.transcript.View()) - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Button: tea.MouseButtonLeft, - }); !handled { - t.Fatalf("expected mouse press on copy button to be handled") - } - if copied != "" { - t.Fatalf("expected press phase not to copy yet, got %q", copied) - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Action: tea.MouseActionRelease, - Type: tea.MouseRelease, - }); !handled { - t.Fatalf("expected mouse release on copy button to be handled") - } - - if copied != "fmt.Println(1)" { - t.Fatalf("expected copied code block content, got %q", copied) - } - if !strings.Contains(app.state.StatusText, "Copied code block #1") { - t.Fatalf("expected copy success status, got %q", app.state.StatusText) - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + 60, - Y: y + targetY, - Button: tea.MouseButtonLeft, - Action: tea.MouseActionRelease, - Type: tea.MouseRelease, - }); handled { - t.Fatalf("expected release outside copy button text to be ignored") - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Button: tea.MouseButtonLeft, - Action: tea.MouseActionMotion, - Type: tea.MouseMotion, - }); handled { - t.Fatalf("expected hover/motion over copy button to be ignored") - } -} - -func TestTranscriptMouseCopyFailureSetsError(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) - } - - originalClipboardWrite := clipboardWriteAll - t.Cleanup(func() { clipboardWriteAll = originalClipboardWrite }) - - clipboardWriteAll = func(text string) error { - return errors.New("clipboard unavailable") - } - - app.width = 128 - app.height = 40 - app.activeMessages = []providertypes.Message{ - {Role: roleAssistant, Content: "```txt\nhello\n```"}, - } - app.applyComponentLayout(true) - app.rebuildTranscript() - - x, y, _, _ := app.transcriptBounds() - lines := strings.Split(stripANSI(app.transcript.View()), "\n") - targetY := -1 - targetX := -1 - for i, line := range lines { - col := strings.Index(line, "[Copy code #1]") - if col >= 0 { - targetY = i - targetX = col - break - } - } - if targetY < 0 || targetX < 0 { - t.Fatalf("expected visible copy button in transcript view") - } - - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Button: tea.MouseButtonLeft, - }); !handled { - t.Fatalf("expected mouse press on copy button to be handled") - } - if handled := app.handleTranscriptMouse(tea.MouseMsg{ - X: x + targetX + 1, - Y: y + targetY, - Action: tea.MouseActionRelease, - Type: tea.MouseRelease, - }); !handled { - t.Fatalf("expected mouse release on copy button to be handled") - } - - if app.state.StatusText != statusCodeCopyError || app.state.ExecutionError == "" { - t.Fatalf("expected copy failure status/error, got status=%q err=%q", app.state.StatusText, app.state.ExecutionError) - } -} - -func providerMessage(role, content string) providertypes.Message { - return providertypes.Message{Role: role, Content: content} -} diff --git a/internal/tui/core/app/input_features_test.go b/internal/tui/core/app/input_features_test.go deleted file mode 100644 index 1e83c4f9..00000000 --- a/internal/tui/core/app/input_features_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package tui - -import ( - "context" - "os" - "path/filepath" - goruntime "runtime" - "strings" - "testing" - - "neo-code/internal/config" -) - -func TestWorkspaceCommandHelpers(t *testing.T) { - t.Run("execute workspace command forwards explicit workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - capturedWorkdir := "" - capturedCommand := "" - previous := workspaceCommandExecutor - t.Cleanup(func() { workspaceCommandExecutor = previous }) - workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { - capturedWorkdir = workdir - capturedCommand = command - return "ok", nil - } - - target := t.TempDir() - command, output, err := executeWorkspaceCommand(context.Background(), manager, target, "& git status") - if err != nil { - t.Fatalf("executeWorkspaceCommand() error = %v", err) - } - if command != "git status" || output != "ok" { - t.Fatalf("unexpected execute result command=%q output=%q", command, output) - } - if capturedWorkdir != target || capturedCommand != "git status" { - t.Fatalf("expected forwarded workdir=%q command=%q, got workdir=%q command=%q", target, "git status", capturedWorkdir, capturedCommand) - } - - msg := runWorkspaceCommand(manager, target, "& git status")() - result, ok := msg.(workspaceCommandResultMsg) - if !ok { - t.Fatalf("expected workspaceCommandResultMsg, got %T", msg) - } - if result.Err != nil || result.Command != "git status" || result.Output != "ok" { - t.Fatalf("unexpected runWorkspaceCommand result: %+v", result) - } - }) - - t.Run("extract workspace command validates prefix and body", func(t *testing.T) { - if _, err := extractWorkspaceCommand("git status"); err == nil { - t.Fatalf("expected missing prefix to fail") - } - if _, err := extractWorkspaceCommand("& "); err == nil { - t.Fatalf("expected empty command to fail") - } - got, err := extractWorkspaceCommand(" & git status ") - if err != nil || got != "git status" { - t.Fatalf("expected git status, got %q / %v", got, err) - } - }) - - t.Run("shell args support bash sh and default powershell", func(t *testing.T) { - if got := shellArgs("bash", "pwd"); len(got) != 3 || got[0] != "bash" || got[2] != "pwd" { - t.Fatalf("unexpected bash args %+v", got) - } - if got := shellArgs("sh", "pwd"); len(got) != 3 || got[0] != "sh" || got[2] != "pwd" { - t.Fatalf("unexpected sh args %+v", got) - } - if got := shellArgs("unknown", "git status"); len(got) != 4 || got[0] != "powershell" { - t.Fatalf("expected fallback to powershell, got %+v", got) - } - }) - - t.Run("format workspace command result handles failures and escapes code fences", func(t *testing.T) { - got := formatWorkspaceCommandResult("git status", "before ``` after", context.DeadlineExceeded) - for _, want := range []string{"Command Failed: & git status", "` ` `", "before"} { - if !strings.Contains(got, want) { - t.Fatalf("expected formatted result to contain %q, got %q", want, got) - } - } - }) - - t.Run("default workspace command executor rejects empty commands", func(t *testing.T) { - output, err := defaultWorkspaceCommandExecutor(context.Background(), config.Config{}, "", " ") - if err == nil || !strings.Contains(err.Error(), "empty") || output != "" { - t.Fatalf("expected empty command error, got output=%q err=%v", output, err) - } - }) - - t.Run("default workspace command executor falls back to cfg workdir", func(t *testing.T) { - workdir := t.TempDir() - cfg := config.Config{ - Workdir: workdir, - ToolTimeoutSec: 15, - } - command := "pwd" - if goruntime.GOOS == "windows" { - cfg.Shell = "powershell" - command = "$PWD.Path" - } else { - cfg.Shell = "sh" - } - - output, err := defaultWorkspaceCommandExecutor(context.Background(), cfg, "", command) - if err != nil { - t.Fatalf("defaultWorkspaceCommandExecutor() error = %v", err) - } - normalizedOutput := strings.ToLower(filepath.Clean(strings.TrimSpace(output))) - normalizedWorkdir := strings.ToLower(filepath.Clean(workdir)) - if !strings.Contains(normalizedOutput, normalizedWorkdir) { - t.Fatalf("expected output %q to contain resolved workdir %q", output, workdir) - } - }) -} - -func TestWorkspaceFileHelpers(t *testing.T) { - root := t.TempDir() - mustWrite := func(rel string) { - t.Helper() - path := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir %s: %v", rel, err) - } - if err := os.WriteFile(path, []byte(rel), 0o644); err != nil { - t.Fatalf("write %s: %v", rel, err) - } - } - - mustWrite("README.md") - mustWrite("internal/tui/update.go") - mustWrite("internal/tui/view.go") - mustWrite("node_modules/skip.js") - mustWrite(".git/config") - - t.Run("collect workspace files skips ignored directories and respects limit", func(t *testing.T) { - files, err := collectWorkspaceFiles(root, 2) - if err != nil { - t.Fatalf("collectWorkspaceFiles() error = %v", err) - } - if len(files) != 2 { - t.Fatalf("expected limited result size 2, got %d (%v)", len(files), files) - } - - files, err = collectWorkspaceFiles(root, 10) - if err != nil { - t.Fatalf("collectWorkspaceFiles() error = %v", err) - } - got := strings.Join(files, ",") - if strings.Contains(got, "node_modules") || strings.Contains(got, ".git") { - t.Fatalf("expected ignored directories to be skipped, got %v", files) - } - if !strings.Contains(got, "internal/tui/update.go") || !strings.Contains(got, "internal/tui/view.go") { - t.Fatalf("expected workspace files to be collected, got %v", files) - } - }) - - t.Run("current reference token detects token boundaries", func(t *testing.T) { - if _, _, _, ok := currentReferenceToken("hello world"); ok { - t.Fatalf("expected non-reference token to be ignored") - } - start, end, token, ok := currentReferenceToken("inspect @internal/tui/upd") - if !ok || token != "@internal/tui/upd" || start >= end { - t.Fatalf("unexpected token result start=%d end=%d token=%q ok=%v", start, end, token, ok) - } - }) - - t.Run("matching references and apply suggestion handle both hit and miss cases", func(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.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} - app.input.SetValue("inspect @internal/tui/upd") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - - _, _, _, suggestions, ok := app.resolveFileReferenceSuggestions(app.input.Value()) - if !ok || len(suggestions) == 0 || suggestions[0] != "internal/tui/update.go" { - t.Fatalf("unexpected suggestions %v", suggestions) - } - if !app.applySelectedCommandSuggestion() { - t.Fatalf("expected top suggestion to be applied") - } - if app.state.InputText != "inspect @internal/tui/update.go" { - t.Fatalf("unexpected completed input %q", app.state.InputText) - } - - app.input.SetValue("inspect plain-text") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - if app.applySelectedCommandSuggestion() { - t.Fatalf("expected applySelectedCommandSuggestion to fail without @ token") - } - }) - - t.Run("apply file reference handles replace append and path resolution", func(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) - } - - workdir := t.TempDir() - inside := filepath.Join(workdir, "internal", "tui", "update.go") - outside := filepath.Join(t.TempDir(), "outside.go") - app.state.CurrentWorkdir = workdir - - app.input.SetValue("inspect @old/path.go") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(inside); err != nil { - t.Fatalf("applyFileReference(replace) error = %v", err) - } - if !strings.Contains(app.state.InputText, "@internal/tui/update.go") { - t.Fatalf("expected replaced relative reference, got %q", app.state.InputText) - } - - app.input.SetValue("inspect") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(inside); err != nil { - t.Fatalf("applyFileReference(append with space) error = %v", err) - } - if !strings.HasPrefix(app.state.InputText, "inspect @internal/tui/update.go") { - t.Fatalf("expected appended reference with separator, got %q", app.state.InputText) - } - - app.input.SetValue("inspect ") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(inside); err != nil { - t.Fatalf("applyFileReference(append without extra space) error = %v", err) - } - if strings.Contains(app.state.InputText, "inspect @") { - t.Fatalf("expected single separator before reference, got %q", app.state.InputText) - } - - app.input.SetValue(" ") - app.state.InputText = app.input.Value() - if err := app.applyFileReference(outside); err != nil { - t.Fatalf("applyFileReference(empty input) error = %v", err) - } - outsideAbs, _ := filepath.Abs(outside) - if !strings.Contains(app.state.InputText, "@"+filepath.ToSlash(outsideAbs)) { - t.Fatalf("expected absolute reference for outside file, got %q", app.state.InputText) - } - - if err := app.applyFileReference(" "); err == nil { - t.Fatalf("expected empty path to fail") - } - }) -} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 669326b0..e9face16 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -15,7 +15,7 @@ import ( "github.com/charmbracelet/lipgloss" "neo-code/internal/config" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" agentruntime "neo-code/internal/runtime" "neo-code/internal/tools" tuicommands "neo-code/internal/tui/core/commands" @@ -384,7 +384,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.state.ExecutionError = "" a.state.StatusText = statusThinking a.state.CurrentTool = "" - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleUser, Content: input}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleUser, Content: input}) a.rebuildTranscript() runID := fmt.Sprintf("run-%d", a.now().UnixNano()) a.state.ActiveRunID = runID @@ -821,7 +821,7 @@ func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent func runtimeEventToolStartHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StatusText = statusRunningTool a.state.StreamingReply = false - if payload, ok := event.Payload.(provider.ToolCall); ok { + if payload, ok := event.Payload.(providertypes.ToolCall); ok { a.state.CurrentTool = payload.Name a.setRunProgress(0.6, "Running tool") a.appendActivity("tool", "Running tool", payload.Name, false) @@ -838,7 +838,7 @@ func runtimeEventToolResultHandler(a *App, event agentruntime.RuntimeEvent) bool if !ok { return false } - a.activeMessages = append(a.activeMessages, provider.Message{ + a.activeMessages = append(a.activeMessages, providertypes.Message{ Role: roleTool, Content: payload.Content, IsError: payload.IsError, @@ -886,8 +886,8 @@ func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady } - 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}) + if payload, ok := event.Payload.(providertypes.Message); ok && strings.TrimSpace(payload.Content) != "" && !a.lastAssistantMatches(payload.Content) { + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: payload.Content}) return true } return false @@ -973,7 +973,7 @@ func (a *App) appendAssistantChunk(chunk string) { } if !a.state.StreamingReply || len(a.activeMessages) == 0 || a.activeMessages[len(a.activeMessages)-1].Role != roleAssistant { - a.activeMessages = append(a.activeMessages, provider.Message{Role: roleAssistant, Content: chunk}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: roleAssistant, Content: chunk}) a.state.StreamingReply = true return } @@ -987,7 +987,7 @@ func (a *App) appendInlineMessage(role string, message string) { return } - a.activeMessages = append(a.activeMessages, provider.Message{Role: role, Content: content}) + a.activeMessages = append(a.activeMessages, providertypes.Message{Role: role, Content: content}) } func (a *App) appendActivity(kind string, title string, detail string, isError bool) { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go deleted file mode 100644 index 0015dbe5..00000000 --- a/internal/tui/core/app/update_test.go +++ /dev/null @@ -1,2882 +0,0 @@ -package tui - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "regexp" - goruntime "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "neo-code/internal/config" - contextcompact "neo-code/internal/context/compact" - "neo-code/internal/provider" - providercatalog "neo-code/internal/provider/catalog" - providertypes "neo-code/internal/provider/types" - agentruntime "neo-code/internal/runtime" - agentsession "neo-code/internal/session" - "neo-code/internal/tools" - tuiutils "neo-code/internal/tui/core/utils" - tuiworkspace "neo-code/internal/tui/core/workspace" - tuiservices "neo-code/internal/tui/services" - tuistate "neo-code/internal/tui/state" -) - -type stubRuntime struct { - runInputs []agentruntime.UserInput - compactInputs []agentruntime.CompactInput - events chan agentruntime.RuntimeEvent - sessions []agentsession.Summary - loads map[string]agentsession.Session - runErr error - compactErr error - compactResult agentruntime.CompactResult - listErr error - loadErr error - setWorkdirErr error - setResult *agentsession.Session - setCalls int - resolveInputs []agentruntime.PermissionResolutionInput - resolveErr error - cancelCalls int - cancelResult bool -} - -type stubMarkdownRenderer struct { - output string - err error - calls int -} - -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) - -func (r *stubMarkdownRenderer) Render(content string, width int) (string, error) { - r.calls++ - if r.err != nil { - return "", r.err - } - if r.output != "" { - return r.output, nil - } - return content, nil -} - -func newStubRuntime() *stubRuntime { - return &stubRuntime{ - events: make(chan agentruntime.RuntimeEvent, 16), - loads: map[string]agentsession.Session{}, - } -} - -func (r *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { - r.runInputs = append(r.runInputs, input) - return r.runErr -} - -func (r *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { - r.compactInputs = append(r.compactInputs, input) - return r.compactResult, r.compactErr -} - -func (r *stubRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { - r.resolveInputs = append(r.resolveInputs, input) - return r.resolveErr -} - -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) ([]agentsession.Summary, error) { - if r.listErr != nil { - return nil, r.listErr - } - return append([]agentsession.Summary(nil), r.sessions...), nil -} - -func (r *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { - if r.loadErr != nil { - return agentsession.Session{}, r.loadErr - } - if session, ok := r.loads[id]; ok { - return session, nil - } - return agentsession.Session{}, nil -} - -func (r *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { - r.setCalls++ - if r.setWorkdirErr != nil { - return agentsession.Session{}, r.setWorkdirErr - } - if r.setResult != nil { - return *r.setResult, nil - } - session, ok := r.loads[sessionID] - if !ok { - session = agentsession.Session{ID: sessionID} - } - session.Workdir = strings.TrimSpace(workdir) - r.loads[sessionID] = session - return session, nil -} - -func TestAppUpdateComposerCommands(t *testing.T) { - tests := []struct { - name string - input string - assert func(t *testing.T, beforeRuntime *stubRuntime, manager *config.Manager, app App) - }{ - { - 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)) - } - 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\n", - 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", - 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 != pickerModel { - t.Fatalf("expected model picker to open") - } - if app.state.StatusText != statusChooseModel { - t.Fatalf("expected status %q, got %q", statusChooseModel, app.state.StatusText) - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(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.input.SetValue(tt.input) - app.state.InputText = tt.input - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - - for _, msg := range collectTeaMessages(cmd) { - model, followCmd := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(followCmd) - } - - tt.assert(t, runtime, manager, app) - }) - } -} - -func TestAppUpdateWorkspaceSlashCommands(t *testing.T) { - t.Run("draft workspace command updates local current workdir", func(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) - } - - target := t.TempDir() - app.input.SetValue("/cwd " + target) - app.state.InputText = app.input.Value() - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected slash command not to call runtime.Run, got %+v", runtime.runInputs) - } - if runtime.setCalls != 0 { - t.Fatalf("expected draft workspace change not to call SetSessionWorkdir") - } - if app.state.CurrentWorkdir != target { - t.Fatalf("expected current workdir %q, got %q", target, app.state.CurrentWorkdir) - } - if !strings.Contains(app.state.StatusText, "Draft workspace switched") { - t.Fatalf("expected draft workspace switch status, got %q", app.state.StatusText) - } - }) - - t.Run("session workspace command updates runtime session workdir", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - sessionID := "session-workdir" - runtime.loads[sessionID] = agentsession.Session{ID: sessionID, Workdir: t.TempDir()} - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.state.ActiveSessionID = sessionID - target := t.TempDir() - - app.input.SetValue("/cwd " + target) - app.state.InputText = app.input.Value() - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - if runtime.setCalls != 1 { - t.Fatalf("expected SetSessionWorkdir to be called once, got %d", runtime.setCalls) - } - if app.state.CurrentWorkdir != target { - t.Fatalf("expected current workdir %q, got %q", target, app.state.CurrentWorkdir) - } - if !strings.Contains(app.state.StatusText, "Session workspace switched") { - t.Fatalf("expected session workspace switch status, got %q", app.state.StatusText) - } - }) -} - -func TestRunSessionWorkdirCommandBranches(t *testing.T) { - t.Run("invalid slash command returns parser error", func(t *testing.T) { - msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/status")() - result, ok := msg.(sessionWorkdirResultMsg) - if !ok { - t.Fatalf("expected sessionWorkdirResultMsg, got %T", msg) - } - if result.Err == nil || !strings.Contains(result.Err.Error(), "unknown command") { - t.Fatalf("expected unknown command error, got %+v", result) - } - }) - - t.Run("empty workspace query without current workdir returns usage", func(t *testing.T) { - msg := runSessionWorkdirCommand(newStubRuntime(), "", "", "/cwd")() - result := msg.(sessionWorkdirResultMsg) - if result.Err == nil || !strings.Contains(result.Err.Error(), "usage: /cwd ") { - t.Fatalf("expected usage error, got %+v", result) - } - }) - - t.Run("empty workspace query with current workdir shows current directory", func(t *testing.T) { - current := t.TempDir() - msg := runSessionWorkdirCommand(newStubRuntime(), "", current, "/cwd")() - result := msg.(sessionWorkdirResultMsg) - if result.Err != nil { - t.Fatalf("unexpected error: %v", result.Err) - } - if result.Workdir != current || !strings.Contains(result.Notice, "Current workspace is") { - t.Fatalf("expected current workspace message, got %+v", result) - } - }) - - t.Run("session workdir change surfaces runtime error", func(t *testing.T) { - runtime := newStubRuntime() - runtime.setWorkdirErr = errors.New("set workdir failed") - msg := runSessionWorkdirCommand(runtime, "session-1", t.TempDir(), "/cwd ./subdir")() - result := msg.(sessionWorkdirResultMsg) - if result.Err == nil || !strings.Contains(result.Err.Error(), "set workdir failed") { - t.Fatalf("expected set workdir error, got %+v", result) - } - }) - - t.Run("session workdir fallback uses current workdir when runtime returns empty", func(t *testing.T) { - current := t.TempDir() - runtime := newStubRuntime() - runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: ""} - msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() - result := msg.(sessionWorkdirResultMsg) - if result.Err != nil { - t.Fatalf("unexpected error: %v", result.Err) - } - if result.Workdir != current { - t.Fatalf("expected fallback workdir %q, got %q", current, result.Workdir) - } - }) - - t.Run("session workdir change uses runtime returned workdir when available", func(t *testing.T) { - current := t.TempDir() - target := t.TempDir() - runtime := newStubRuntime() - runtime.setResult = &agentsession.Session{ID: "session-1", Workdir: target} - msg := runSessionWorkdirCommand(runtime, "session-1", current, "/cwd ./subdir")() - result := msg.(sessionWorkdirResultMsg) - if result.Err != nil { - t.Fatalf("unexpected error: %v", result.Err) - } - if result.Workdir != target || !strings.Contains(result.Notice, "Session workspace switched") { - t.Fatalf("expected runtime returned workdir %q, got %+v", target, result) - } - }) - - t.Run("draft workspace change returns resolve error for missing path", func(t *testing.T) { - msg := runSessionWorkdirCommand(newStubRuntime(), "", t.TempDir(), "/cwd ./missing-path")() - result := msg.(sessionWorkdirResultMsg) - if result.Err == nil || !strings.Contains(strings.ToLower(result.Err.Error()), "resolve path") { - t.Fatalf("expected resolve path error, got %+v", result) - } - }) -} - -func TestResolveWorkspacePathAndSelector(t *testing.T) { - t.Run("resolve from empty base falls back to process cwd", func(t *testing.T) { - resolved, err := tuiworkspace.ResolveWorkspacePath("", ".") - if err != nil { - t.Fatalf("ResolveWorkspacePath() error = %v", err) - } - expected, err := filepath.Abs(".") - if err != nil { - t.Fatalf("filepath.Abs(.) error = %v", err) - } - if resolved != filepath.Clean(expected) { - t.Fatalf("expected %q, got %q", filepath.Clean(expected), resolved) - } - }) - - t.Run("resolve relative path from base", func(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "sub") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("mkdir target: %v", err) - } - resolved, err := tuiworkspace.ResolveWorkspacePath(base, "sub") - if err != nil { - t.Fatalf("ResolveWorkspacePath() error = %v", err) - } - if resolved != filepath.Clean(target) { - t.Fatalf("expected %q, got %q", filepath.Clean(target), resolved) - } - }) - - t.Run("resolve workspace path rejects non-directory", func(t *testing.T) { - base := t.TempDir() - file := filepath.Join(base, "note.txt") - if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { - t.Fatalf("write file: %v", err) - } - _, err := tuiworkspace.ResolveWorkspacePath(base, "note.txt") - if err == nil || !strings.Contains(err.Error(), "is not a directory") { - t.Fatalf("expected non-directory error, got %v", err) - } - }) - - t.Run("resolve workspace path returns error for missing target", func(t *testing.T) { - base := t.TempDir() - _, err := tuiworkspace.ResolveWorkspacePath(base, "missing-dir") - if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve path") { - t.Fatalf("expected missing path error, got %v", err) - } - }) - - t.Run("select session workdir prefers session value", func(t *testing.T) { - if got := tuiworkspace.SelectSessionWorkdir(" /session ", "/default"); got != "/session" { - t.Fatalf("expected trimmed session workdir, got %q", got) - } - if got := tuiworkspace.SelectSessionWorkdir("", " /default "); got != "/default" { - t.Fatalf("expected fallback default workdir, got %q", got) - } - }) -} - -func TestAppUpdateSessionWorkdirResultMessage(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) - } - - model, cmd := app.Update(sessionWorkdirResultMsg{ - Notice: "ok", - Workdir: filepath.Join(t.TempDir(), "missing-dir"), - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.state.ExecutionError == "" || !strings.Contains(strings.ToLower(app.state.ExecutionError), "missing-dir") { - t.Fatalf("expected refresh workspace file error, got %q", app.state.ExecutionError) - } - if app.state.StatusText == "ok" { - t.Fatalf("expected status text to switch to refresh error, got %q", app.state.StatusText) - } -} - -func TestRunAgentWorkdirForwarding(t *testing.T) { - t.Run("draft run forwards current workdir", func(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) - } - - target := t.TempDir() - app.state.CurrentWorkdir = target - app.input.SetValue("hello") - app.state.InputText = "hello" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 { - t.Fatalf("expected one runtime input, got %d", len(runtime.runInputs)) - } - if runtime.runInputs[0].Workdir != target { - t.Fatalf("expected draft run workdir %q, got %q", target, runtime.runInputs[0].Workdir) - } - }) - - t.Run("session run uses persisted session workdir", func(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.state.ActiveSessionID = "session-1" - app.state.CurrentWorkdir = t.TempDir() - app.input.SetValue("hello") - app.state.InputText = "hello" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 { - t.Fatalf("expected one runtime input, got %d", len(runtime.runInputs)) - } - if runtime.runInputs[0].Workdir != "" { - t.Fatalf("expected session run to omit workdir override, got %q", runtime.runInputs[0].Workdir) - } - }) -} - -func TestHandlePermissionDecisionKey(t *testing.T) { - t.Parallel() - - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.pendingPermission = &pendingPermissionPrompt{ - RequestID: "perm-1", - ToolName: "webfetch", - } - - tests := []struct { - name string - key tea.KeyMsg - wantSent agentruntime.PermissionResolutionDecision - handled bool - }{ - { - name: "y maps to allow once", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}, - wantSent: agentruntime.PermissionResolutionAllowOnce, - handled: true, - }, - { - name: "a maps to allow session", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}, - wantSent: agentruntime.PermissionResolutionAllowSession, - handled: true, - }, - { - name: "n maps to reject", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}, - wantSent: agentruntime.PermissionResolutionReject, - handled: true, - }, - { - name: "other key ignored", - key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}, - handled: false, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - app.pendingPermission.Submitted = false - runtime.resolveInputs = nil - - cmd, handled := app.handlePermissionDecisionKey(tt.key) - if handled != tt.handled { - t.Fatalf("expected handled=%v, got %v", tt.handled, handled) - } - if !tt.handled { - if cmd != nil { - t.Fatalf("expected nil cmd for unhandled key") - } - return - } - if cmd == nil { - t.Fatalf("expected resolve cmd") - } - msg := cmd() - result, ok := msg.(permissionResolveResultMsg) - if !ok { - t.Fatalf("expected permissionResolveResultMsg, got %T", msg) - } - if result.err != nil { - t.Fatalf("expected nil resolve error, got %v", result.err) - } - if len(runtime.resolveInputs) == 0 { - t.Fatalf("expected runtime resolve inputs") - } - last := runtime.resolveInputs[len(runtime.resolveInputs)-1] - if last.Decision != tt.wantSent { - t.Fatalf("expected decision %q, got %q", tt.wantSent, last.Decision) - } - if strings.TrimSpace(last.RequestID) != "perm-1" { - t.Fatalf("expected request id perm-1, got %q", last.RequestID) - } - }) - } -} - -func TestHandlePermissionDecisionKeyIgnoresRepeatedSubmission(t *testing.T) { - t.Parallel() - - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.pendingPermission = &pendingPermissionPrompt{ - RequestID: "perm-repeat", - ToolName: "webfetch", - } - - cmd, handled := app.handlePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) - if !handled || cmd == nil { - t.Fatalf("expected first permission key to be handled with cmd") - } - msg := cmd() - result, ok := msg.(permissionResolveResultMsg) - if !ok || result.err != nil { - t.Fatalf("expected successful permissionResolveResultMsg, got %#v", msg) - } - if len(runtime.resolveInputs) != 1 { - t.Fatalf("expected one resolve call after first submission, got %d", len(runtime.resolveInputs)) - } - - cmd, handled = app.handlePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) - if !handled { - t.Fatalf("expected repeated permission key to be consumed") - } - if cmd != nil { - t.Fatalf("expected repeated submission to skip runtime command") - } - if len(runtime.resolveInputs) != 1 { - t.Fatalf("expected resolve call count unchanged after repeat, got %d", len(runtime.resolveInputs)) - } -} - -func TestAppUpdateModelPickerAndRuntimeMessages(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, app *App, runtime *stubRuntime) - msg tea.Msg - assert func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) - }{ - { - name: "escape closes model picker", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - 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.ActivePicker != pickerNone { - t.Fatalf("expected model picker to close") - } - if app.state.Focus != panelInput { - t.Fatalf("expected focus to return to input") - } - }, - }, - { - name: "runtime chunk appends assistant draft", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ActiveSessionID = "session-1" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventAgentChunk, - SessionID: "session-1", - Payload: "hello", - }}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if len(app.activeMessages) == 0 { - t.Fatalf("expected assistant draft message") - } - last := app.activeMessages[len(app.activeMessages)-1] - if last.Role != roleAssistant || last.Content != "hello" { - t.Fatalf("unexpected last assistant draft: %+v", last) - } - }, - }, - { - name: "runtime done appends final assistant and clears running state", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.IsAgentRunning = true - app.state.ActiveSessionID = "session-2" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventAgentDone, - SessionID: "session-2", - Payload: providertypes.Message{ - Role: roleAssistant, - Content: "final", - }, - }}, - 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.StatusText != statusReady { - t.Fatalf("expected status ready, got %q", app.state.StatusText) - } - last := app.activeMessages[len(app.activeMessages)-1] - if last.Content != "final" { - t.Fatalf("expected final assistant message, got %+v", last) - } - }, - }, - { - 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) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected cancel notice to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Canceled current run" { - t.Fatalf("expected cancel notice in activity, got %+v", app.activities) - } - }, - }, - { - name: "runtime tool result error is surfaced", - setup: func(t *testing.T, app *App, runtime *stubRuntime) { - app.state.ActiveSessionID = "session-3" - }, - msg: RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolResult, - SessionID: "session-3", - Payload: tools.ToolResult{ - Name: "filesystem_edit", - Content: "boom", - IsError: true, - }, - }}, - assert: func(t *testing.T, runtime *stubRuntime, app App, msgs []tea.Msg) { - t.Helper() - if app.state.ExecutionError != "boom" { - t.Fatalf("expected execution error boom, got %q", app.state.ExecutionError) - } - if app.state.StatusText != statusToolError { - t.Fatalf("expected status tool error, got %q", app.state.StatusText) - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleTool { - t.Fatalf("expected tool result to stay in transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Tool error" { - t.Fatalf("expected tool error in activity, got %+v", app.activities) - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(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) - } - if tt.setup != nil { - tt.setup(t, &app, runtime) - } - - model, cmd := app.Update(tt.msg) - app = model.(App) - tt.assert(t, runtime, app, collectTeaMessages(cmd)) - }) - } -} - -func TestAppHelpersAndRenderingSmoke(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - now := agentsession.Session{ - ID: "session-1", - Title: "Existing Session", - Messages: []providertypes.Message{ - {Role: roleUser, Content: "hi"}, - {Role: roleAssistant, Content: "hello"}, - }, - } - runtime.sessions = []agentsession.Summary{ - {ID: now.ID, Title: now.Title, UpdatedAt: now.UpdatedAt}, - } - runtime.loads[now.ID] = now - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - if app.Init() == nil { - t.Fatalf("expected init command") - } - - app.openModelPicker() - app.closePicker() - app.selectCurrentModel(config.OpenAIDefaultModel) - app.appendAssistantChunk("hello") - app.appendAssistantChunk(" world") - if !app.lastAssistantMatches("hello world") { - t.Fatalf("expected assistant draft to match") - } - app.appendInlineMessage(roleSystem, "notice") - - app.focusNext() - app.focusPrev() - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) - - if err := app.refreshSessions(); err != nil { - t.Fatalf("refreshSessions() error = %v", err) - } - if err := app.activateSelectedSession(); err != nil { - t.Fatalf("activateSelectedSession() error = %v", err) - } - app.syncActiveSessionTitle() - app.syncConfigState(manager.Get()) - app.rebuildTranscript() - - view := app.View() - if view == "" { - t.Fatalf("expected non-empty View()") - } - 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.ActivePicker = pickerModel - if app.renderPicker(48, 12) == "" || app.renderWaterfall(80, 20) == "" { - t.Fatalf("expected model picker rendering") - } - app.state.ActivePicker = pickerNone - app.refreshCommandMenu() - if app.renderCommandMenu(80) == "" { - app.input.SetValue("/") - app.state.InputText = "/" - app.refreshCommandMenu() - if app.renderCommandMenu(80) == "" { - t.Fatalf("expected slash command menu when input starts with slash") - } - } - app.input.SetValue("/status") - app.state.InputText = "/status" - app.refreshCommandMenu() - if menu := app.renderCommandMenu(80); menu != "" { - t.Fatalf("expected complete slash command to hide menu, got %q", menu) - } - if app.renderPrompt(80) == "" || app.renderHelp(80) == "" { - t.Fatalf("expected prompt and help output") - } - app.state.StatusText = "Status:\nSession: Draft\nProvider: openll" - // Ensure a reasonable width so the header does not wrap on narrow terminals. - app.width = 160 - app.height = 48 - app.applyComponentLayout(false) - if lipgloss.Height(app.renderHeader(app.computeLayout().contentWidth)) != 1 { - t.Fatalf("expected header to remain a single line even with multiline status text") - } - if lipgloss.Width(app.renderPrompt(80)) != 80 { - t.Fatalf("expected prompt width 80, got %d", lipgloss.Width(app.renderPrompt(80))) - } - if got := newKeyMap().Send.Help().Key; got != "Enter" { - t.Fatalf("expected send shortcut help to use Enter, got %q", got) - } - if got := newKeyMap().Send.Keys(); len(got) != 1 || got[0] != "enter" { - t.Fatalf("expected send binding to use enter, got %+v", got) - } - if got := newKeyMap().Newline.Help().Key; got != "Ctrl+J" { - t.Fatalf("expected newline shortcut help to use Ctrl+J, got %q", got) - } - if got := newKeyMap().Newline.Keys(); len(got) != 1 || got[0] != "ctrl+j" { - t.Fatalf("expected newline binding to use ctrl+j, got %+v", got) - } - if !strings.Contains(app.renderHelp(80), "Ctrl+J") { - t.Fatalf("expected footer help to render newline shortcut") - } - 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 sends, Ctrl+J inserts a newline") { - t.Fatalf("expected keyboard hint to move out of placeholder text") - } - if strings.TrimSpace(app.renderPrompt(80)) == "" { - t.Fatalf("expected prompt to render a visible border") - } - app.input.SetValue("one") - app.state.InputText = "one" - app.applyComponentLayout(true) - if app.input.Height() != 1 { - t.Fatalf("expected single-line composer height 1, got %d", app.input.Height()) - } - if strings.Count(app.renderPrompt(80), "> ") < 1 { - t.Fatalf("expected single-line prompt to render composer prefix") - } - app.input.SetValue("one\ntwo") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - if app.input.Height() != 2 { - t.Fatalf("expected two-line composer height 2, got %d", app.input.Height()) - } - if strings.Count(app.renderPrompt(80), "> ") < 2 { - t.Fatalf("expected multi-line prompt to repeat composer prefix") - } - app.input.SetValue(strings.Join([]string{"1", "2", "3", "4", "5", "6"}, "\n")) - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - if app.input.Height() != composerMaxHeight { - t.Fatalf("expected composer height capped at %d, got %d", composerMaxHeight, app.input.Height()) - } - if app.focusLabel() == "" || app.statusBadge("ready") == "" { - t.Fatalf("expected status helpers to render") - } - app.focus = panelSessions - if app.focusLabel() != focusLabelSessions { - t.Fatalf("expected session focus label") - } - app.focus = panelTranscript - if app.focusLabel() != focusLabelTranscript { - t.Fatalf("expected transcript focus label") - } - app.focus = panelInput - if app.statusBadge("error: boom") == "" || app.statusBadge("running now") == "" { - t.Fatalf("expected status badge variants") - } - if rendered, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleError, Content: "boom"}, 80, 1); rendered == "" { - t.Fatalf("expected error message block") - } - if rendered, _ := app.renderMessageBlockWithCopy(providertypes.Message{ - Role: roleAssistant, - ToolCalls: []providertypes.ToolCall{ - {Name: "filesystem_edit"}, - }, - }, 80, 1); rendered == "" { - t.Fatalf("expected tool call message block") - } - renderedCodeOnly, _ := app.renderMessageContentWithCopy("```go\nfmt.Println(\"x\")\n```", 80, app.styles.messageBody, 1) - if renderedCodeOnly == "" { - t.Fatalf("expected code block rendering") - } - if app.computeLayout().contentWidth == 0 { - t.Fatalf("expected computed layout") - } - app.width = 90 - app.height = 26 - compact := app.computeLayout() - if !compact.stacked { - t.Fatalf("expected compact layout to stack") - } - app.sessions.SetFilterState(list.Filtering) - 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) { - t.Parallel() - - if len(newKeyMap().ShortHelp()) == 0 || len(newKeyMap().FullHelp()) == 0 { - t.Fatalf("expected key help bindings") - } - - if wrapPlain("abcdef", 3) == "" || tuiutils.TrimRunes("abcdef", 4) == "" || tuiutils.TrimMiddle("abcdefgh", 5) == "" { - t.Fatalf("expected string helpers to return content") - } - if tuiutils.Fallback("", "x") != "x" { - t.Fatalf("expected fallback to use replacement") - } - if preview("line1\nline2\nline3", 8, 2) == "" { - t.Fatalf("expected preview output") - } - if tuiutils.Clamp(10, 0, 5) != 5 || max(2, 3) != 3 { - t.Fatalf("expected numeric helpers to work") - } - - sItem := sessionItem{Summary: agentsession.Summary{Title: "My Session"}} - if sItem.FilterValue() != "my session" { - t.Fatalf("unexpected session item filter value") - } - - mItem := selectionItem{name: "gpt-5.4", description: "Frontier"} - if mItem.Title() == "" || mItem.Description() == "" || mItem.FilterValue() == "" { - t.Fatalf("expected model item helpers to return values") - } - - delegate := sessionDelegate{styles: newStyles()} - if delegate.Height() == 0 || delegate.Spacing() == 0 { - t.Fatalf("expected delegate sizing") - } - if delegate.Update(nil, nil) != nil { - t.Fatalf("expected delegate update to return nil") - } - var buf bytes.Buffer - model := newSelectionPickerItems(mapModelItems([]config.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) - if buf.Len() == 0 { - t.Fatalf("expected delegate render output") - } - - eventCh := make(chan agentruntime.RuntimeEvent, 1) - eventCh <- agentruntime.RuntimeEvent{Type: agentruntime.EventAgentChunk, Payload: "x"} - if msg := ListenForRuntimeEvent(eventCh)(); msg == nil { - t.Fatalf("expected runtime event message") - } - close(eventCh) - if _, ok := ListenForRuntimeEvent(eventCh)().(RuntimeClosedMsg); !ok { - t.Fatalf("expected runtime closed message") - } - - runtime := newStubRuntime() - runMsg := runAgent(runtime, "run-x", "session-x", "", "hello")() - if _, ok := runMsg.(runFinishedMsg); !ok { - t.Fatalf("expected runFinishedMsg") - } - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "hello" { - t.Fatalf("expected runtime run input to be captured") - } - - manager := newTestConfigManager(t) - msg := runModelSelection(newTestProviderService(t, manager), config.OpenAIDefaultModel)() - if result, ok := msg.(localCommandResultMsg); !ok || result.Err != nil { - t.Fatalf("expected successful localCommandResultMsg, got %+v", msg) - } - - _ = model -} - -func TestAppUpdateAdditionalTransitions(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) - msg tea.Msg - assert func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) - }{ - { - name: "window resize updates dimensions", - msg: tea.WindowSizeMsg{Width: 100, Height: 32}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.width != 100 || app.height != 32 { - t.Fatalf("expected updated dimensions, got %dx%d", app.width, app.height) - } - }, - }, - { - name: "runtime closed stops agent", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.IsAgentRunning = true - app.state.StatusText = "" - }, - msg: RuntimeClosedMsg{}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning || app.state.StatusText != statusRuntimeClosed { - t.Fatalf("expected runtime closed state, got %+v", app.state) - } - }, - }, - { - 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) { - app.state.IsAgentRunning = true - }, - msg: runFinishedMsg{Err: context.DeadlineExceeded}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.IsAgentRunning || app.state.ExecutionError == "" { - t.Fatalf("expected execution error to be set") - } - }, - }, - { - 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() - if app.state.StatusText != "[System] ok" { - t.Fatalf("expected success notice, got %q", app.state.StatusText) - } - }, - }, - { - 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() - if app.state.ExecutionError == "" || app.state.StatusText == "" { - t.Fatalf("expected local command error state") - } - }, - }, - { - 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.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 { - t.Fatalf("expected help to be visible") - } - }, - }, - { - name: "next panel moves focus", - msg: tea.KeyMsg{Type: tea.KeyTab}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.focus != panelSessions { - t.Fatalf("expected focus to move to sessions, got %v", app.focus) - } - }, - }, - { - name: "tab in non-empty input inserts indentation instead of switching panel", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.focus = panelInput - app.applyFocus() - app.input.SetValue("func main() {\n") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyTab}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.focus != panelInput { - t.Fatalf("expected focus to stay in input, got %v", app.focus) - } - if !strings.Contains(app.state.InputText, "func main() {") { - t.Fatalf("expected existing code to be preserved, got %q", app.state.InputText) - } - if !strings.HasSuffix(app.state.InputText, "\n\t") && !strings.HasSuffix(app.state.InputText, "\n ") { - t.Fatalf("expected tab indentation at the end, got %q", app.state.InputText) - } - }, - }, - { - name: "previous panel moves focus backward", - msg: tea.KeyMsg{Type: tea.KeyShiftTab}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.focus != panelActivity { - t.Fatalf("expected focus to move backward, got %v", app.focus) - } - }, - }, - { - name: "new session clears active draft", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.state.ActiveSessionID = "existing" - app.state.ActiveSessionTitle = "Existing" - app.activeMessages = []providertypes.Message{{Role: roleUser, Content: "hello"}} - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlN}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.ActiveSessionID != "" || len(app.activeMessages) != 0 || app.state.StatusText != statusDraft { - t.Fatalf("expected new draft state, got %+v", app.state) - } - }, - }, - { - name: "session enter activates selected session", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - runtime.sessions = []agentsession.Summary{{ID: "s1", Title: "One"}} - runtime.loads["s1"] = agentsession.Session{ - ID: "s1", - Title: "One", - Messages: []providertypes.Message{{Role: roleAssistant, Content: "loaded"}}, - } - if err := app.refreshSessions(); err != nil { - t.Fatalf("refresh sessions: %v", err) - } - app.focus = panelSessions - app.applyFocus() - }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.ActiveSessionID != "s1" || len(app.activeMessages) != 1 { - t.Fatalf("expected selected session to load, got %+v / %+v", app.state, app.activeMessages) - } - }, - }, - { - name: "transcript focus handles scroll keys", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.focus = panelTranscript - app.transcript.SetContent(strings.Repeat("line\n", 80)) - app.transcript.Height = 5 - app.transcript.GotoBottom() - }, - msg: tea.KeyMsg{Type: tea.KeyUp}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.transcript.YOffset < 0 { - t.Fatalf("expected non-negative offset") - } - }, - }, - { - name: "input typing updates composer text", - msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.state.InputText != "h" { - t.Fatalf("expected input text to update, got %q", app.state.InputText) - } - }, - }, - { - name: "ctrl+j inserts newline without sending", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("inspect repo") - app.state.InputText = "inspect repo" - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected ctrl+j not to send input, got %+v", runtime.runInputs) - } - if app.state.InputText != "inspect repo\n" { - t.Fatalf("expected newline to be inserted, got %q", app.state.InputText) - } - if app.input.Height() != 2 { - t.Fatalf("expected composer height to grow to 2, got %d", app.input.Height()) - } - prompt := app.renderPrompt(80) - if !strings.Contains(prompt, "inspect repo") { - t.Fatalf("expected first line to remain visible after newline, got %q", prompt) - } - if strings.Count(prompt, "> ") < 2 { - t.Fatalf("expected both lines to keep prompt prefix, got %q", prompt) - } - }, - }, - { - name: "second ctrl+j grows composer to third line", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("line1\nline2") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 { - t.Fatalf("expected second ctrl+j not to send input") - } - if app.input.Height() != 3 { - t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) - } - prompt := app.renderPrompt(80) - if !strings.Contains(prompt, "line1") || !strings.Contains(prompt, "line2") { - t.Fatalf("expected previous lines to remain visible, got %q", prompt) - } - if strings.Count(prompt, "> ") < 3 { - t.Fatalf("expected all three lines to keep prompt prefix, got %q", prompt) - } - }, - }, - { - name: "plain multiline input enter starts runtime", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("inspect repo\nwith details") - app.state.InputText = "inspect repo\nwith details" - }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if !app.state.IsAgentRunning { - t.Fatalf("expected agent to start running") - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleUser { - t.Fatalf("expected user message appended") - } - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "inspect repo\nwith details" { - t.Fatalf("expected runtime command to execute once, got %+v", runtime.runInputs) - } - finished := false - for _, msg := range msgs { - if _, ok := msg.(runFinishedMsg); ok { - finished = true - } - } - if !finished { - t.Fatalf("expected runFinishedMsg from command") - } - }, - }, - { - name: "blank input enter stays local", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue(" \n ") - app.state.InputText = " \n " - }, - msg: tea.KeyMsg{Type: tea.KeyEnter}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { - t.Fatalf("expected blank input enter not to send") - } - if app.state.InputText != " \n " { - t.Fatalf("expected blank input to stay unchanged on enter, got %q", app.state.InputText) - } - }, - }, - { - name: "blank input ctrl+j inserts newline locally", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue(" \n ") - app.state.InputText = " \n " - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyCtrlJ}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if len(runtime.runInputs) != 0 || app.state.IsAgentRunning { - t.Fatalf("expected blank input ctrl+j not to send") - } - if app.state.InputText != " \n \n" { - t.Fatalf("expected ctrl+j to insert newline into blank input, got %q", app.state.InputText) - } - if app.input.Height() != 3 { - t.Fatalf("expected composer height to grow to 3, got %d", app.input.Height()) - } - }, - }, - { - name: "delete newline shrinks composer height", - setup: func(t *testing.T, app *App, runtime *stubRuntime, manager *config.Manager) { - app.input.SetValue("line1\n") - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - }, - msg: tea.KeyMsg{Type: tea.KeyBackspace}, - assert: func(t *testing.T, app App, runtime *stubRuntime, manager *config.Manager, msgs []tea.Msg) { - t.Helper() - if app.input.Height() != 1 { - t.Fatalf("expected composer height to shrink to 1, got %d", app.input.Height()) - } - }, - }, - { - name: "quit returns quit command", - 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 - for _, msg := range msgs { - if _, ok := msg.(tea.QuitMsg); ok { - foundQuit = true - } - } - if !foundQuit { - t.Fatalf("expected quit message") - } - }, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(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) - } - if tt.setup != nil { - tt.setup(t, &app, runtime, manager) - } - - model, cmd := app.Update(tt.msg) - app = model.(App) - tt.assert(t, app, runtime, manager, collectTeaMessages(cmd)) - }) - } -} - -func TestAppUpdatePasteEnterGuard(t *testing.T) { - t.Run("paste-like burst keeps enter as newline", func(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) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - for _, r := range []rune("function_name") { - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - app = model.(App) - _ = collectTeaMessages(cmd) - now = now.Add(15 * time.Millisecond) - } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected enter to stay local during paste-like burst, got %+v", runtime.runInputs) - } - if app.state.InputText != "function_name\n" { - t.Fatalf("expected enter to insert newline, got %q", app.state.InputText) - } - if app.state.IsAgentRunning { - t.Fatalf("expected agent to remain idle") - } - }) - - t.Run("normal typing enter still sends", func(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) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - for _, r := range []rune("hello") { - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) - app = model.(App) - _ = collectTeaMessages(cmd) - now = now.Add(300 * time.Millisecond) - } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - msgs := collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "hello" { - t.Fatalf("expected enter to send normal input once, got %+v", runtime.runInputs) - } - if app.state.InputText != "" { - t.Fatalf("expected input to reset after send, got %q", app.state.InputText) - } - for _, msg := range msgs { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - }) - - t.Run("explicit paste enter inserts newline", func(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.input.SetValue("before") - app.state.InputText = "before" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter, Paste: true}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected paste enter not to send, got %+v", runtime.runInputs) - } - if app.state.InputText != "before\n" { - t.Fatalf("expected paste enter to insert newline, got %q", app.state.InputText) - } - }) - - t.Run("segmented long paste keeps enter as newline across chunks", func(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) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("package main")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(80 * time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(80 * time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("func main() {}")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(80 * time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected segmented paste not to trigger send, got %+v", runtime.runInputs) - } - if app.state.InputText != "package main\nfunc main() {}\n" { - t.Fatalf("expected multiline pasted content, got %q", app.state.InputText) - } - }) - - t.Run("long gap after paste-like input allows immediate send", func(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) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(2 * time.Second) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "line1" { - t.Fatalf("expected enter to send after long gap, got %+v", runtime.runInputs) - } - }) - - t.Run("enter sends after paste session expires", func(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) - } - - now := time.Date(2026, 4, 3, 10, 0, 0, 0, time.UTC) - app.nowFn = func() time.Time { return now } - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("line1\nline2")}) - app = model.(App) - _ = collectTeaMessages(cmd) - - now = now.Add(pasteSessionGuard + 100*time.Millisecond) - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - - if len(runtime.runInputs) != 1 || runtime.runInputs[0].Content != "line1\nline2" { - t.Fatalf("expected send after paste session expiry, got %+v", runtime.runInputs) - } - }) - - t.Run("enter sends right after tab indentation in normal input", func(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.input.SetValue("hello") - app.state.InputText = app.input.Value() - app.focus = panelInput - app.applyFocus() - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyTab}) - app = model.(App) - _ = collectTeaMessages(cmd) - if !strings.Contains(app.state.InputText, "hello") { - t.Fatalf("expected tab to keep current input, got %q", app.state.InputText) - } - - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - if len(runtime.runInputs) != 1 { - t.Fatalf("expected enter to send after tab indentation, got %+v", runtime.runInputs) - } - }) -} - -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, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.openModelPicker() - if len(app.modelPicker.Items()) == 0 { - t.Fatalf("expected model picker catalog") - } - selected := app.modelPicker.Items()[0].(selectionItem).id - app.modelPicker.Select(0) - - 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") - } - - if cmd != nil { - if msg := cmd(); msg != nil { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - } - - cfg := manager.Get() - if cfg.CurrentModel != selected { - t.Fatalf("expected current model %q, got %q", selected, cfg.CurrentModel) - } -} - -func TestAppUpdateProviderPickerEnterAppliesSelection(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, 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.(selectionItem) - if !ok { - continue - } - if candidate.id == config.QiniuName { - selectedIndex = idx - selected = candidate.id - break - } - } - if selectedIndex < 0 { - t.Fatalf("expected provider picker to include %s", config.QiniuName) - } - 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 != config.QiniuDefaultModel { - 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 - event agentruntime.RuntimeEvent - setup func(*App) - assert func(t *testing.T, app App) - }{ - { - name: "user message resets execution state", - setup: func(app *App) { - app.state.ExecutionError = "old" - app.state.CurrentTool = "bash" - }, - event: agentruntime.RuntimeEvent{Type: agentruntime.EventUserMessage, SessionID: "s1"}, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.ExecutionError != "" || app.state.CurrentTool != "" || app.state.StatusText != statusThinking { - t.Fatalf("unexpected user message state: %+v", app.state) - } - }, - }, - { - name: "tool start stores current tool", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolStart, - SessionID: "s1", - Payload: providertypes.ToolCall{ - Name: "filesystem_edit", - }, - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.CurrentTool != "filesystem_edit" || app.state.StatusText != statusRunningTool { - t.Fatalf("unexpected tool start state: %+v", app.state) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected tool start to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Running tool" { - t.Fatalf("expected tool start in activity, got %+v", app.activities) - } - }, - }, - { - name: "tool success appends completion event", - setup: func(app *App) { - app.state.CurrentTool = "filesystem_edit" - }, - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolResult, - SessionID: "s1", - Payload: tools.ToolResult{ - Name: "filesystem_edit", - }, - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.CurrentTool != "" || app.state.StatusText != statusToolFinished { - t.Fatalf("unexpected tool success state: %+v", app.state) - } - if len(app.activeMessages) == 0 || app.activeMessages[len(app.activeMessages)-1].Role != roleTool { - t.Fatalf("expected tool result message in transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Completed tool" { - t.Fatalf("expected tool completion in activity, got %+v", app.activities) - } - }, - }, - { - name: "error event appends inline error", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventError, - SessionID: "s1", - Payload: "boom", - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.ExecutionError != "boom" || app.state.IsAgentRunning { - t.Fatalf("unexpected error state: %+v", app.state) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected runtime error to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Runtime error" { - t.Fatalf("expected runtime error in activity, got %+v", app.activities) - } - }, - }, - { - name: "tool call thinking is tracked as activity", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolCallThinking, - SessionID: "s1", - Payload: "filesystem_edit", - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.CurrentTool != "filesystem_edit" { - t.Fatalf("expected current tool to be populated, got %+v", app.state) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Planning tool call" { - t.Fatalf("expected planning activity, got %+v", app.activities) - } - }, - }, - { - name: "provider retry is tracked as activity", - event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventProviderRetry, - SessionID: "s1", - Payload: "retrying provider call (attempt 1/2, wait=1.0s)...", - }, - assert: func(t *testing.T, app App) { - t.Helper() - if app.state.StatusText != statusThinking { - t.Fatalf("expected provider retry to preserve thinking status, got %+v", app.state) - } - if len(app.activities) == 0 || app.activities[len(app.activities)-1].Title != "Retrying provider call" { - t.Fatalf("expected provider retry activity, got %+v", app.activities) - } - }, - }, - { - 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 { - tt := tt - t.Run(tt.name, func(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) - } - if tt.setup != nil { - tt.setup(&app) - } - app.handleRuntimeEvent(tt.event) - tt.assert(t, app) - }) - } -} - -func TestAppRefreshErrorPaths(t *testing.T) { - t.Run("refresh sessions returns runtime error", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - runtime.listErr = context.DeadlineExceeded - - 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) - } - _ = app - }) - - t.Run("refresh messages returns load error", func(t *testing.T) { - manager := newTestConfigManager(t) - runtime := newStubRuntime() - runtime.loadErr = context.Canceled - - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - app.state.ActiveSessionID = "broken" - - err = app.refreshMessages() - if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { - t.Fatalf("expected load session error, got %v", err) - } - }) -} - -func TestImmediateSlashCommandsAndLayoutBranches(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) - } - - handled, cmd := app.handleImmediateSlashCommand("/help") - if handled || cmd != nil { - t.Fatalf("expected /help to stay on normal slash flow") - } - - handled, cmd = app.handleImmediateSlashCommand("/clear") - if !handled || cmd != nil { - t.Fatalf("expected /clear to be handled locally") - } - if app.state.ActiveSessionID != "" || len(app.activeMessages) != 0 { - t.Fatalf("expected /clear to reset draft state") - } - - handled, cmd = app.handleImmediateSlashCommand("/compact") - if !handled || cmd != nil { - t.Fatalf("expected /compact without session to be handled locally") - } - if !strings.Contains(app.state.StatusText, "compact requires an existing session") { - t.Fatalf("expected missing-session compact hint, got %q", app.state.StatusText) - } - if len(runtime.compactInputs) != 0 { - t.Fatalf("expected no runtime compact call without session, got %+v", runtime.compactInputs) - } - - runtime.compactResult = agentruntime.CompactResult{ - Applied: true, - BeforeChars: 100, - AfterChars: 40, - SavedRatio: 0.6, - TriggerMode: string(contextcompact.ModeManual), - TranscriptPath: "/tmp/transcript.jsonl", - } - app.state.ActiveSessionID = "session-compact" - handled, cmd = app.handleImmediateSlashCommand("/compact") - if !handled || cmd == nil { - t.Fatalf("expected /compact to trigger compact cmd") - } - if app.state.StatusText != statusCompacting { - t.Fatalf("expected compact status %q, got %q", statusCompacting, app.state.StatusText) - } - if !app.state.IsCompacting { - t.Fatalf("expected /compact to mark UI as compacting") - } - msgs := collectTeaMessages(cmd) - if len(msgs) != 1 { - t.Fatalf("expected one compact command message, got %d", len(msgs)) - } - if _, ok := msgs[0].(compactFinishedMsg); !ok { - t.Fatalf("expected compact finished msg, got %T", msgs[0]) - } - if len(runtime.compactInputs) != 1 || runtime.compactInputs[0].SessionID != "session-compact" { - t.Fatalf("expected runtime compact call with active session, got %+v", runtime.compactInputs) - } - - handled, cmd = app.handleImmediateSlashCommand("/compact") - if !handled || cmd != nil { - t.Fatalf("expected re-entrant /compact to be rejected while compacting") - } - if !strings.Contains(strings.ToLower(app.state.StatusText), "compact is already running") { - t.Fatalf("expected compact busy hint, got %q", app.state.StatusText) - } - - handled, cmd = app.handleImmediateSlashCommand("/compact now") - if !handled || cmd != nil { - t.Fatalf("expected /compact with args to be handled locally with usage error") - } - if !strings.Contains(app.state.StatusText, "usage: /compact") { - t.Fatalf("expected /compact usage hint, got %q", app.state.StatusText) - } - - handled, cmd = app.handleImmediateSlashCommand("/exit") - if !handled || cmd == nil { - t.Fatalf("expected /exit to return a quit cmd") - } - foundQuit := false - for _, msg := range collectTeaMessages(cmd) { - if _, ok := msg.(tea.QuitMsg); ok { - foundQuit = true - } - } - if !foundQuit { - t.Fatalf("expected quit msg from /exit") - } - - app.state.IsAgentRunning = false - app.transcript.Width = 40 - app.transcript.Height = 4 - app.transcript.SetContent(strings.Repeat("line\n", 20)) - app.transcript.GotoBottom() - app.applyComponentLayout(false) - if app.transcript.Width <= 0 || app.transcript.Height <= 0 { - t.Fatalf("expected resizeComposerLayout to keep transcript dimensions positive") - } - - snapshot := app.currentStatusSnapshot() - if snapshot.FocusLabel == "" || snapshot.CurrentProvider == "" || snapshot.CurrentModel == "" { - t.Fatalf("expected non-empty status snapshot fields, got %+v", snapshot) - } -} - -func TestCompactEventAndBusyBranches(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.state.ActiveSessionID = "s1" - dirty := app.handleRuntimeEvent(agentruntime.RuntimeEvent{ - Type: agentruntime.EventCompactDone, - SessionID: "s1", - Payload: agentruntime.CompactDonePayload{ - Applied: true, - BeforeChars: 100, - AfterChars: 60, - SavedRatio: 0.4, - TriggerMode: string(contextcompact.ModeManual), - TranscriptPath: "/tmp/t.jsonl", - }, - }) - if !dirty { - t.Fatalf("expected compact done to dirty transcript") - } - if !strings.Contains(app.state.StatusText, "Compact(manual)") { - t.Fatalf("expected compact status text, got %q", app.state.StatusText) - } - if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "Compact(manual)") { - t.Fatalf("expected compact inline notice, got %+v", app.activeMessages) - } - - dirty = app.handleRuntimeEvent(agentruntime.RuntimeEvent{ - Type: agentruntime.EventCompactError, - SessionID: "s1", - Payload: agentruntime.CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), - Message: "disk full", - }, - }) - if !dirty { - t.Fatalf("expected compact error to dirty transcript") - } - if !strings.Contains(app.state.ExecutionError, "Compact(manual) failed: disk full") { - t.Fatalf("expected compact error in state, got %q", app.state.ExecutionError) - } - - app.state.IsCompacting = true - app.state.ActiveSessionID = "session-existing" - app.state.ActiveSessionTitle = "Existing" - app.input.SetValue("hello") - app.state.InputText = "hello" - - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - _ = collectTeaMessages(cmd) - if len(runtime.runInputs) != 0 { - t.Fatalf("expected send to be blocked while compacting, got %+v", runtime.runInputs) - } - if app.state.InputText != "hello" { - t.Fatalf("expected input unchanged while compacting, got %q", app.state.InputText) - } - - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.state.ActiveSessionID != "session-existing" { - t.Fatalf("expected new-session shortcut to be blocked while compacting") - } - - model, cmd = app.Update(compactFinishedMsg{Err: nil}) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.state.IsCompacting { - t.Fatalf("expected compact finished message to clear busy compacting state") - } -} - -func TestAdditionalRenderingAndToolChunkBranches(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.state.ActiveSessionID = "session-tool" - app.handleRuntimeEvent(agentruntime.RuntimeEvent{ - Type: agentruntime.EventToolChunk, - SessionID: "session-tool", - Payload: "chunk output", - }) - if app.state.StatusText != statusRunningTool { - t.Fatalf("expected tool chunk to keep running status, got %q", app.state.StatusText) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected tool chunk to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) == 0 || !strings.Contains(app.activities[len(app.activities)-1].Detail, "chunk output") { - t.Fatalf("expected tool chunk preview in activity, got %+v", app.activities) - } - - if got := wrapCodeBlock("a\tb", 3); !strings.Contains(got, "\n") { - t.Fatalf("expected tabs to expand and wrap, got %q", got) - } - if got := wrapCodeBlock("abc", 0); got != "abc" { - t.Fatalf("expected width<=0 to return original text, got %q", got) - } - - rendered, _ := app.renderMessageContentWithCopy("```\n```", 20, app.styles.messageBody, 1) - if !strings.Contains(stripANSI(rendered), emptyMessageText) { - t.Fatalf("expected empty code block placeholder, got %q", rendered) - } -} - -func TestHandleViewportKeysPageScrollingUsesFullPage(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.transcript.SetContent(strings.Repeat("line\n", 120)) - app.transcript.Height = 10 - app.transcript.GotoTop() - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyPgDown}) - if app.transcript.YOffset != 10 { - t.Fatalf("expected page down to move a full page, got offset %d", app.transcript.YOffset) - } - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyPgUp}) - if app.transcript.YOffset != 0 { - t.Fatalf("expected page up to return a full page, got offset %d", app.transcript.YOffset) - } - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyEnd}) - if !app.transcript.AtBottom() { - t.Fatalf("expected end to jump to bottom") - } - - app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyHome}) - if !app.transcript.AtTop() { - t.Fatalf("expected home to jump to top") - } -} - -func TestTranscriptMouseWheelScrollsOnlyInsideTranscript(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.width = 128 - app.height = 40 - app.applyComponentLayout(true) - app.transcript.SetContent(strings.Repeat("line\n", 160)) - app.transcript.GotoTop() - - x, y, _, _ := app.transcriptBounds() - model, cmd := app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelDown, - Type: tea.MouseWheelDown, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.transcript.YOffset != mouseWheelStepLines { - t.Fatalf("expected wheel down to scroll transcript by %d lines, got %d", mouseWheelStepLines, app.transcript.YOffset) - } - - model, cmd = app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelUp, - Type: tea.MouseWheelUp, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.transcript.YOffset != 0 { - t.Fatalf("expected wheel up to scroll transcript back to top, got %d", app.transcript.YOffset) - } - - model, cmd = app.Update(tea.MouseMsg{ - X: 0, - Y: 0, - Button: tea.MouseButtonWheelDown, - Type: tea.MouseWheelDown, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.transcript.YOffset != 0 { - t.Fatalf("expected wheel event outside transcript to be ignored, got %d", app.transcript.YOffset) - } - - app.transcript.Height = 0 - if app.isMouseWithinTranscript(tea.MouseMsg{X: x + 1, Y: y + 1}) { - t.Fatalf("expected zero-height transcript bounds to reject mouse hits") - } - - app.transcript.Height = 10 - if app.handleTranscriptMouse(tea.MouseMsg{X: x + 1, Y: y + 1, Button: tea.MouseButtonLeft}) { - t.Fatalf("expected non-wheel mouse button to be ignored") - } - - app.width = 100 - app.height = 32 - app.applyComponentLayout(true) - stackX, stackY, _, stackH := app.transcriptBounds() - if stackH <= 0 || !app.isMouseWithinTranscript(tea.MouseMsg{X: stackX + 1, Y: stackY + 1}) { - t.Fatalf("expected stacked layout transcript bounds to accept mouse hits") - } -} - -func TestInputMouseWheelScrollsComposer(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.width = 128 - app.height = 40 - app.input.SetValue(strings.Join([]string{ - "line1", "line2", "line3", "line4", "line5", - "line6", "line7", "line8", "line9", "line10", - "line11", "line12", - }, "\n")) - app.state.InputText = app.input.Value() - app.applyComponentLayout(true) - app.focus = panelTranscript - app.applyFocus() - - initialLine := app.input.Line() - if initialLine < 1 { - t.Fatalf("expected cursor line to be >=1 for multiline input, got %d", initialLine) - } - pageStep := max(1, app.input.Height()-1) - - x, y, _, _ := app.inputBounds() - model, cmd := app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelUp, - Type: tea.MouseWheelUp, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.focus != panelInput { - t.Fatalf("expected input wheel to focus input panel, got %v", app.focus) - } - if initialLine-app.input.Line() < pageStep-1 { - t.Fatalf("expected wheel up in input to page-scroll by ~%d lines, got from %d to %d", pageStep, initialLine, app.input.Line()) - } - - lineAfterUp := app.input.Line() - model, cmd = app.Update(tea.MouseMsg{ - X: x + 1, - Y: y + 1, - Button: tea.MouseButtonWheelDown, - Type: tea.MouseWheelDown, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.input.Line()-lineAfterUp < pageStep-1 { - t.Fatalf("expected wheel down in input to page-scroll by ~%d lines, got from %d to %d", pageStep, lineAfterUp, app.input.Line()) - } - - lineBeforeOutside := app.input.Line() - model, cmd = app.Update(tea.MouseMsg{ - X: 0, - Y: 0, - Button: tea.MouseButtonWheelUp, - Type: tea.MouseWheelUp, - }) - app = model.(App) - _ = collectTeaMessages(cmd) - if app.input.Line() != lineBeforeOutside { - t.Fatalf("expected wheel outside input to be ignored, got line=%d", app.input.Line()) - } -} - -func TestInputCharLimitIsUnlimited(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) - } - - if app.input.CharLimit != 0 { - t.Fatalf("expected unlimited input char limit, got %d", app.input.CharLimit) - } -} - -func TestViewActivityPreviewAndStatusHelpers(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) - } - - if app.activityPreviewHeight() != 0 || app.renderActivityPreview(80) != "" { - t.Fatalf("expected empty activity state to render nothing") - } - - fixed := time.Date(2026, 4, 2, 9, 30, 0, 0, time.UTC) - app.activities = []tuistate.ActivityEntry{ - {Time: fixed, Kind: "tool", Title: "first", Detail: "alpha"}, - {Time: fixed, Kind: "", Title: "second", Detail: ""}, - {Time: fixed, Kind: "provider", Title: "third", Detail: "retry"}, - {Time: fixed, Kind: "run", Title: "fourth", Detail: "done"}, - } - app.applyComponentLayout(true) - app.rebuildActivity() - app.focus = panelActivity - if app.focusLabel() != focusLabelActivity { - t.Fatalf("expected activity focus label, got %q", app.focusLabel()) - } - if app.activityPreviewHeight() != 6 { - t.Fatalf("expected fixed activity preview height, got %d", app.activityPreviewHeight()) - } - - preview := app.renderActivityPreview(64) - if !strings.Contains(preview, "second") || !strings.Contains(preview, "third") || !strings.Contains(preview, "fourth") { - t.Fatalf("expected latest activity rows in preview, got %q", preview) - } - if strings.Contains(preview, "first") { - t.Fatalf("expected oldest activity row to be clipped from current viewport, got %q", preview) - } - - line := app.renderActivityLine(tuistate.ActivityEntry{Time: fixed, Kind: "", Title: "single line", Detail: ""}, 80) - if !strings.Contains(line, "EVENT") || strings.Contains(line, "single line:") { - t.Fatalf("expected fallback kind without detail suffix, got %q", line) - } - - rendered, _ := app.renderMessageContentWithCopy("before\n```go\nfmt.Println(1)\n```\nafter", 30, app.styles.messageBody, 1) - rendered = stripANSI(rendered) - if !strings.Contains(rendered, "before") || !strings.Contains(rendered, "fmt.Println(") || !strings.Contains(rendered, "1)") || !strings.Contains(rendered, "after") { - t.Fatalf("expected mixed prose and code to render, got %q", rendered) - } - if !strings.Contains(rendered, "[Copy code #1]") { - t.Fatalf("expected copy button alongside code block, got %q", rendered) - } - - if got := compactStatusText("\n hello world \n", 0); got != "hello world" { - t.Fatalf("expected compact status without truncation, got %q", got) - } - if got := compactStatusText("\n \n", 10); got != "" { - t.Fatalf("expected empty compact status for blank input, got %q", got) - } - - if app.statusBadge("failed request") == "" || app.statusBadge("canceled") == "" || app.statusBadge("ready") == "" { - t.Fatalf("expected status badge branches to render non-empty output") - } -} - -func TestRenderMessageContentUsesMarkdownRenderer(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) - } - - stub := &stubMarkdownRenderer{output: "markdown-rendered"} - app.markdownRenderer = stub - - rendered, _ := app.renderMessageContentWithCopy("# Title\n\n- item", 40, app.styles.messageBody, 1) - if !strings.Contains(rendered, "markdown-rendered") { - t.Fatalf("expected markdown renderer output, got %q", rendered) - } - if stub.calls != 1 { - t.Fatalf("expected markdown renderer to be called once, got %d", stub.calls) - } -} - -func TestRenderMessageBlockUserContentAlignsWithUserTag(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) - } - - renderedMessage, _ := app.renderMessageBlockWithCopy(providertypes.Message{Role: roleUser, Content: "nihao"}, 80, 1) - rendered := stripANSI(renderedMessage) - lines := strings.Split(rendered, "\n") - - tagLine := "" - bodyLine := "" - for _, line := range lines { - if strings.Contains(line, messageTagUser) { - tagLine = line - } - if strings.Contains(line, "nihao") { - bodyLine = line - } - } - if tagLine == "" || bodyLine == "" { - t.Fatalf("expected user tag and body lines, got %q", rendered) - } - - tagCol := strings.Index(tagLine, messageTagUser) - bodyCol := strings.Index(bodyLine, "nihao") - if tagCol < 0 || bodyCol < 0 { - t.Fatalf("expected valid columns for user tag/body, got tag=%d body=%d", tagCol, bodyCol) - } - if bodyCol+6 < tagCol { - t.Fatalf("expected user body to align near user tag, got tagCol=%d bodyCol=%d rendered=%q", tagCol, bodyCol, rendered) - } -} - -func TestRenderMessageContentNormalizesRightEdge(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.markdownRenderer = &stubMarkdownRenderer{ - output: "very long line\nshort\nmid", - } - - renderedRaw, _ := app.renderMessageContentWithCopy("ignored", 40, app.styles.messageBody, 1) - rendered := stripANSI(renderedRaw) - lines := strings.Split(rendered, "\n") - if len(lines) < 3 { - t.Fatalf("expected multiline output, got %q", rendered) - } - - firstWidth := len([]rune(lines[0])) - for i, line := range lines[1:] { - if len([]rune(line)) != firstWidth { - t.Fatalf("expected aligned right edge, line %d width=%d first=%d rendered=%q", i+1, len([]rune(line)), firstWidth, rendered) - } - } -} - -func TestRenderMessageContentShowsPlaceholderWhenMarkdownFails(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.markdownRenderer = &stubMarkdownRenderer{err: errors.New("render failed")} - - content := "before\n```go\nfmt.Println(1)\n```\nafter" - rendered, _ := app.renderMessageContentWithCopy(content, 50, app.styles.messageBody, 1) - if !strings.Contains(rendered, "fmt.Println(1)") { - t.Fatalf("expected code block to keep rendering when markdown prose fails, got %q", rendered) - } - if !strings.Contains(rendered, "[Copy code #1]") { - t.Fatalf("expected copy button for rendered code block, got %q", rendered) - } -} - -func TestRenderMessageContentShowsPlaceholderWhenRendererMissing(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.markdownRenderer = nil - - rendered, _ := app.renderMessageContentWithCopy("content", 50, app.styles.messageBody, 1) - if !strings.Contains(rendered, emptyMessageText) { - t.Fatalf("expected placeholder when markdown renderer is missing, got %q", rendered) - } -} - -func TestWorkspaceCommandAndFileReferenceFlow(t *testing.T) { - previousExecutor := workspaceCommandExecutor - t.Cleanup(func() { workspaceCommandExecutor = previousExecutor }) - workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { - return "stubbed output for " + command, nil - } - - manager := newTestConfigManager(t) - runtime := newStubRuntime() - app, err := New(nil, manager, runtime, newTestProviderService(t, manager)) - if err != nil { - t.Fatalf("New() error = %v", err) - } - - app.input.SetValue("& git status") - app.state.InputText = app.input.Value() - model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) - app = model.(App) - for _, msg := range collectTeaMessages(cmd) { - model, follow := app.Update(msg) - app = model.(App) - _ = collectTeaMessages(follow) - } - - if len(runtime.runInputs) != 0 { - t.Fatalf("expected & command not to hit agent runtime, got %+v", runtime.runInputs) - } - if app.state.StatusText != statusCommandDone { - t.Fatalf("expected command done status, got %q", app.state.StatusText) - } - if len(app.activeMessages) != 0 { - t.Fatalf("expected workspace command flow to stay out of transcript, got %+v", app.activeMessages) - } - if len(app.activities) < 2 { - t.Fatalf("expected running event and command result in activity, got %+v", app.activities) - } - first := app.activities[0] - if first.Title != "Running command" || !strings.Contains(first.Detail, "git status") { - t.Fatalf("expected running command activity, got %+v", first) - } - last := app.activities[len(app.activities)-1] - if last.Title != "Command finished" || !strings.Contains(last.Detail, "Command: & git status") || !strings.Contains(last.Detail, "stubbed output for git status") { - t.Fatalf("expected command output in activity, got %+v", last) - } - - app.fileCandidates = []string{"README.md", "internal/tui/update.go", "internal/tui/view.go"} - app.input.SetValue("inspect @internal/tui/upd") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - menu := app.renderCommandMenu(80) - if !strings.Contains(menu, fileMenuTitle) || !strings.Contains(menu, "@internal/tui/update.go") { - t.Fatalf("expected file suggestion menu, got %q", menu) - } - if strings.Count(menu, "\n") > 6 { - t.Fatalf("expected compact file suggestion menu, got %q", menu) - } - - model, cmd = app.Update(tea.KeyMsg{Type: tea.KeyTab}) - app = model.(App) - if cmd != nil { - _ = collectTeaMessages(cmd) - } - if app.focus != panelInput { - t.Fatalf("expected tab completion to keep focus in input, got %v", app.focus) - } - if app.state.InputText != "inspect @internal/tui/update.go" { - t.Fatalf("expected @ suggestion to be applied, got %q", app.state.InputText) - } - - app.input.SetValue("& go test ./...") - app.state.InputText = app.input.Value() - app.refreshCommandMenu() - menu = app.renderCommandMenu(80) - if !strings.Contains(menu, shellMenuTitle) || !strings.Contains(menu, workspaceCommandUsage) { - t.Fatalf("expected shell hint menu, got %q", menu) - } - // Shell menu should stay reasonably compact (title + one item row + padding). - // Allow extra newlines on Windows where long paths with non-ASCII characters - // may cause lipgloss to wrap the description line. - maxShellMenuLines := 4 - if goruntime.GOOS == "windows" { - maxShellMenuLines = 6 - } - if strings.Count(menu, "\n") > maxShellMenuLines { - t.Fatalf("expected compact shell menu, got %q", menu) - } -} - -func TestActivityMouseFilePickerAndProgressRendering(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.width = 120 - app.height = 38 - app.applyComponentLayout(true) - x0, y0, _, _ := app.activityBounds() - if app.isMouseWithinActivity(tea.MouseMsg{X: x0, Y: y0}) { - t.Fatalf("expected empty activity panel hit test to be false") - } - if app.handleActivityMouse(tea.MouseMsg{X: x0, Y: y0, Type: tea.MouseWheelDown, Button: tea.MouseButtonWheelDown}) { - t.Fatalf("expected mouse handling to be false without activities") - } - - app.appendActivity("tool", "Running tool", "detail", false) - app.applyComponentLayout(true) - ax, ay, aw, ah := app.activityBounds() - if aw <= 0 || ah <= 0 { - t.Fatalf("expected visible activity bounds, got width=%d height=%d", aw, ah) - } - inside := tea.MouseMsg{ - X: ax + 1, - Y: ay + 1, - Type: tea.MouseWheelDown, - Button: tea.MouseButtonWheelDown, - Action: tea.MouseActionPress, - } - app.focus = panelTranscript - if !app.handleActivityMouse(inside) { - t.Fatalf("expected activity wheel event to be handled") - } - if app.focus != panelActivity { - t.Fatalf("expected focus to move to activity panel, got %v", app.focus) - } - - app.state.ActivePicker = pickerModel - if app.handleActivityMouse(inside) { - t.Fatalf("expected activity mouse ignored when picker is active") - } - app.state.ActivePicker = pickerNone - if app.isMouseWithinActivity(tea.MouseMsg{X: ax + aw + 2, Y: ay}) { - t.Fatalf("expected out-of-bound mouse point to be rejected") - } - - app.state.ActivePicker = pickerFile - if pickerView := stripANSI(app.renderPicker(48, 12)); !strings.Contains(pickerView, filePickerTitle) { - t.Fatalf("expected file picker title, got %q", pickerView) - } - model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyDown}) - app = model.(App) - if cmd != nil { - _ = collectTeaMessages(cmd) - } - - app.state.IsAgentRunning = true - app.state.StatusText = "thinking" - app.runProgressKnown = true - app.runProgressValue = 0.45 - app.runProgressLabel = "Planning" - header := stripANSI(app.renderHeader(100)) - if !strings.Contains(header, "Planning") { - t.Fatalf("expected progress label in header, got %q", header) - } - app.runProgressLabel = "" - app.state.StatusText = "" - header = stripANSI(app.renderHeader(100)) - if !strings.Contains(header, statusRunning) { - t.Fatalf("expected running fallback text in header, got %q", header) - } - - app.state.ActivePicker = pickerFile - snapshot := app.currentStatusSnapshot() - if snapshot.PickerLabel != "file" { - t.Fatalf("expected picker label file, got %q", snapshot.PickerLabel) - } - - app.runProgressKnown = true - modelAny, _ := app.Update(RuntimeClosedMsg{}) - closed := modelAny.(App) - if closed.runProgressKnown { - t.Fatalf("expected RuntimeClosedMsg to clear run progress") - } -} - -func TestRuntimeSourceEventsUpdateState(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) - } - - runID := "run-source-state" - sessionID := "session-source-state" - - model, _ := app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventType(tuiservices.RuntimeEventRunContext), - RunID: runID, - SessionID: sessionID, - Payload: tuiservices.RuntimeRunContextPayload{ - Provider: "openai", - Model: "gpt-5.4", - Workdir: "D:/repo", - Mode: "act", - }, - }}) - app = model.(App) - if app.state.ActiveRunID != runID || app.state.RunContext.Provider != "openai" { - t.Fatalf("expected run context to be mapped, got runID=%q context=%+v", app.state.ActiveRunID, app.state.RunContext) - } - - model, _ = app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventType(tuiservices.RuntimeEventToolStatus), - RunID: runID, - SessionID: sessionID, - Payload: tuiservices.RuntimeToolStatusPayload{ - ToolCallID: "call-1", - ToolName: "filesystem_edit", - Status: string(tuistate.ToolLifecycleRunning), - }, - }}) - app = model.(App) - if len(app.state.ToolStates) != 1 || app.state.ToolStates[0].ToolName != "filesystem_edit" { - t.Fatalf("expected tool status to be tracked, got %+v", app.state.ToolStates) - } - - model, _ = app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ - Type: agentruntime.EventType(tuiservices.RuntimeEventUsage), - RunID: runID, - SessionID: sessionID, - Payload: tuiservices.RuntimeUsagePayload{ - Run: tuiservices.RuntimeUsageSnapshot{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}, - Session: tuiservices.RuntimeUsageSnapshot{InputTokens: 10, OutputTokens: 20, TotalTokens: 30}, - }, - }}) - app = model.(App) - if app.state.TokenUsage.RunTotalTokens != 3 || app.state.TokenUsage.SessionTotalTokens != 30 { - t.Fatalf("expected usage to be mapped, got %+v", app.state.TokenUsage) - } - -} - -func newTestConfigManager(t *testing.T) *config.Manager { - t.Helper() - manager := config.NewManager(config.NewLoader(t.TempDir(), config.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) *config.SelectionService { - t.Helper() - - registry := provider.NewRegistry() - err := registry.Register(provider.DriverDefinition{ - Name: config.OpenAIName, - Build: func(ctx context.Context, cfg config.ResolvedProviderConfig) (provider.Provider, error) { - return tUItestProvider{}, nil - }, - }) - if err != nil { - t.Fatalf("register provider drivers: %v", err) - } - modelCatalogs := providercatalog.NewService("", registry, newTUItestCatalogStore()) - return config.NewSelectionService(manager, registry, registry, modelCatalogs) -} - -type tUItestProvider struct{} - -func (tUItestProvider) Chat(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { - return nil -} - -type tUItestCatalogStore struct { - catalogs map[string]providercatalog.ModelCatalog - mu sync.Mutex -} - -func newTUItestCatalogStore() *tUItestCatalogStore { - return &tUItestCatalogStore{ - catalogs: map[string]providercatalog.ModelCatalog{}, - } -} - -func (s *tUItestCatalogStore) Load(ctx context.Context, identity config.ProviderIdentity) (providercatalog.ModelCatalog, error) { - if err := ctx.Err(); err != nil { - return providercatalog.ModelCatalog{}, err - } - - catalog, ok := s.catalogs[identity.Key()] - if !ok { - return providercatalog.ModelCatalog{}, providercatalog.ErrCatalogNotFound - } - return catalog, nil -} - -func (s *tUItestCatalogStore) Save(ctx context.Context, catalog providercatalog.ModelCatalog) error { - if err := ctx.Err(); err != nil { - return err - } - - s.catalogs[catalog.Identity.Key()] = catalog - return nil -} - -func collectTeaMessages(cmd tea.Cmd) []tea.Msg { - if cmd == nil { - return nil - } - msgCh := make(chan tea.Msg, 1) - go func() { - msgCh <- cmd() - }() - - var msg tea.Msg - select { - case msg = <-msgCh: - case <-time.After(250 * time.Millisecond): - return nil - } - if msg == nil { - return nil - } - switch typed := msg.(type) { - case tea.BatchMsg: - var out []tea.Msg - for _, child := range typed { - out = append(out, collectTeaMessages(child)...) - } - return out - default: - return []tea.Msg{typed} - } -} - -func stripANSI(input string) string { - return ansiPattern.ReplaceAllString(input, "") -} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 5cbdb115..10a47c10 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" - "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" tuicomponents "neo-code/internal/tui/components" tuiutils "neo-code/internal/tui/core/utils" tuistate "neo-code/internal/tui/state" @@ -234,7 +234,7 @@ func (a App) renderPanel(title string, subtitle string, body string, width int, return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } -func (a App) renderMessageBlockWithCopy(message provider.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { +func (a App) renderMessageBlockWithCopy(message providertypes.Message, width int, startCopyID int) (string, []copyCodeButtonBinding) { switch message.Role { case roleEvent: return a.styles.inlineNotice.Width(width).Render(" > " + wrapPlain(message.Content, max(16, width-6))), nil @@ -481,4 +481,3 @@ func (a App) helpHeight(width int) int { func (a App) isFilteringSessions() bool { return a.sessions.FilterState() != list.Unfiltered } - diff --git a/internal/tui/core/commands/workspace.go b/internal/tui/core/commands/workspace.go index 0886164d..08be31d1 100644 --- a/internal/tui/core/commands/workspace.go +++ b/internal/tui/core/commands/workspace.go @@ -5,12 +5,12 @@ import ( "fmt" "strings" - agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" ) // SessionWorkdirSetter 定义设置会话工作目录所需的最小 runtime 能力。 type SessionWorkdirSetter interface { - SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) } // SessionWorkdirCommandResult 表示工作目录命令执行结果。 diff --git a/internal/tui/core/commands/workspace_test.go b/internal/tui/core/commands/workspace_test.go index 53650046..4ed48934 100644 --- a/internal/tui/core/commands/workspace_test.go +++ b/internal/tui/core/commands/workspace_test.go @@ -8,20 +8,20 @@ import ( "strings" "testing" - agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" tuiworkspace "neo-code/internal/tui/core/workspace" ) type stubSessionWorkdirSetter struct { - session agentruntime.Session + session agentsession.Session err error calls int } -func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentruntime.Session, error) { +func (s *stubSessionWorkdirSetter) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { s.calls++ if s.err != nil { - return agentruntime.Session{}, s.err + return agentsession.Session{}, s.err } return s.session, nil } @@ -91,7 +91,7 @@ func TestExecuteSessionWorkdirCommand(t *testing.T) { t.Run("runtime empty workdir fallback", func(t *testing.T) { current := t.TempDir() - stub := &stubSessionWorkdirSetter{session: agentruntime.Session{ID: "session-1", Workdir: ""}} + stub := &stubSessionWorkdirSetter{session: agentsession.Session{ID: "session-1", Workdir: ""}} result := ExecuteSessionWorkdirCommand(stub, "session-1", current, "/cwd sub", parse, tuiworkspace.ResolveWorkspacePath, tuiworkspace.SelectSessionWorkdir) if result.Err != nil { t.Fatalf("unexpected error: %v", result.Err) From 0292f648e5adb777c429533487cfc61514832f36 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 8 Apr 2026 15:35:46 +0800 Subject: [PATCH 16/54] =?UTF-8?q?fix(runtime)=EF=BC=9A=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/runtime.go | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 8f38a8a4..2e13466d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -133,17 +133,17 @@ type ProviderFactory interface { } type Service struct { - configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 - sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 - toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 - providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 - contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 - compactRunner contextcompact.Runner - events chan RuntimeEvent - operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 - runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 - activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 - nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 + configManager *config.Manager // 配置管理器,提供当前选中的 provider、model、workdir 等配置读取能力。 + sessionStore agentsession.Store // 会话持久化接口,负责保存和加载聊天会话。 + toolManager tools.Manager // 工具管理器,统一工具 schema 暴露与执行入口。 + providerFactory ProviderFactory // Provider 工厂接口,根据配置动态创建具体的 provider 实例。 + contextBuilder agentcontext.Builder // 上下文构建器,负责组装 system prompt 与本轮发给模型的消息上下文。 + compactRunner contextcompact.Runner + events chan RuntimeEvent + operationMu sync.Mutex // 运行级互斥:串行化 Run 与 Compact,避免并发写同一会话。 + runMu sync.Mutex // 仅保护 activeRun* 字段的并发读写。 + activeRunToken uint64 // 当前活跃运行的令牌标识,用于标记正在执行的 Run 实例。 + nextRunToken uint64 // 下一个运行令牌的递增计数器,用于区分不同 Run 的生命周期。 activeRunCancel context.CancelFunc // 当前活跃 Run 的取消函数。 sessionInputTokens int // 当前会话累计输入 token。 sessionOutputTokens int // 当前会话累计输出 token。 @@ -532,12 +532,16 @@ func handleProviderStreamEvent( acc.accumulateToolCallDelta(payload.Index, payload.ID, payload.ArgumentsDelta) } case providertypes.StreamEventMessageDone: - if _, err := event.MessageDoneValue(); err != nil { + payload, err := event.MessageDoneValue() + if err != nil { return err } if acc != nil { acc.messageDone = true } + if onMessageDone != nil { + onMessageDone(payload) + } default: return fmt.Errorf("runtime: unsupported provider stream event type %q", event.Type) } @@ -579,8 +583,8 @@ func (s *Service) forwardProviderEvents( s.sessionInputTokens += done.Usage.InputTokens s.sessionOutputTokens += done.Usage.OutputTokens s.emit(ctx, EventTokenUsage, runID, sessionID, TokenUsagePayload{ - InputTokens: done.Usage.InputTokens, - OutputTokens: done.Usage.OutputTokens, + InputTokens: done.Usage.InputTokens, + OutputTokens: done.Usage.OutputTokens, SessionInputTokens: s.sessionInputTokens, SessionOutputTokens: s.sessionOutputTokens, }) From 57c13407e545b1ac82c9d65f4e5b868e8ff967b7 Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 8 Apr 2026 15:36:02 +0800 Subject: [PATCH 17/54] test: add bootstrap tests to improve coverage --- internal/tui/bootstrap/builder_test.go | 166 +++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 internal/tui/bootstrap/builder_test.go diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go new file mode 100644 index 00000000..71a9cf06 --- /dev/null +++ b/internal/tui/bootstrap/builder_test.go @@ -0,0 +1,166 @@ +package bootstrap + +import ( + "context" + "testing" + + "neo-code/internal/config" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" +) + +type testRuntime struct{} + +func (r *testRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *testRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *testRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r *testRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *testRuntime) CancelActiveRun() bool { + return false +} + +func (r *testRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r *testRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r *testRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +type testProviderService struct{} + +func (s *testProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s *testProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s *testProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *testProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s *testProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func TestBuild(t *testing.T) { + t.Run("success", func(t *testing.T) { + manager := &config.Manager{} + runtime := &testRuntime{} + providerSvc := &testProviderService{} + + container, err := Build(Options{ + ConfigManager: manager, + Runtime: runtime, + ProviderService: providerSvc, + }) + if err != nil { + t.Fatalf("Build() error = %v", err) + } + if container.ConfigManager != manager { + t.Error("expected ConfigManager to be set") + } + }) + + t.Run("nil config manager", func(t *testing.T) { + _, err := Build(Options{ + ConfigManager: nil, + Runtime: &testRuntime{}, + ProviderService: &testProviderService{}, + }) + if err == nil { + t.Fatal("expected error for nil config manager") + } + }) + + t.Run("nil runtime", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: nil, + ProviderService: &testProviderService{}, + }) + if err == nil { + t.Fatal("expected error for nil runtime") + } + }) + + t.Run("nil provider service", func(t *testing.T) { + manager := &config.Manager{} + _, err := Build(Options{ + ConfigManager: manager, + Runtime: &testRuntime{}, + ProviderService: nil, + }) + if err == nil { + t.Fatal("expected error for nil provider service") + } + }) +} + +func TestResolveConfigSnapshot(t *testing.T) { + t.Run("nil config returns manager get", func(t *testing.T) { + manager := &config.Manager{} + cfg := resolveConfigSnapshot(nil, manager) + if cfg.Workdir == "" && cfg.Shell == "" { + t.Log("config returned from manager") + } + }) + + t.Run("config provided returns clone", func(t *testing.T) { + manager := &config.Manager{} + inputCfg := &config.Config{ + Workdir: "/test", + } + cfg := resolveConfigSnapshot(inputCfg, manager) + if cfg.Workdir != "/test" { + t.Errorf("expected Workdir /test, got %s", cfg.Workdir) + } + }) +} + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + name string + input Mode + want Mode + }{ + {"empty becomes live", "", ModeLive}, + {"live stays live", ModeLive, ModeLive}, + {"offline stays offline", ModeOffline, ModeOffline}, + {"mock stays mock", ModeMock, ModeMock}, + {"unknown becomes live", Mode("unknown"), ModeLive}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeMode(tt.input); got != tt.want { + t.Errorf("NormalizeMode(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} From 5908332f6ae3e24e9a5ca1601ae5254732ca93ed Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 15:40:46 +0800 Subject: [PATCH 18/54] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=9A=94=E7=A6=BB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 Cobra CLI 根命令,并新增 --workdir 启动参数。 将 Windows UTF-8 控制台初始化前置到 CLI 与 TUI 共用启动路径,保证中文路径与中文输出兼容。 会话存储目录切换为 ~/.neocode/projects//sessions,并保持 session.Workdir 仅表示运行目录。 补充工作区路径规范化、中文路径分桶、启动覆盖与相关回归测试,同时更新 README 与文档说明。 --- README.md | 14 ++++ cmd/neocode/main.go | 10 +-- docs/guides/configuration.md | 14 ++++ docs/session-persistence-design.md | 7 ++ go.mod | 21 +++-- go.sum | 74 +++++++++++++----- internal/app/bootstrap.go | 101 ++++++++++++++++++++---- internal/app/bootstrap_test.go | 46 ++++++++++- internal/cli/root.go | 58 ++++++++++++++ internal/cli/root_test.go | 64 +++++++++++++++ internal/runtime/runtime_test.go | 2 +- internal/session/store.go | 22 +++--- internal/session/store_test.go | 121 +++++++++++++++++++++++------ internal/session/workspace.go | 48 ++++++++++++ 14 files changed, 515 insertions(+), 87 deletions(-) create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/session/workspace.go diff --git a/README.md b/README.md index 33fdaa73..95135439 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,17 @@ MIT ## Manual Compact NeoCode 支持通过 `/compact` 手动压缩当前会话上下文。配置项见 `docs/guides/configuration.md`,流程和摘要约定见 `docs/context-compact.md`。 + +## CLI Workdir + +NeoCode 现在支持通过 CLI 启动参数覆盖本次运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +说明: + +- `--workdir` 只影响当前进程,不会写回 `config.yaml` +- 当前工作区会同时用于工具执行根目录与 session 存储分桶 +- session 历史现在按工作区隔离存储,不同工作区默认互不可见 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 68d741d3..1926ff8e 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -5,17 +5,11 @@ import ( "fmt" "os" - "neo-code/internal/app" + "neo-code/internal/cli" ) func main() { - program, err := app.NewProgram(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "neocode: %v\n", err) - os.Exit(1) - } - - if _, err := program.Run(); err != nil { + if err := cli.Execute(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "neocode: %v\n", err) os.Exit(1) } diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 26a5800e..07d3e01b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -330,3 +330,17 @@ context: 新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。 更多行为说明见 [context-compact.md](../context-compact.md)。 + +## CLI 工作区覆盖 + +NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +补充说明: + +- `--workdir` 只影响本次启动,不会持久化到 `config.yaml` +- 运行时工具根目录与 session 存储分桶都会使用该工作区 +- session 现按工作区隔离存储,不同工作区的历史会话默认互不可见 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index a3f40511..fb22d26d 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -35,3 +35,10 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 ## 兼容性与演进说明 - 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` - 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 + +## 工作区隔离 + +- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` +- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` +- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 +- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/go.mod b/go.mod index 671203ae..7d1726d8 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,37 @@ module neo-code go 1.25.0 require ( + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // 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 @@ -39,12 +43,19 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/sys v0.42.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 2c5bd376..56abd244 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -14,16 +18,12 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -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/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -36,26 +36,37 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Y github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -65,8 +76,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -79,37 +88,60 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 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= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index ef3a7c9d..29fa456a 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -2,6 +2,10 @@ package app import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -27,46 +31,62 @@ var ( setConsoleInputCodePage = platformSetConsoleInputCodePage ) -// ensureConsoleUTF8 is best-effort and should never block app startup. -func ensureConsoleUTF8() { +// BootstrapOptions 描述应用启动时可注入的运行时选项。 +type BootstrapOptions struct { + Workdir string +} + +// RuntimeBundle 聚合 CLI 与 TUI 共享的运行时依赖。 +type RuntimeBundle struct { + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderSelection *config.SelectionService +} + +// EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。 +func EnsureConsoleUTF8() { if err := setConsoleOutputCodePage(utf8CodePage); err != nil { return } _ = setConsoleInputCodePage(utf8CodePage) } -func NewProgram(ctx context.Context) (*tea.Program, error) { - - ensureConsoleUTF8() +// BuildRuntime 构建 CLI 与 TUI 共用的运行时依赖。 +func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, error) { + defaultCfg, err := bootstrapDefaultConfig(opts) + if err != nil { + return RuntimeBundle{}, err + } - loader := config.NewLoader("", config.DefaultConfig()) + loader := config.NewLoader("", defaultCfg) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } providerRegistry, err := builtin.NewRegistry() if err != nil { - return nil, err + return RuntimeBundle{}, err } modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) providerSelection := config.NewSelectionService(manager, providerRegistry, providerRegistry, modelCatalogs) if _, err := providerSelection.EnsureSelection(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } cfg := manager.Get() toolRegistry, err := buildToolRegistry(cfg) if err != nil { - return nil, err + return RuntimeBundle{}, err } toolManager, err := buildToolManager(toolRegistry) if err != nil { - return nil, err + return RuntimeBundle{}, err } - sessionStore := agentsession.NewStore(loader.BaseDir()) + sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, @@ -75,7 +95,22 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { agentcontext.NewBuilderWithToolPolicies(toolRegistry), ) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection) + return RuntimeBundle{ + Config: cfg, + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderSelection: providerSelection, + }, nil +} + +// NewProgram 基于共享运行时依赖构建并返回 TUI 程序。 +func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error) { + bundle, err := BuildRuntime(ctx, opts) + if err != nil { + return nil, err + } + + tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection) if err != nil { return nil, err } @@ -86,6 +121,46 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { ), nil } +// bootstrapDefaultConfig 负责计算本次启动应使用的默认配置快照。 +func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { + defaultCfg := config.DefaultConfig() + workdir := strings.TrimSpace(opts.Workdir) + if workdir == "" { + return defaultCfg, nil + } + + resolved, err := resolveBootstrapWorkdir(workdir) + if err != nil { + return nil, err + } + defaultCfg.Workdir = resolved + return defaultCfg, nil +} + +// resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。 +func resolveBootstrapWorkdir(workdir string) (string, error) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" { + return "", fmt.Errorf("app: workdir is empty") + } + + absolute, err := filepath.Abs(trimmed) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + absolute = filepath.Clean(absolute) + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("app: workdir %q is not a directory", absolute) + } + + return absolute, nil +} + func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { toolRegistry := tools.NewRegistry() toolRegistry.Register(filesystem.New(cfg.Workdir)) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a81903c0..f0a598ec 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -29,7 +29,7 @@ func TestNewProgram(t *testing.T) { t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -61,7 +61,7 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) { t.Fatalf("write config: %v", err) } - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -515,6 +515,44 @@ func TestBuildToolManagerAllowsWebfetchWhitelist(t *testing.T) { } } +func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + override := filepath.Join(t.TempDir(), "中文工作区") + if err := os.MkdirAll(override, 0o755); err != nil { + t.Fatalf("mkdir override workdir: %v", err) + } + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: override}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + if bundle.Config.Workdir != filepath.Clean(override) { + t.Fatalf("expected workdir %q, got %q", filepath.Clean(override), bundle.Config.Workdir) + } + if bundle.ConfigManager == nil || bundle.Runtime == nil || bundle.ProviderSelection == nil { + t.Fatalf("expected runtime bundle dependencies, got %+v", bundle) + } +} + +func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + invalid := filepath.Join(t.TempDir(), "missing", "中文") + _, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: invalid}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve workdir") { + t.Fatalf("expected resolve workdir error, got %v", err) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage @@ -539,7 +577,7 @@ func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { t.Fatalf("expected output->input order, got %+v", calls) @@ -564,7 +602,7 @@ func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if inputCalled { t.Fatalf("expected input code page setup to be skipped when output setup fails") diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 00000000..094135c5 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "neo-code/internal/app" +) + +var launchRootProgram = defaultRootProgramLauncher + +// GlobalFlags 描述 CLI 根命令当前支持的全局参数。 +type GlobalFlags struct { + Workdir string +} + +// Execute 负责执行 NeoCode 的 CLI 根命令。 +func Execute(ctx context.Context) error { + app.EnsureConsoleUTF8() + return NewRootCommand().ExecuteContext(ctx) +} + +// NewRootCommand 创建 NeoCode 的 CLI 根命令。 +func NewRootCommand() *cobra.Command { + settings := viper.New() + flags := &GlobalFlags{} + + cmd := &cobra.Command{ + Use: "neocode", + Short: "NeoCode coding agent", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.Workdir = strings.TrimSpace(settings.GetString("workdir")) + return launchRootProgram(cmd.Context(), app.BootstrapOptions{ + Workdir: flags.Workdir, + }) + }, + } + + cmd.PersistentFlags().String("workdir", "", "工作目录(覆盖本次运行工作区)") + _ = settings.BindPFlag("workdir", cmd.PersistentFlags().Lookup("workdir")) + + return cmd +} + +// defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 +func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { + program, err := app.NewProgram(ctx, opts) + if err != nil { + return err + } + _, err = program.Run() + return err +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 00000000..5a63c566 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "neo-code/internal/app" +) + +func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"--workdir", `D:\项目\中文目录`}) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != `D:\项目\中文目录` { + t.Fatalf("expected workdir to be forwarded, got %q", captured.Workdir) + } +} + +func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != "" { + t.Fatalf("expected empty workdir override, got %q", captured.Workdir) + } +} + +func TestNewRootCommandReturnsLauncherError(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + expected := errors.New("launch failed") + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + return expected + } + + cmd := NewRootCommand() + err := cmd.ExecuteContext(context.Background()) + if !errors.Is(err, expected) { + t.Fatalf("expected launcher error %v, got %v", expected, err) + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 97be69ed..fbcc27f0 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2311,7 +2311,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) } - sessionStore := agentsession.NewStore(t.TempDir()) + sessionStore := agentsession.NewStore(t.TempDir(), t.TempDir()) if sessionStore == nil { t.Fatalf("expected JSON session store") } diff --git a/internal/session/store.go b/internal/session/store.go index fb660dd2..190618ff 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -25,13 +25,13 @@ type Session struct { // Provider 记录最近一次成功运行会话时使用的 provider,用于 compact 优先复用历史配置。 Provider string `json:"provider,omitempty"` // Model 记录最近一次成功运行会话时使用的 model,用于 compact 优先复用历史配置。 - Model string `json:"model,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"workdir,omitempty"` - Messages []providertypes.Message `json:"messages"` - TokenInputTotal int `json:"token_input_total,omitempty"` - TokenOutputTotal int `json:"token_output_total,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"workdir,omitempty"` + Messages []providertypes.Message `json:"messages"` + TokenInputTotal int `json:"token_input_total,omitempty"` + TokenOutputTotal int `json:"token_output_total,omitempty"` } // Summary 表示会话列表视图所需的轻量摘要信息。 @@ -56,15 +56,15 @@ type JSONStore struct { } // NewJSONStore 创建 JSONStore,实际会话目录为 {baseDir}/sessions。 -func NewJSONStore(baseDir string) *JSONStore { +func NewJSONStore(baseDir string, workspaceRoot string) *JSONStore { return &JSONStore{ - baseDir: filepath.Join(baseDir, sessionsDirName), + baseDir: sessionDirectory(baseDir, workspaceRoot), } } // NewStore 返回默认会话存储实现(当前为 JSONStore)。 -func NewStore(baseDir string) *JSONStore { - return NewJSONStore(baseDir) +func NewStore(baseDir string, workspaceRoot string) *JSONStore { + return NewJSONStore(baseDir, workspaceRoot) } // Save 持久化会话到 JSON 文件,采用临时文件 + 原子替换策略。 diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 6b412322..aab03df2 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -17,7 +17,11 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspaceRoot, 0o755); err != nil { + t.Fatalf("mkdir workspace root: %v", err) + } + store := NewJSONStore(baseDir, workspaceRoot) older := &Session{ ID: "session-old", @@ -61,7 +65,7 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } - rawPath := filepath.Join(baseDir, sessionsDirName, newer.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), newer.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read saved session: %v", err) @@ -70,8 +74,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("expected persisted session file to include workdir, got:\n%s", string(raw)) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") - if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "invalid.json"), "{invalid") + if err := os.MkdirAll(filepath.Join(sessionDirectory(baseDir, workspaceRoot), "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) } @@ -87,11 +91,78 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { } } +func TestJSONStoreScopesSessionsByWorkspaceRoot(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceA := filepath.Join(t.TempDir(), "中文项目A") + workspaceB := filepath.Join(t.TempDir(), "中文项目B") + if err := os.MkdirAll(workspaceA, 0o755); err != nil { + t.Fatalf("mkdir workspaceA: %v", err) + } + if err := os.MkdirAll(workspaceB, 0o755); err != nil { + t.Fatalf("mkdir workspaceB: %v", err) + } + + storeA := NewJSONStore(baseDir, workspaceA) + storeB := NewJSONStore(baseDir, workspaceB) + + sessionA := &Session{ID: "session-a", Title: "A", CreatedAt: time.Now(), UpdatedAt: time.Now()} + sessionB := &Session{ID: "session-b", Title: "B", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := storeA.Save(context.Background(), sessionA); err != nil { + t.Fatalf("save sessionA: %v", err) + } + if err := storeB.Save(context.Background(), sessionB); err != nil { + t.Fatalf("save sessionB: %v", err) + } + + summariesA, err := storeA.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeA error: %v", err) + } + if len(summariesA) != 1 || summariesA[0].ID != sessionA.ID { + t.Fatalf("expected storeA to only list sessionA, got %+v", summariesA) + } + + summariesB, err := storeB.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeB error: %v", err) + } + if len(summariesB) != 1 || summariesB[0].ID != sessionB.ID { + t.Fatalf("expected storeB to only list sessionB, got %+v", summariesB) + } + + if _, err := storeA.Load(context.Background(), sessionB.ID); err == nil { + t.Fatalf("expected storeA to fail loading session from another workspace bucket") + } +} + +func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { + t.Parallel() + + base := filepath.Join(t.TempDir(), "中文项目") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatalf("mkdir base: %v", err) + } + + normalized := normalizeWorkspaceRoot(base) + slashVariant := strings.ReplaceAll(normalized, `\`, `/`) + if got, want := hashWorkspaceRoot(normalized), hashWorkspaceRoot(slashVariant); got != want { + t.Fatalf("expected slash variants to hash equally, got %q and %q", got, want) + } + + upperVariant := strings.ToUpper(normalized) + lowerVariant := strings.ToLower(normalized) + if got, want := hashWorkspaceRoot(upperVariant), hashWorkspaceRoot(lowerVariant); got != want { + t.Fatalf("expected case variants to hash equally, got %q and %q", got, want) + } +} + func TestJSONStoreErrors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() @@ -114,7 +185,8 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-session", @@ -127,7 +199,7 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Fatalf("Save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "broken.json"), "{broken") _, err := store.Load(context.Background(), "broken") if err == nil || !strings.Contains(err.Error(), "decode session broken") { @@ -152,7 +224,7 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - store := NewJSONStore(baseFile) + store := NewJSONStore(baseFile, t.TempDir()) err := store.Save(context.Background(), &Session{ ID: "session-x", Title: "Broken Save", @@ -195,7 +267,7 @@ func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { func TestNewWithWorkdirTrimAndTitleSanitize(t *testing.T) { t.Parallel() - tooLong := strings.Repeat("中", 45) // rune 长度 > 40 + tooLong := strings.Repeat("测", 45) workdir := " /tmp/workdir " session := NewWithWorkdir(tooLong, workdir) @@ -221,7 +293,7 @@ func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { func TestNewStoreReturnsJSONStore(t *testing.T) { t.Parallel() - store := NewStore(t.TempDir()) + store := NewStore(t.TempDir(), t.TempDir()) if store == nil { t.Fatalf("expected non-nil store") } @@ -231,13 +303,11 @@ func TestJSONStoreListSummariesReadDirFailure(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - // 把 sessions 目录位置占成普通文件,触发 ReadDir 失败路径。 - sessionsPath := filepath.Join(baseDir, sessionsDirName) - if err := os.WriteFile(sessionsPath, []byte("not-a-dir"), 0o644); err != nil { - t.Fatalf("write %s: %v", sessionsPath, err) - } + sessionsPath := sessionDirectory(baseDir, workspaceRoot) + mustWriteSessionFile(t, sessionsPath, "not-a-dir") _, err := store.ListSummaries(context.Background()) if err == nil || !strings.Contains(err.Error(), "create sessions dir") { @@ -249,7 +319,7 @@ func TestJSONStoreListSummariesContextCanceledDuringIteration(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) for i := 0; i < 10; i++ { s := &Session{ @@ -276,9 +346,10 @@ func TestJSONStoreLoadDecodeErrorWithNonJSONPayload(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "decode-bad.json"), "{not-json") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "decode-bad.json"), "{not-json") _, err := store.Load(context.Background(), "decode-bad") if err == nil || !strings.Contains(err.Error(), "decode session decode-bad") { @@ -290,7 +361,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-summary", @@ -302,8 +374,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Fatalf("save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "malformed.json"), "{malformed") - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "empty-id.json"), `{"id":" ","title":"x"}`) + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "malformed.json"), "{malformed") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "empty-id.json"), `{"id":" ","title":"x"}`) summaries, err := store.ListSummaries(context.Background()) if err != nil { @@ -318,7 +390,8 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) session := &Session{ ID: "persist-full-fields", @@ -345,7 +418,7 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Fatalf("save session: %v", err) } - rawPath := filepath.Join(baseDir, sessionsDirName, session.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), session.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read raw file: %v", err) diff --git a/internal/session/workspace.go b/internal/session/workspace.go new file mode 100644 index 00000000..686883e7 --- /dev/null +++ b/internal/session/workspace.go @@ -0,0 +1,48 @@ +package session + +import ( + "crypto/sha1" + "encoding/hex" + "path/filepath" + "strings" +) + +const projectsDirName = "projects" + +// sessionDirectory 负责根据工作区根目录计算会话分桶目录。 +func sessionDirectory(baseDir string, workspaceRoot string) string { + return filepath.Join(baseDir, projectsDirName, hashWorkspaceRoot(workspaceRoot), sessionsDirName) +} + +// hashWorkspaceRoot 负责为规范化后的工作区根目录生成稳定哈希。 +func hashWorkspaceRoot(workspaceRoot string) string { + key := workspacePathKey(workspaceRoot) + if key == "" { + key = "unknown" + } + sum := sha1.Sum([]byte(key)) + return hex.EncodeToString(sum[:8]) +} + +// workspacePathKey 负责生成工作区路径的稳定比较键,并与项目级 transcript 哈希规则保持一致。 +func workspacePathKey(workspaceRoot string) string { + normalized := normalizeWorkspaceRoot(workspaceRoot) + if normalized == "" { + return "" + } + return strings.ToLower(normalized) +} + +// normalizeWorkspaceRoot 负责将工作区根目录规范化为绝对清洗路径。 +func normalizeWorkspaceRoot(workspaceRoot string) string { + trimmed := strings.TrimSpace(workspaceRoot) + if trimmed == "" { + return "" + } + + absolute, err := filepath.Abs(trimmed) + if err == nil { + trimmed = absolute + } + return filepath.Clean(trimmed) +} From 31c0473224ea89a4a8a7d44e63681fa341aa6996 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 15:40:46 +0800 Subject: [PATCH 19/54] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=9A=94=E7=A6=BB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 Cobra CLI 根命令,并新增 --workdir 启动参数。 将 Windows UTF-8 控制台初始化前置到 CLI 与 TUI 共用启动路径,保证中文路径与中文输出兼容。 会话存储目录切换为 ~/.neocode/projects//sessions,并保持 session.Workdir 仅表示运行目录。 补充工作区路径规范化、中文路径分桶、启动覆盖与相关回归测试,同时更新 README 与文档说明。 --- README.md | 14 ++++ cmd/neocode/main.go | 10 +-- docs/guides/configuration.md | 14 ++++ docs/session-persistence-design.md | 7 ++ go.mod | 21 +++-- go.sum | 74 +++++++++++++----- internal/app/bootstrap.go | 101 ++++++++++++++++++++---- internal/app/bootstrap_test.go | 46 ++++++++++- internal/cli/root.go | 58 ++++++++++++++ internal/cli/root_test.go | 64 +++++++++++++++ internal/runtime/runtime_test.go | 2 +- internal/session/store.go | 22 +++--- internal/session/store_test.go | 121 +++++++++++++++++++++++------ internal/session/workspace.go | 48 ++++++++++++ 14 files changed, 515 insertions(+), 87 deletions(-) create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/session/workspace.go diff --git a/README.md b/README.md index 33fdaa73..95135439 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,17 @@ MIT ## Manual Compact NeoCode 支持通过 `/compact` 手动压缩当前会话上下文。配置项见 `docs/guides/configuration.md`,流程和摘要约定见 `docs/context-compact.md`。 + +## CLI Workdir + +NeoCode 现在支持通过 CLI 启动参数覆盖本次运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +说明: + +- `--workdir` 只影响当前进程,不会写回 `config.yaml` +- 当前工作区会同时用于工具执行根目录与 session 存储分桶 +- session 历史现在按工作区隔离存储,不同工作区默认互不可见 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 68d741d3..1926ff8e 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -5,17 +5,11 @@ import ( "fmt" "os" - "neo-code/internal/app" + "neo-code/internal/cli" ) func main() { - program, err := app.NewProgram(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "neocode: %v\n", err) - os.Exit(1) - } - - if _, err := program.Run(); err != nil { + if err := cli.Execute(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "neocode: %v\n", err) os.Exit(1) } diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 26a5800e..07d3e01b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -330,3 +330,17 @@ context: 新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。 更多行为说明见 [context-compact.md](../context-compact.md)。 + +## CLI 工作区覆盖 + +NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +补充说明: + +- `--workdir` 只影响本次启动,不会持久化到 `config.yaml` +- 运行时工具根目录与 session 存储分桶都会使用该工作区 +- session 现按工作区隔离存储,不同工作区的历史会话默认互不可见 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index a3f40511..fb22d26d 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -35,3 +35,10 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 ## 兼容性与演进说明 - 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` - 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 + +## 工作区隔离 + +- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` +- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` +- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 +- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/go.mod b/go.mod index 671203ae..7d1726d8 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,37 @@ module neo-code go 1.25.0 require ( + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // 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 @@ -39,12 +43,19 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/sys v0.42.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 2c5bd376..56abd244 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -14,16 +18,12 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 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/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -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/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -36,26 +36,37 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Y github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -65,8 +76,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -79,37 +88,60 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 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= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index ef3a7c9d..29fa456a 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -2,6 +2,10 @@ package app import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -27,46 +31,62 @@ var ( setConsoleInputCodePage = platformSetConsoleInputCodePage ) -// ensureConsoleUTF8 is best-effort and should never block app startup. -func ensureConsoleUTF8() { +// BootstrapOptions 描述应用启动时可注入的运行时选项。 +type BootstrapOptions struct { + Workdir string +} + +// RuntimeBundle 聚合 CLI 与 TUI 共享的运行时依赖。 +type RuntimeBundle struct { + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderSelection *config.SelectionService +} + +// EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。 +func EnsureConsoleUTF8() { if err := setConsoleOutputCodePage(utf8CodePage); err != nil { return } _ = setConsoleInputCodePage(utf8CodePage) } -func NewProgram(ctx context.Context) (*tea.Program, error) { - - ensureConsoleUTF8() +// BuildRuntime 构建 CLI 与 TUI 共用的运行时依赖。 +func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, error) { + defaultCfg, err := bootstrapDefaultConfig(opts) + if err != nil { + return RuntimeBundle{}, err + } - loader := config.NewLoader("", config.DefaultConfig()) + loader := config.NewLoader("", defaultCfg) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } providerRegistry, err := builtin.NewRegistry() if err != nil { - return nil, err + return RuntimeBundle{}, err } modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) providerSelection := config.NewSelectionService(manager, providerRegistry, providerRegistry, modelCatalogs) if _, err := providerSelection.EnsureSelection(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } cfg := manager.Get() toolRegistry, err := buildToolRegistry(cfg) if err != nil { - return nil, err + return RuntimeBundle{}, err } toolManager, err := buildToolManager(toolRegistry) if err != nil { - return nil, err + return RuntimeBundle{}, err } - sessionStore := agentsession.NewStore(loader.BaseDir()) + sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, @@ -75,7 +95,22 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { agentcontext.NewBuilderWithToolPolicies(toolRegistry), ) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection) + return RuntimeBundle{ + Config: cfg, + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderSelection: providerSelection, + }, nil +} + +// NewProgram 基于共享运行时依赖构建并返回 TUI 程序。 +func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error) { + bundle, err := BuildRuntime(ctx, opts) + if err != nil { + return nil, err + } + + tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection) if err != nil { return nil, err } @@ -86,6 +121,46 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { ), nil } +// bootstrapDefaultConfig 负责计算本次启动应使用的默认配置快照。 +func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { + defaultCfg := config.DefaultConfig() + workdir := strings.TrimSpace(opts.Workdir) + if workdir == "" { + return defaultCfg, nil + } + + resolved, err := resolveBootstrapWorkdir(workdir) + if err != nil { + return nil, err + } + defaultCfg.Workdir = resolved + return defaultCfg, nil +} + +// resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。 +func resolveBootstrapWorkdir(workdir string) (string, error) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" { + return "", fmt.Errorf("app: workdir is empty") + } + + absolute, err := filepath.Abs(trimmed) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + absolute = filepath.Clean(absolute) + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("app: workdir %q is not a directory", absolute) + } + + return absolute, nil +} + func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { toolRegistry := tools.NewRegistry() toolRegistry.Register(filesystem.New(cfg.Workdir)) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a81903c0..f0a598ec 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -29,7 +29,7 @@ func TestNewProgram(t *testing.T) { t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -61,7 +61,7 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) { t.Fatalf("write config: %v", err) } - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -515,6 +515,44 @@ func TestBuildToolManagerAllowsWebfetchWhitelist(t *testing.T) { } } +func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + override := filepath.Join(t.TempDir(), "中文工作区") + if err := os.MkdirAll(override, 0o755); err != nil { + t.Fatalf("mkdir override workdir: %v", err) + } + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: override}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + if bundle.Config.Workdir != filepath.Clean(override) { + t.Fatalf("expected workdir %q, got %q", filepath.Clean(override), bundle.Config.Workdir) + } + if bundle.ConfigManager == nil || bundle.Runtime == nil || bundle.ProviderSelection == nil { + t.Fatalf("expected runtime bundle dependencies, got %+v", bundle) + } +} + +func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + invalid := filepath.Join(t.TempDir(), "missing", "中文") + _, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: invalid}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve workdir") { + t.Fatalf("expected resolve workdir error, got %v", err) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage @@ -539,7 +577,7 @@ func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { t.Fatalf("expected output->input order, got %+v", calls) @@ -564,7 +602,7 @@ func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if inputCalled { t.Fatalf("expected input code page setup to be skipped when output setup fails") diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 00000000..094135c5 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "neo-code/internal/app" +) + +var launchRootProgram = defaultRootProgramLauncher + +// GlobalFlags 描述 CLI 根命令当前支持的全局参数。 +type GlobalFlags struct { + Workdir string +} + +// Execute 负责执行 NeoCode 的 CLI 根命令。 +func Execute(ctx context.Context) error { + app.EnsureConsoleUTF8() + return NewRootCommand().ExecuteContext(ctx) +} + +// NewRootCommand 创建 NeoCode 的 CLI 根命令。 +func NewRootCommand() *cobra.Command { + settings := viper.New() + flags := &GlobalFlags{} + + cmd := &cobra.Command{ + Use: "neocode", + Short: "NeoCode coding agent", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.Workdir = strings.TrimSpace(settings.GetString("workdir")) + return launchRootProgram(cmd.Context(), app.BootstrapOptions{ + Workdir: flags.Workdir, + }) + }, + } + + cmd.PersistentFlags().String("workdir", "", "工作目录(覆盖本次运行工作区)") + _ = settings.BindPFlag("workdir", cmd.PersistentFlags().Lookup("workdir")) + + return cmd +} + +// defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 +func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { + program, err := app.NewProgram(ctx, opts) + if err != nil { + return err + } + _, err = program.Run() + return err +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 00000000..5a63c566 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "neo-code/internal/app" +) + +func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"--workdir", `D:\项目\中文目录`}) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != `D:\项目\中文目录` { + t.Fatalf("expected workdir to be forwarded, got %q", captured.Workdir) + } +} + +func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != "" { + t.Fatalf("expected empty workdir override, got %q", captured.Workdir) + } +} + +func TestNewRootCommandReturnsLauncherError(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + expected := errors.New("launch failed") + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + return expected + } + + cmd := NewRootCommand() + err := cmd.ExecuteContext(context.Background()) + if !errors.Is(err, expected) { + t.Fatalf("expected launcher error %v, got %v", expected, err) + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 97be69ed..fbcc27f0 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2311,7 +2311,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) } - sessionStore := agentsession.NewStore(t.TempDir()) + sessionStore := agentsession.NewStore(t.TempDir(), t.TempDir()) if sessionStore == nil { t.Fatalf("expected JSON session store") } diff --git a/internal/session/store.go b/internal/session/store.go index fb660dd2..190618ff 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -25,13 +25,13 @@ type Session struct { // Provider 记录最近一次成功运行会话时使用的 provider,用于 compact 优先复用历史配置。 Provider string `json:"provider,omitempty"` // Model 记录最近一次成功运行会话时使用的 model,用于 compact 优先复用历史配置。 - Model string `json:"model,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"workdir,omitempty"` - Messages []providertypes.Message `json:"messages"` - TokenInputTotal int `json:"token_input_total,omitempty"` - TokenOutputTotal int `json:"token_output_total,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"workdir,omitempty"` + Messages []providertypes.Message `json:"messages"` + TokenInputTotal int `json:"token_input_total,omitempty"` + TokenOutputTotal int `json:"token_output_total,omitempty"` } // Summary 表示会话列表视图所需的轻量摘要信息。 @@ -56,15 +56,15 @@ type JSONStore struct { } // NewJSONStore 创建 JSONStore,实际会话目录为 {baseDir}/sessions。 -func NewJSONStore(baseDir string) *JSONStore { +func NewJSONStore(baseDir string, workspaceRoot string) *JSONStore { return &JSONStore{ - baseDir: filepath.Join(baseDir, sessionsDirName), + baseDir: sessionDirectory(baseDir, workspaceRoot), } } // NewStore 返回默认会话存储实现(当前为 JSONStore)。 -func NewStore(baseDir string) *JSONStore { - return NewJSONStore(baseDir) +func NewStore(baseDir string, workspaceRoot string) *JSONStore { + return NewJSONStore(baseDir, workspaceRoot) } // Save 持久化会话到 JSON 文件,采用临时文件 + 原子替换策略。 diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 6b412322..aab03df2 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -17,7 +17,11 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspaceRoot, 0o755); err != nil { + t.Fatalf("mkdir workspace root: %v", err) + } + store := NewJSONStore(baseDir, workspaceRoot) older := &Session{ ID: "session-old", @@ -61,7 +65,7 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } - rawPath := filepath.Join(baseDir, sessionsDirName, newer.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), newer.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read saved session: %v", err) @@ -70,8 +74,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("expected persisted session file to include workdir, got:\n%s", string(raw)) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") - if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "invalid.json"), "{invalid") + if err := os.MkdirAll(filepath.Join(sessionDirectory(baseDir, workspaceRoot), "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) } @@ -87,11 +91,78 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { } } +func TestJSONStoreScopesSessionsByWorkspaceRoot(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceA := filepath.Join(t.TempDir(), "中文项目A") + workspaceB := filepath.Join(t.TempDir(), "中文项目B") + if err := os.MkdirAll(workspaceA, 0o755); err != nil { + t.Fatalf("mkdir workspaceA: %v", err) + } + if err := os.MkdirAll(workspaceB, 0o755); err != nil { + t.Fatalf("mkdir workspaceB: %v", err) + } + + storeA := NewJSONStore(baseDir, workspaceA) + storeB := NewJSONStore(baseDir, workspaceB) + + sessionA := &Session{ID: "session-a", Title: "A", CreatedAt: time.Now(), UpdatedAt: time.Now()} + sessionB := &Session{ID: "session-b", Title: "B", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := storeA.Save(context.Background(), sessionA); err != nil { + t.Fatalf("save sessionA: %v", err) + } + if err := storeB.Save(context.Background(), sessionB); err != nil { + t.Fatalf("save sessionB: %v", err) + } + + summariesA, err := storeA.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeA error: %v", err) + } + if len(summariesA) != 1 || summariesA[0].ID != sessionA.ID { + t.Fatalf("expected storeA to only list sessionA, got %+v", summariesA) + } + + summariesB, err := storeB.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeB error: %v", err) + } + if len(summariesB) != 1 || summariesB[0].ID != sessionB.ID { + t.Fatalf("expected storeB to only list sessionB, got %+v", summariesB) + } + + if _, err := storeA.Load(context.Background(), sessionB.ID); err == nil { + t.Fatalf("expected storeA to fail loading session from another workspace bucket") + } +} + +func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { + t.Parallel() + + base := filepath.Join(t.TempDir(), "中文项目") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatalf("mkdir base: %v", err) + } + + normalized := normalizeWorkspaceRoot(base) + slashVariant := strings.ReplaceAll(normalized, `\`, `/`) + if got, want := hashWorkspaceRoot(normalized), hashWorkspaceRoot(slashVariant); got != want { + t.Fatalf("expected slash variants to hash equally, got %q and %q", got, want) + } + + upperVariant := strings.ToUpper(normalized) + lowerVariant := strings.ToLower(normalized) + if got, want := hashWorkspaceRoot(upperVariant), hashWorkspaceRoot(lowerVariant); got != want { + t.Fatalf("expected case variants to hash equally, got %q and %q", got, want) + } +} + func TestJSONStoreErrors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() @@ -114,7 +185,8 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-session", @@ -127,7 +199,7 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Fatalf("Save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "broken.json"), "{broken") _, err := store.Load(context.Background(), "broken") if err == nil || !strings.Contains(err.Error(), "decode session broken") { @@ -152,7 +224,7 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - store := NewJSONStore(baseFile) + store := NewJSONStore(baseFile, t.TempDir()) err := store.Save(context.Background(), &Session{ ID: "session-x", Title: "Broken Save", @@ -195,7 +267,7 @@ func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { func TestNewWithWorkdirTrimAndTitleSanitize(t *testing.T) { t.Parallel() - tooLong := strings.Repeat("中", 45) // rune 长度 > 40 + tooLong := strings.Repeat("测", 45) workdir := " /tmp/workdir " session := NewWithWorkdir(tooLong, workdir) @@ -221,7 +293,7 @@ func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { func TestNewStoreReturnsJSONStore(t *testing.T) { t.Parallel() - store := NewStore(t.TempDir()) + store := NewStore(t.TempDir(), t.TempDir()) if store == nil { t.Fatalf("expected non-nil store") } @@ -231,13 +303,11 @@ func TestJSONStoreListSummariesReadDirFailure(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - // 把 sessions 目录位置占成普通文件,触发 ReadDir 失败路径。 - sessionsPath := filepath.Join(baseDir, sessionsDirName) - if err := os.WriteFile(sessionsPath, []byte("not-a-dir"), 0o644); err != nil { - t.Fatalf("write %s: %v", sessionsPath, err) - } + sessionsPath := sessionDirectory(baseDir, workspaceRoot) + mustWriteSessionFile(t, sessionsPath, "not-a-dir") _, err := store.ListSummaries(context.Background()) if err == nil || !strings.Contains(err.Error(), "create sessions dir") { @@ -249,7 +319,7 @@ func TestJSONStoreListSummariesContextCanceledDuringIteration(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) for i := 0; i < 10; i++ { s := &Session{ @@ -276,9 +346,10 @@ func TestJSONStoreLoadDecodeErrorWithNonJSONPayload(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "decode-bad.json"), "{not-json") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "decode-bad.json"), "{not-json") _, err := store.Load(context.Background(), "decode-bad") if err == nil || !strings.Contains(err.Error(), "decode session decode-bad") { @@ -290,7 +361,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-summary", @@ -302,8 +374,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Fatalf("save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "malformed.json"), "{malformed") - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "empty-id.json"), `{"id":" ","title":"x"}`) + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "malformed.json"), "{malformed") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "empty-id.json"), `{"id":" ","title":"x"}`) summaries, err := store.ListSummaries(context.Background()) if err != nil { @@ -318,7 +390,8 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) session := &Session{ ID: "persist-full-fields", @@ -345,7 +418,7 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Fatalf("save session: %v", err) } - rawPath := filepath.Join(baseDir, sessionsDirName, session.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), session.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read raw file: %v", err) diff --git a/internal/session/workspace.go b/internal/session/workspace.go new file mode 100644 index 00000000..686883e7 --- /dev/null +++ b/internal/session/workspace.go @@ -0,0 +1,48 @@ +package session + +import ( + "crypto/sha1" + "encoding/hex" + "path/filepath" + "strings" +) + +const projectsDirName = "projects" + +// sessionDirectory 负责根据工作区根目录计算会话分桶目录。 +func sessionDirectory(baseDir string, workspaceRoot string) string { + return filepath.Join(baseDir, projectsDirName, hashWorkspaceRoot(workspaceRoot), sessionsDirName) +} + +// hashWorkspaceRoot 负责为规范化后的工作区根目录生成稳定哈希。 +func hashWorkspaceRoot(workspaceRoot string) string { + key := workspacePathKey(workspaceRoot) + if key == "" { + key = "unknown" + } + sum := sha1.Sum([]byte(key)) + return hex.EncodeToString(sum[:8]) +} + +// workspacePathKey 负责生成工作区路径的稳定比较键,并与项目级 transcript 哈希规则保持一致。 +func workspacePathKey(workspaceRoot string) string { + normalized := normalizeWorkspaceRoot(workspaceRoot) + if normalized == "" { + return "" + } + return strings.ToLower(normalized) +} + +// normalizeWorkspaceRoot 负责将工作区根目录规范化为绝对清洗路径。 +func normalizeWorkspaceRoot(workspaceRoot string) string { + trimmed := strings.TrimSpace(workspaceRoot) + if trimmed == "" { + return "" + } + + absolute, err := filepath.Abs(trimmed) + if err == nil { + trimmed = absolute + } + return filepath.Clean(trimmed) +} From 7f726bf3f37fce9c54acffc839832814e7c47ba2 Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 8 Apr 2026 15:48:34 +0800 Subject: [PATCH 20/54] test: add more tests for command_menu, commands, and tui entry --- internal/tui/core/app/command_menu_test.go | 82 ++++++++++++ internal/tui/core/app/commands_test.go | 144 +++++++++++++++++++++ internal/tui/tui_test.go | 35 +++++ 3 files changed, 261 insertions(+) create mode 100644 internal/tui/core/app/command_menu_test.go create mode 100644 internal/tui/core/app/commands_test.go create mode 100644 internal/tui/tui_test.go diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go new file mode 100644 index 00000000..fa1ea274 --- /dev/null +++ b/internal/tui/core/app/command_menu_test.go @@ -0,0 +1,82 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestCommandMenuItem(t *testing.T) { + item := commandMenuItem{ + title: "Test Command", + description: "Test description", + filter: "test", + highlight: false, + replacement: "/test", + useReplaceRange: false, + replaceStart: 0, + replaceEnd: 0, + openFileBrowser: false, + } + + if item.Title() != "Test Command" { + t.Errorf("Title() = %v, want Test Command", item.Title()) + } + if item.Description() != "Test description" { + t.Errorf("Description() = %v, want Test description", item.Description()) + } + if item.FilterValue() != "test" { + t.Errorf("FilterValue() = %v, want test", item.FilterValue()) + } +} + +func TestCommandMenuItemWithEmptyFilter(t *testing.T) { + item := commandMenuItem{ + title: "Command", + description: "Description", + filter: "", + } + + if item.FilterValue() != "command description" { + t.Errorf("FilterValue() = %v, want command description", item.FilterValue()) + } +} + +func TestCommandMenuItemFilterValueCase(t *testing.T) { + item := commandMenuItem{ + title: "UPPERCASE", + description: "Description", + filter: "lowercase", + } + + if !strings.Contains(item.FilterValue(), "lowercase") { + t.Errorf("FilterValue() should contain lowercase, got %v", item.FilterValue()) + } +} + +func TestSelectionItem(t *testing.T) { + item := selectionItem{ + id: "test-id", + name: "Test Name", + description: "Test description", + } + + if item.Title() != "Test Name" { + t.Errorf("Title() = %v, want Test Name", item.Title()) + } + if item.Description() != "Test description" { + t.Errorf("Description() = %v, want Test description", item.Description()) + } + if !strings.Contains(item.FilterValue(), "test-id") { + t.Errorf("FilterValue() should contain test-id, got %v", item.FilterValue()) + } +} + +func TestCommandMenuView(t *testing.T) { + styles := newStyles() + model := newCommandMenuModel(styles) + + v := model.View() + if v == "" { + t.Error("View() returned empty string") + } +} diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go new file mode 100644 index 00000000..cef5e8b4 --- /dev/null +++ b/internal/tui/core/app/commands_test.go @@ -0,0 +1,144 @@ +package tui + +import ( + "testing" + + "github.com/charmbracelet/bubbles/list" +) + +func TestBuiltinSlashCommands(t *testing.T) { + if len(builtinSlashCommands) == 0 { + t.Error("builtinSlashCommands should not be empty") + } + + found := false + for _, cmd := range builtinSlashCommands { + if cmd.Usage == slashUsageHelp { + found = true + break + } + } + if !found { + t.Error("expected to find /help command") + } +} + +func TestNewSelectionPicker(t *testing.T) { + items := []list.Item{ + selectionItem{id: "1", name: "Item 1", description: "Desc 1"}, + } + picker := newSelectionPicker(items) + _ = picker +} + +func TestNewSelectionPickerItems(t *testing.T) { + items := []selectionItem{ + {id: "1", name: "Item 1", description: "Desc 1"}, + } + picker := newSelectionPickerItems(items) + _ = picker +} + +func TestNewCommandMenuModel(t *testing.T) { + uiStyles := newStyles() + delegate := commandMenuDelegate{styles: uiStyles} + if delegate.Height() == 0 { + t.Error("delegate should have height") + } +} + +func TestStatusConstants(t *testing.T) { + tests := []struct { + name string + value string + }{ + {"statusReady", statusReady}, + {"statusThinking", statusThinking}, + {"statusCanceling", statusCanceling}, + {"statusCanceled", statusCanceled}, + {"statusRunningTool", statusRunningTool}, + {"statusToolFinished", statusToolFinished}, + {"statusToolError", statusToolError}, + {"statusError", statusError}, + {"statusDraft", statusDraft}, + {"statusRunning", statusRunning}, + {"statusApplyingCommand", statusApplyingCommand}, + {"statusRunningCommand", statusRunningCommand}, + {"statusCommandDone", statusCommandDone}, + {"statusCompacting", statusCompacting}, + {"statusChooseProvider", statusChooseProvider}, + {"statusChooseModel", statusChooseModel}, + {"statusBrowseFile", statusBrowseFile}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.value == "" { + t.Error("status constant should not be empty") + } + }) + } +} + +func TestFocusLabels(t *testing.T) { + if focusLabelSessions == "" { + t.Error("focusLabelSessions should not be empty") + } + if focusLabelTranscript == "" { + t.Error("focusLabelTranscript should not be empty") + } + if focusLabelActivity == "" { + t.Error("focusLabelActivity should not be empty") + } + if focusLabelComposer == "" { + t.Error("focusLabelComposer should not be empty") + } +} + +func TestMessageTags(t *testing.T) { + if messageTagUser == "" { + t.Error("messageTagUser should not be empty") + } + if messageTagAgent == "" { + t.Error("messageTagAgent should not be empty") + } + if messageTagTool == "" { + t.Error("messageTagTool should not be empty") + } +} + +func TestRoleConstants(t *testing.T) { + if roleUser == "" { + t.Error("roleUser should not be empty") + } + if roleAssistant == "" { + t.Error("roleAssistant should not be empty") + } + if roleTool == "" { + t.Error("roleTool should not be empty") + } +} + +func TestCopyCodeButton(t *testing.T) { + if copyCodeButton == "" { + t.Error("copyCodeButton should not be empty") + } +} + +func TestStatusCodeCopied(t *testing.T) { + if statusCodeCopied == "" { + t.Error("statusCodeCopied should not be empty") + } +} + +func TestStatusCodeCopyError(t *testing.T) { + if statusCodeCopyError == "" { + t.Error("statusCodeCopyError should not be empty") + } +} + +func TestMaxActivityEntries(t *testing.T) { + if maxActivityEntries == 0 { + t.Error("maxActivityEntries should not be zero") + } +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 00000000..f5e7e6c8 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,35 @@ +package tui + +import ( + "testing" + + "neo-code/internal/config" + "neo-code/internal/runtime" + tuibootstrap "neo-code/internal/tui/bootstrap" +) + +func TestAppTypeAlias(t *testing.T) { + var _ App = App{} +} + +func TestProviderControllerTypeAlias(t *testing.T) { + var _ ProviderController = ProviderController(nil) +} + +func TestNewForwardsToCore(t *testing.T) { + t.Run("nil config", func(t *testing.T) { + _, err := New(nil, &config.Manager{}, &runtime.Service{}, nil) + if err == nil { + t.Error("expected error for nil runtime") + } + }) +} + +func TestNewWithBootstrapForwardsToCore(t *testing.T) { + t.Run("empty options", func(t *testing.T) { + _, err := NewWithBootstrap(tuibootstrap.Options{}) + if err == nil { + t.Error("expected error for empty options") + } + }) +} From 9f116580ac46331bfd39db6d5cd678271dc33886 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 15:51:43 +0800 Subject: [PATCH 21/54] =?UTF-8?q?docs:=20=E5=BC=BA=E5=8C=96=20AGENTS=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在测试规则中新增整体测试覆盖率 100% 目标。 要求改动必须覆盖正常路径、边界条件、异常分支、回归场景以及必要的跨模块交互。 同时在提交前最低检查中补充测试覆盖完整性核对项。 --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d3f8d794..1002316b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ ## 5. 测试规则 - 测试文件命名为 `*_test.go`,测试函数命名为 `TestXxx`。 +- 所有改动必须以整体测试覆盖率 100% 为硬性目标;新增、修改或修复的逻辑必须同步补齐测试,覆盖正常路径、边界条件、异常分支、回归场景以及必要的跨模块交互,确保测试场景完整、结果可验证。 - 优先覆盖以下边界: - 配置校验 - provider 请求/响应转换 @@ -113,6 +114,7 @@ - 确认改动没有破坏 `TUI / Runtime / Provider / Tools / Config` 的职责分工。 - 确认新增能力已经接到正确层级,而不是临时跨层实现。 - 运行必要的格式化和测试。 +- 确认本次改动对应的测试已补齐,并满足整体测试覆盖率 100% 目标,不得遗漏关键路径、边界分支和回归场景。 - 检查 `git status`,确保没有无关文件、密钥、本地配置或临时数据混入。 ## 10. 常用命令 From e286ecf4c1e1e5f323a963fb0bed452399bf8f68 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 16:16:31 +0800 Subject: [PATCH 22/54] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E9=9A=94=E7=A6=BB=E6=94=B9=E5=8A=A8=E7=9A=84?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 CLI 执行入口、默认启动链路和启动参数转发测试。 补充启动装配、中文路径兼容、MCP 配置失败分支与 Windows 控制台编码测试。 补充 session 工作区分桶、覆盖写入和持久化异常分支测试,并验证全量测试通过。 --- internal/app/bootstrap_test.go | 102 +++++++++++++++ internal/app/console_encoding_windows_test.go | 27 ++++ internal/cli/root.go | 3 +- internal/cli/root_test.go | 70 +++++++++++ internal/session/store_test.go | 116 ++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 internal/app/console_encoding_windows_test.go diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index f0a598ec..51be1490 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -323,6 +323,25 @@ func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { } } +func TestBuildToolRegistryReturnsMCPSourceError(t *testing.T) { + t.Parallel() + + cfg := config.Default().Clone() + cfg.Workdir = t.TempDir() + cfg.Tools.MCP.Servers = []config.MCPServerConfig{ + { + ID: "docs", + Enabled: true, + Source: "sse", + }, + } + + _, err := buildToolRegistry(cfg) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unsupported mcp source") { + t.Fatalf("expected unsupported mcp source error, got %v", err) + } +} + func TestResolveMCPServerEnvAndWorkdir(t *testing.T) { t.Setenv("MCP_TOKEN", "secret") env, err := resolveMCPServerEnv(config.MCPServerConfig{ @@ -553,6 +572,89 @@ func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { } } +func TestBuildRuntimeRejectsInvalidConfigFile(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configDir := filepath.Join(home, ".neocode") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte("workdir: legacy\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if err == nil || !strings.Contains(err.Error(), "no longer supported") { + t.Fatalf("expected legacy config error, got %v", err) + } +} + +func TestBuildRuntimeRejectsUnsupportedMCPSource(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configDir := filepath.Join(home, ".neocode") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, "config.yaml") + raw := []byte(strings.Join([]string{ + "selected_provider: openai", + "current_model: " + config.OpenAIDefaultModel, + "shell: powershell", + "tools:", + " mcp:", + " servers:", + " - id: docs", + " enabled: true", + " source: sse", + }, "\n") + "\n") + if err := os.WriteFile(configPath, raw, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "not supported") { + t.Fatalf("expected unsupported mcp source validation error, got %v", err) + } +} + +func TestNewProgramRejectsInvalidWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + _, err := NewProgram(context.Background(), BootstrapOptions{Workdir: filepath.Join(t.TempDir(), "missing", "中文")}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve workdir") { + t.Fatalf("expected invalid workdir error, got %v", err) + } +} + +func TestResolveBootstrapWorkdirRejectsEmptyAndFile(t *testing.T) { + if _, err := resolveBootstrapWorkdir(" "); err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty workdir error, got %v", err) + } + + filePath := filepath.Join(t.TempDir(), "note.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + if _, err := resolveBootstrapWorkdir(filePath); err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("expected file path error, got %v", err) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage diff --git a/internal/app/console_encoding_windows_test.go b/internal/app/console_encoding_windows_test.go new file mode 100644 index 00000000..e502df4b --- /dev/null +++ b/internal/app/console_encoding_windows_test.go @@ -0,0 +1,27 @@ +//go:build windows + +package app + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +func TestPlatformSetConsoleCodePagesWithCurrentValues(t *testing.T) { + output, err := windows.GetConsoleOutputCP() + if err != nil { + t.Fatalf("GetConsoleOutputCP() error = %v", err) + } + if err := platformSetConsoleOutputCodePage(output); err != nil { + t.Fatalf("platformSetConsoleOutputCodePage() error = %v", err) + } + + input, err := windows.GetConsoleCP() + if err != nil { + t.Fatalf("GetConsoleCP() error = %v", err) + } + if err := platformSetConsoleInputCodePage(input); err != nil { + t.Fatalf("platformSetConsoleInputCodePage() error = %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 094135c5..b48d8600 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( ) var launchRootProgram = defaultRootProgramLauncher +var newRootProgram = app.NewProgram // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { @@ -49,7 +50,7 @@ func NewRootCommand() *cobra.Command { // defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { - program, err := app.NewProgram(ctx, opts) + program, err := newRootProgram(ctx, opts) if err != nil { return err } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 5a63c566..06538080 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,8 +3,12 @@ package cli import ( "context" "errors" + "io" + "os" "testing" + tea "github.com/charmbracelet/bubbletea" + "neo-code/internal/app" ) @@ -62,3 +66,69 @@ func TestNewRootCommandReturnsLauncherError(t *testing.T) { t.Fatalf("expected launcher error %v, got %v", expected, err) } } + +func TestExecuteUsesOSArgs(t *testing.T) { + originalLauncher := launchRootProgram + originalArgs := os.Args + t.Cleanup(func() { + launchRootProgram = originalLauncher + os.Args = originalArgs + }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + os.Args = []string{"neocode", "--workdir", `D:\项目\中文目录`} + + if err := Execute(context.Background()); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if captured.Workdir != `D:\项目\中文目录` { + t.Fatalf("expected Execute to forward workdir, got %q", captured.Workdir) + } +} + +func TestDefaultRootProgramLauncherRunsProgram(t *testing.T) { + originalNewProgram := newRootProgram + t.Cleanup(func() { newRootProgram = originalNewProgram }) + + newRootProgram = func(ctx context.Context, opts app.BootstrapOptions) (*tea.Program, error) { + model := quitModel{} + return tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(io.Discard)), nil + } + + if err := defaultRootProgramLauncher(context.Background(), app.BootstrapOptions{Workdir: `D:\项目\中文目录`}); err != nil { + t.Fatalf("defaultRootProgramLauncher() error = %v", err) + } +} + +func TestDefaultRootProgramLauncherReturnsNewProgramError(t *testing.T) { + originalNewProgram := newRootProgram + t.Cleanup(func() { newRootProgram = originalNewProgram }) + + expected := errors.New("new program failed") + newRootProgram = func(ctx context.Context, opts app.BootstrapOptions) (*tea.Program, error) { + return nil, expected + } + + err := defaultRootProgramLauncher(context.Background(), app.BootstrapOptions{}) + if !errors.Is(err, expected) { + t.Fatalf("expected new program error %v, got %v", expected, err) + } +} + +type quitModel struct{} + +func (quitModel) Init() tea.Cmd { + return tea.Quit +} + +func (quitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return quitModel{}, nil +} + +func (quitModel) View() string { + return "" +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index aab03df2..16bd6ce3 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -158,6 +158,31 @@ func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { } } +func TestWorkspaceHelpersHandleEmptyAndRelativePath(t *testing.T) { + t.Parallel() + + if got := workspacePathKey(" "); got != "" { + t.Fatalf("expected empty workspace key, got %q", got) + } + if got := normalizeWorkspaceRoot(" "); got != "" { + t.Fatalf("expected empty normalized workspace root, got %q", got) + } + + workingDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + relative := "." + normalized := normalizeWorkspaceRoot(relative) + if normalized != filepath.Clean(workingDir) { + t.Fatalf("expected relative path to normalize to %q, got %q", filepath.Clean(workingDir), normalized) + } + + if got, want := hashWorkspaceRoot(""), hashWorkspaceRoot(" "); got != want { + t.Fatalf("expected empty workspace root variants to share fallback hash, got %q want %q", got, want) + } +} + func TestJSONStoreErrors(t *testing.T) { t.Parallel() @@ -236,6 +261,97 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { } } +func TestJSONStoreSaveReplaceFailureWhenTargetIsNonEmptyDirectory(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + targetDir := filepath.Join(sessionDirectory(baseDir, workspaceRoot), "blocked.json") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + if err := os.WriteFile(filepath.Join(targetDir, "child.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("write child file: %v", err) + } + + err := store.Save(context.Background(), &Session{ + ID: "blocked", + Title: "Blocked", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil || !strings.Contains(err.Error(), "replace session file") { + t.Fatalf("expected replace failure, got %v", err) + } +} + +func TestJSONStoreSaveOverwritesExistingSessionFile(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + session := &Session{ + ID: "overwrite", + Title: "First", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now().Add(-time.Minute), + } + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save initial session: %v", err) + } + + session.Title = "Second" + session.UpdatedAt = time.Now() + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save updated session: %v", err) + } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load updated session: %v", err) + } + if loaded.Title != "Second" { + t.Fatalf("expected overwritten session title %q, got %q", "Second", loaded.Title) + } +} + +func TestJSONStoreSaveWriteTempFailure(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + sessionsPath := sessionDirectory(baseDir, workspaceRoot) + if err := os.MkdirAll(sessionsPath, 0o755); err != nil { + t.Fatalf("mkdir sessions path: %v", err) + } + tempDir := filepath.Join(sessionsPath, "temp-blocked.json.tmp") + if err := os.MkdirAll(tempDir, 0o755); err != nil { + t.Fatalf("mkdir temp dir: %v", err) + } + + err := store.Save(context.Background(), &Session{ + ID: "temp-blocked", + Title: "Temp Blocked", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil || !strings.Contains(err.Error(), "write temp session") { + t.Fatalf("expected temp write failure, got %v", err) + } +} + +func TestJSONStoreLoadMissingFileReturnsError(t *testing.T) { + t.Parallel() + + store := NewJSONStore(t.TempDir(), t.TempDir()) + if _, err := store.Load(context.Background(), "missing"); err == nil { + t.Fatalf("expected missing file load to fail") + } +} + func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { t.Parallel() From 11cded5c7de683c7fdcb00a591fe3f1a1ae5d2b1 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 16:34:03 +0800 Subject: [PATCH 23/54] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E5=88=86=E6=A1=B6=E7=9A=84=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E5=BD=92=E4=B8=80=E5=8C=96=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在 Windows 下对工作区路径做大小写归一化,避免大小写敏感文件系统上的不同目录落入同一个 session bucket。 同步更新工作区哈希测试,区分 Windows 与大小写敏感平台的预期行为。 --- internal/session/store_test.go | 13 +++++++++++-- internal/session/workspace.go | 8 ++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 16bd6ce3..4e08448f 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + goruntime "runtime" "strings" "testing" "time" @@ -153,8 +154,16 @@ func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { upperVariant := strings.ToUpper(normalized) lowerVariant := strings.ToLower(normalized) - if got, want := hashWorkspaceRoot(upperVariant), hashWorkspaceRoot(lowerVariant); got != want { - t.Fatalf("expected case variants to hash equally, got %q and %q", got, want) + gotCaseUpper := hashWorkspaceRoot(upperVariant) + gotCaseLower := hashWorkspaceRoot(lowerVariant) + if goruntime.GOOS == "windows" { + if gotCaseUpper != gotCaseLower { + t.Fatalf("expected case variants to hash equally on windows, got %q and %q", gotCaseUpper, gotCaseLower) + } + } else { + if gotCaseUpper == gotCaseLower { + t.Fatalf("expected case variants to hash differently on case-sensitive platforms, got %q", gotCaseUpper) + } } } diff --git a/internal/session/workspace.go b/internal/session/workspace.go index 686883e7..dd244204 100644 --- a/internal/session/workspace.go +++ b/internal/session/workspace.go @@ -4,6 +4,7 @@ import ( "crypto/sha1" "encoding/hex" "path/filepath" + goruntime "runtime" "strings" ) @@ -24,13 +25,16 @@ func hashWorkspaceRoot(workspaceRoot string) string { return hex.EncodeToString(sum[:8]) } -// workspacePathKey 负责生成工作区路径的稳定比较键,并与项目级 transcript 哈希规则保持一致。 +// workspacePathKey 负责生成工作区路径的稳定比较键,并在 Windows 下兼容大小写不敏感路径。 func workspacePathKey(workspaceRoot string) string { normalized := normalizeWorkspaceRoot(workspaceRoot) if normalized == "" { return "" } - return strings.ToLower(normalized) + if goruntime.GOOS == "windows" { + return strings.ToLower(normalized) + } + return normalized } // normalizeWorkspaceRoot 负责将工作区根目录规范化为绝对清洗路径。 From 6c4990c6ef7b238b603eb76aef46ce4a1132ca2c Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:46:54 +0800 Subject: [PATCH 24/54] =?UTF-8?q?fix(mcp):=20=E4=BF=AE=E5=A4=8D=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E8=AF=AD=E4=B9=89=E4=B8=A2=E5=A4=B1=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/bootstrap_test.go | 73 ++++++++++++++++++++++ internal/app/mcp_bootstrap.go | 15 +++++ internal/tools/mcp/adapter.go | 6 +- internal/tools/mcp/adapter_test.go | 25 ++++++++ internal/tools/mcp/registry.go | 24 +++++++- internal/tools/mcp/registry_test.go | 39 ++++++++++++ internal/tools/mcp/stdio_client.go | 82 ++++++++++++++++--------- internal/tools/mcp/stdio_client_test.go | 52 +++++++++++++++- internal/tools/registry.go | 7 +++ internal/tools/registry_test.go | 72 ++++++++++++++++++++++ 10 files changed, 360 insertions(+), 35 deletions(-) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a81903c0..981cba06 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -271,6 +271,9 @@ func TestDefaultRegisterMCPStdioServerRefreshFailure(t *testing.T) { if !strings.Contains(strings.ToLower(err.Error()), "list tools failed") { t.Fatalf("unexpected error: %v", err) } + if snapshots := registry.Snapshot(); len(snapshots) != 0 { + t.Fatalf("expected failed registration to rollback server, got %+v", snapshots) + } } func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { @@ -434,6 +437,53 @@ func TestBuildMCPRegistryRegisterError(t *testing.T) { } } +func TestBuildMCPRegistryRollbackRegisteredServersOnFailure(t *testing.T) { + t.Parallel() + + cfg := config.Default().Clone() + cfg.Workdir = t.TempDir() + cfg.Tools.MCP.Servers = []config.MCPServerConfig{ + {ID: "docs", Enabled: true, Source: "stdio"}, + {ID: "search", Enabled: true, Source: "stdio"}, + } + + closedByServer := map[string]*bool{ + "docs": new(bool), + "search": new(bool), + } + + originalRegister := registerMCPStdioServer + t.Cleanup(func() { registerMCPStdioServer = originalRegister }) + registerMCPStdioServer = func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { + client := &closeableStubMCPServerClient{closed: closedByServer[strings.TrimSpace(server.ID)]} + if err := registry.RegisterServer(server.ID, "stdio", server.Version, client); err != nil { + return err + } + if strings.EqualFold(strings.TrimSpace(server.ID), "search") { + return errors.New("search register failed") + } + return nil + } + + registry, err := buildMCPRegistry(cfg) + if err == nil || !strings.Contains(err.Error(), "search register failed") { + t.Fatalf("expected wrapped register error, got %v", err) + } + if registry != nil { + t.Fatalf("expected nil registry on build failure") + } + if !*closedByServer["docs"] || !*closedByServer["search"] { + t.Fatalf("expected rollback to close all registered servers, got %+v", closedByServer) + } +} + +func TestRollbackMCPServersBoundaries(t *testing.T) { + t.Parallel() + + rollbackMCPServers(nil, []string{"docs"}) + rollbackMCPServers(mcp.NewRegistry(), nil) +} + func TestInitialMCPRefreshTimeoutAndDurationConversion(t *testing.T) { t.Parallel() @@ -614,6 +664,29 @@ func (s *stubMCPServerClient) HealthCheck(ctx context.Context) error { return nil } +type closeableStubMCPServerClient struct { + closed *bool +} + +func (s *closeableStubMCPServerClient) ListTools(ctx context.Context) ([]mcp.ToolDescriptor, error) { + return nil, nil +} + +func (s *closeableStubMCPServerClient) CallTool(ctx context.Context, toolName string, arguments []byte) (mcp.CallResult, error) { + return mcp.CallResult{}, nil +} + +func (s *closeableStubMCPServerClient) HealthCheck(ctx context.Context) error { + return nil +} + +func (s *closeableStubMCPServerClient) Close() error { + if s.closed != nil { + *s.closed = true + } + return nil +} + func TestHelperProcessAppMCPStdioServer(t *testing.T) { if os.Getenv("GO_WANT_APP_MCP_STDIO_HELPER") != "1" { return diff --git a/internal/app/mcp_bootstrap.go b/internal/app/mcp_bootstrap.go index 27982e14..d0253b0c 100644 --- a/internal/app/mcp_bootstrap.go +++ b/internal/app/mcp_bootstrap.go @@ -23,6 +23,7 @@ func buildMCPRegistry(cfg config.Config) (*mcp.Registry, error) { registry := mcp.NewRegistry() enabledCount := 0 + registeredServerIDs := make([]string, 0, len(cfg.Tools.MCP.Servers)) for index := range cfg.Tools.MCP.Servers { server := cfg.Tools.MCP.Servers[index] if !server.Enabled { @@ -33,9 +34,12 @@ func buildMCPRegistry(cfg config.Config) (*mcp.Registry, error) { switch strings.ToLower(strings.TrimSpace(server.Source)) { case "", "stdio": if err := registerMCPStdioServer(registry, cfg, server); err != nil { + rollbackMCPServers(registry, append(registeredServerIDs, strings.TrimSpace(server.ID))) return nil, fmt.Errorf("app: register mcp server %q: %w", strings.TrimSpace(server.ID), err) } + registeredServerIDs = append(registeredServerIDs, strings.TrimSpace(server.ID)) default: + rollbackMCPServers(registry, registeredServerIDs) return nil, fmt.Errorf("app: unsupported mcp source %q", server.Source) } } @@ -46,6 +50,16 @@ func buildMCPRegistry(cfg config.Config) (*mcp.Registry, error) { return registry, nil } +// rollbackMCPServers 在批量注册失败时回滚已注册 server,避免残留子进程或脏状态。 +func rollbackMCPServers(registry *mcp.Registry, serverIDs []string) { + if registry == nil || len(serverIDs) == 0 { + return + } + for index := len(serverIDs) - 1; index >= 0; index-- { + _ = registry.UnregisterServer(serverIDs[index]) + } +} + // defaultRegisterMCPStdioServer 创建 stdio client 并完成 server 注册与 tools 快照初始化。 func defaultRegisterMCPStdioServer(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { env, err := resolveMCPServerEnv(server) @@ -79,6 +93,7 @@ func defaultRegisterMCPStdioServer(registry *mcp.Registry, cfg config.Config, se refreshCtx, cancel := context.WithTimeout(context.Background(), initialMCPRefreshTimeout(cfg)) defer cancel() if err := registry.RefreshServerTools(refreshCtx, serverID); err != nil { + _ = registry.UnregisterServer(serverID) return err } return nil diff --git a/internal/tools/mcp/adapter.go b/internal/tools/mcp/adapter.go index d9ecb470..f27545a3 100644 --- a/internal/tools/mcp/adapter.go +++ b/internal/tools/mcp/adapter.go @@ -132,10 +132,12 @@ func ensureObjectSchema(schema map[string]any) map[string]any { } } - if strings.TrimSpace(fmt.Sprintf("%v", cloned["type"])) == "" { + if !strings.EqualFold(strings.TrimSpace(fmt.Sprintf("%v", cloned["type"])), "object") { cloned["type"] = "object" + cloned["properties"] = map[string]any{} + return cloned } - if _, ok := cloned["properties"]; !ok { + if _, ok := cloned["properties"].(map[string]any); !ok { cloned["properties"] = map[string]any{} } return cloned diff --git a/internal/tools/mcp/adapter_test.go b/internal/tools/mcp/adapter_test.go index 2b38691f..4650807b 100644 --- a/internal/tools/mcp/adapter_test.go +++ b/internal/tools/mcp/adapter_test.go @@ -227,3 +227,28 @@ func TestAdapterEnsureObjectSchemaDefaults(t *testing.T) { t.Fatalf("expected properties object, got %+v", schema["properties"]) } } + +func TestAdapterEnsureObjectSchemaNormalizesInvalidType(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + adapter, err := NewAdapter(registry, "docs", ToolDescriptor{ + Name: "search", + InputSchema: map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + }, + }) + if err != nil { + t.Fatalf("NewAdapter() error = %v", err) + } + schema := adapter.Schema() + if schema["type"] != "object" { + t.Fatalf("expected normalized object type, got %v", schema["type"]) + } + if _, ok := schema["properties"].(map[string]any); !ok { + t.Fatalf("expected normalized properties object, got %+v", schema["properties"]) + } +} diff --git a/internal/tools/mcp/registry.go b/internal/tools/mcp/registry.go index 490b5142..ea85e0cc 100644 --- a/internal/tools/mcp/registry.go +++ b/internal/tools/mcp/registry.go @@ -55,6 +55,11 @@ type ServerClient interface { HealthCheck(ctx context.Context) error } +// closeableServerClient 描述支持主动关闭资源的 MCP client 扩展能力。 +type closeableServerClient interface { + Close() error +} + type serverEntry struct { snapshot ServerSnapshot client ServerClient @@ -116,11 +121,14 @@ func (r *Registry) UnregisterServer(serverID string) bool { } r.mu.Lock() - defer r.mu.Unlock() - if _, exists := r.servers[normalizedID]; !exists { + entry, exists := r.servers[normalizedID] + if !exists { + r.mu.Unlock() return false } delete(r.servers, normalizedID) + r.mu.Unlock() + closeServerClient(entry.client) return true } @@ -337,3 +345,15 @@ func cloneAny(value any) any { return value } } + +// closeServerClient 在 server 注销时尽力释放 client 持有的底层资源。 +func closeServerClient(client ServerClient) { + if client == nil { + return + } + closeableClient, ok := client.(closeableServerClient) + if !ok { + return + } + _ = closeableClient.Close() +} diff --git a/internal/tools/mcp/registry_test.go b/internal/tools/mcp/registry_test.go index 30c5cd3f..249e2505 100644 --- a/internal/tools/mcp/registry_test.go +++ b/internal/tools/mcp/registry_test.go @@ -19,6 +19,16 @@ type stubServerClient struct { lastArguments []byte } +type closableStubServerClient struct { + stubServerClient + closed bool +} + +func (s *closableStubServerClient) Close() error { + s.closed = true + return nil +} + func (s *stubServerClient) ListTools(ctx context.Context) ([]ToolDescriptor, error) { if s.listErr != nil { return nil, s.listErr @@ -229,6 +239,35 @@ func TestRegistryRegisterAndUnregisterBoundaries(t *testing.T) { } } +func TestRegistryUnregisterServerClosesClient(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + client := &closableStubServerClient{} + if err := registry.RegisterServer("docs", "stdio", "v1", client); err != nil { + t.Fatalf("register server: %v", err) + } + if !registry.UnregisterServer("docs") { + t.Fatalf("expected unregister success") + } + if !client.closed { + t.Fatalf("expected client to be closed on unregister") + } +} + +func TestCloseServerClientBoundaries(t *testing.T) { + t.Parallel() + + closeServerClient(nil) + closeServerClient(&stubServerClient{}) + + client := &closableStubServerClient{} + closeServerClient(client) + if !client.closed { + t.Fatalf("expected closeable client to be closed") + } +} + func TestRegistrySetServerStatusValidation(t *testing.T) { t.Parallel() diff --git a/internal/tools/mcp/stdio_client.go b/internal/tools/mcp/stdio_client.go index b6808d51..210b9698 100644 --- a/internal/tools/mcp/stdio_client.go +++ b/internal/tools/mcp/stdio_client.go @@ -799,35 +799,6 @@ func decodeCallResult(raw json.RawMessage) CallResult { } } - content := "" - switch typed := payload["content"].(type) { - case string: - content = strings.TrimSpace(typed) - case []any: - lines := make([]string, 0, len(typed)) - for _, item := range typed { - switch value := item.(type) { - case map[string]any: - text, _ := value["text"].(string) - if strings.TrimSpace(text) != "" { - lines = append(lines, strings.TrimSpace(text)) - } - case string: - if strings.TrimSpace(value) != "" { - lines = append(lines, strings.TrimSpace(value)) - } - } - } - content = strings.Join(lines, "\n") - default: - if typed != nil { - content = strings.TrimSpace(fmt.Sprintf("%v", typed)) - } - } - if content == "" { - content = "ok" - } - isError := false if value, ok := payload["isError"].(bool); ok { isError = value @@ -836,6 +807,15 @@ func decodeCallResult(raw json.RawMessage) CallResult { isError = isError || value } + content := decodeCallContent(payload["content"]) + if content == "" { + if isError { + content = "mcp tool returned empty error content" + } else { + content = "ok" + } + } + metadata := map[string]any{} for key, value := range payload { if key == "content" || key == "isError" || key == "is_error" { @@ -851,3 +831,47 @@ func decodeCallResult(raw json.RawMessage) CallResult { Metadata: metadata, } } + +// decodeCallContent 将 MCP tools/call 的 content 字段归一为可回灌文本,避免结构化内容丢失。 +func decodeCallContent(content any) string { + switch typed := content.(type) { + case string: + return strings.TrimSpace(typed) + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + formatted := decodeCallContentItem(item) + if formatted != "" { + parts = append(parts, formatted) + } + } + return strings.Join(parts, "\n") + default: + return decodeCallContentItem(typed) + } +} + +// decodeCallContentItem 对单个 MCP content item 做兜底格式化,保留非文本对象信息。 +func decodeCallContentItem(item any) string { + switch typed := item.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(typed) + case map[string]any: + if text, ok := typed["text"].(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + raw, err := json.Marshal(typed) + if err != nil { + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } + return strings.TrimSpace(string(raw)) + default: + raw, err := json.Marshal(typed) + if err != nil { + return strings.TrimSpace(fmt.Sprintf("%v", typed)) + } + return strings.TrimSpace(string(raw)) + } +} diff --git a/internal/tools/mcp/stdio_client_test.go b/internal/tools/mcp/stdio_client_test.go index 01d8d20b..07163406 100644 --- a/internal/tools/mcp/stdio_client_test.go +++ b/internal/tools/mcp/stdio_client_test.go @@ -355,9 +355,19 @@ func TestDecodeCallResultVariants(t *testing.T) { t.Fatalf("unexpected list content decode: %+v", result) } + result = decodeCallResult(json.RawMessage(`{"content":[{"type":"resource_link","uri":"https://example.com"},{"type":"image","mimeType":"image/png"}]}`)) + if !strings.Contains(result.Content, `"type":"resource_link"`) || !strings.Contains(result.Content, `"type":"image"`) { + t.Fatalf("expected non-text items to be preserved, got %q", result.Content) + } + result = decodeCallResult(json.RawMessage(`{"content":{"nested":"x"}}`)) - if result.Content == "" { - t.Fatalf("expected fallback string content") + if !strings.Contains(result.Content, `"nested":"x"`) { + t.Fatalf("expected structured map content, got %q", result.Content) + } + + result = decodeCallResult(json.RawMessage(`{"content":[],"isError":true}`)) + if !result.IsError || strings.Contains(strings.ToLower(result.Content), "ok") { + t.Fatalf("expected non-ok error fallback content, got %+v", result) } result = decodeCallResult(json.RawMessage(`not-json`)) @@ -369,6 +379,44 @@ func TestDecodeCallResultVariants(t *testing.T) { } } +func TestDecodeCallContentItemVariants(t *testing.T) { + t.Parallel() + + if got := decodeCallContentItem(nil); got != "" { + t.Fatalf("expected empty for nil, got %q", got) + } + if got := decodeCallContentItem(" text "); got != "text" { + t.Fatalf("expected trimmed text, got %q", got) + } + if got := decodeCallContentItem(map[string]any{"text": " hello "}); got != "hello" { + t.Fatalf("expected text extraction, got %q", got) + } + if got := decodeCallContentItem(map[string]any{"type": "resource_link", "uri": "https://example.com"}); !strings.Contains(got, `"type":"resource_link"`) { + t.Fatalf("expected json fallback for object item, got %q", got) + } + if got := decodeCallContentItem(123); got != "123" { + t.Fatalf("expected scalar json fallback, got %q", got) + } + if got := decodeCallContentItem(map[string]any{"bad": func() {}}); !strings.Contains(got, "bad") { + t.Fatalf("expected fmt fallback for non-marshalable map, got %q", got) + } + if got := decodeCallContentItem(func() {}); !strings.Contains(got, "0x") { + t.Fatalf("expected fmt fallback for non-marshalable scalar, got %q", got) + } +} + +func TestDecodeCallResultContentFallbackOK(t *testing.T) { + t.Parallel() + + result := decodeCallResult(json.RawMessage(`{"content":[]}`)) + if result.IsError { + t.Fatalf("expected non-error result") + } + if result.Content != "ok" { + t.Fatalf("expected ok fallback content, got %q", result.Content) + } +} + func TestFailAllPendingLocked(t *testing.T) { t.Parallel() diff --git a/internal/tools/registry.go b/internal/tools/registry.go index a392f629..3f7af5a3 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -173,6 +173,13 @@ func (r *Registry) Execute(ctx context.Context, input ToolCallInput) (ToolResult result = ApplyOutputLimit(result, DefaultOutputLimitBytes) return result, callErr } + if result.IsError { + if strings.TrimSpace(result.Content) == "" { + result.Content = FormatError(result.Name, "mcp tool returned isError=true", "") + } + result = ApplyOutputLimit(result, DefaultOutputLimitBytes) + return result, errors.New("mcp: tool returned error result") + } if result.Content == "" { result.Content = "ok" } diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index a26843e4..09529b75 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -413,6 +413,78 @@ func TestRegistryExecuteMCPCallErrorDoesNotReturnOK(t *testing.T) { } } +func TestRegistryExecuteMCPIsErrorResultDoesNotFallbackToOK(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + callResult: mcp.CallResult{ + Content: "", + IsError: true, + }, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh mcp tools: %v", err) + } + registry.SetMCPRegistry(mcpRegistry) + + result, err := registry.Execute(context.Background(), ToolCallInput{ + ID: "mcp-call-iserror", + Name: "mcp.docs.search", + }) + if err == nil { + t.Fatalf("expected mcp isError to return error") + } + if !result.IsError { + t.Fatalf("expected IsError true") + } + if strings.EqualFold(strings.TrimSpace(result.Content), "ok") || strings.TrimSpace(result.Content) == "" { + t.Fatalf("expected non-ok error content, got %q", result.Content) + } +} + +func TestRegistryExecuteMCPIsErrorWithContentKeepsContent(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + callResult: mcp.CallResult{ + Content: "explicit mcp error", + IsError: true, + }, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh mcp tools: %v", err) + } + registry.SetMCPRegistry(mcpRegistry) + + result, err := registry.Execute(context.Background(), ToolCallInput{ + ID: "mcp-call-iserror-content", + Name: "mcp.docs.search", + }) + if err == nil { + t.Fatalf("expected mcp isError to return error") + } + if !result.IsError { + t.Fatalf("expected IsError true") + } + if result.Content != "explicit mcp error" { + t.Fatalf("expected explicit error content preserved, got %q", result.Content) + } +} + func TestRegistrySupportsMCPToolAndHelpers(t *testing.T) { t.Parallel() From 48e619d1380729d2bd86e40fafc7e2564154276e Mon Sep 17 00:00:00 2001 From: pionxe Date: Wed, 8 Apr 2026 20:46:58 +0800 Subject: [PATCH 25/54] =?UTF-8?q?feat(gateway):=20=E5=AE=9E=E7=8E=B0=20GW-?= =?UTF-8?q?01=20=E5=A5=91=E7=BA=A6=E5=B1=82=EF=BC=88=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=B8=A7=E3=80=81=E6=8E=A5=E5=8F=A3=E4=B8=8E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E9=80=BB=E8=BE=91=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了独立的 internal/gateway 模块,包含: 1. 协议帧 (MessageFrame) 及多模态数据结构 2. Runtime 交互接口契约 (Port Contracts) 3. 自动化帧校验器及标准错误处理机制 4. 完整的单元测试覆盖 Ref: #196, #198 --- internal/gateway/contracts.go | 140 +++++++++++++++++ internal/gateway/errors.go | 54 +++++++ internal/gateway/errors_test.go | 37 +++++ internal/gateway/types.go | 95 ++++++++++++ internal/gateway/types_test.go | 68 +++++++++ internal/gateway/validate.go | 128 ++++++++++++++++ internal/gateway/validate_test.go | 242 ++++++++++++++++++++++++++++++ 7 files changed, 764 insertions(+) create mode 100644 internal/gateway/contracts.go create mode 100644 internal/gateway/errors.go create mode 100644 internal/gateway/errors_test.go create mode 100644 internal/gateway/types.go create mode 100644 internal/gateway/types_test.go create mode 100644 internal/gateway/validate.go create mode 100644 internal/gateway/validate_test.go diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go new file mode 100644 index 00000000..7ebd0366 --- /dev/null +++ b/internal/gateway/contracts.go @@ -0,0 +1,140 @@ +package gateway + +import ( + "context" + "time" +) + +// RuntimeEventType 表示运行事件类型。 +type RuntimeEventType string + +const ( + // RuntimeEventTypeRunProgress 表示运行过程事件。 + RuntimeEventTypeRunProgress RuntimeEventType = "run_progress" + // RuntimeEventTypeRunDone 表示运行完成事件。 + RuntimeEventTypeRunDone RuntimeEventType = "run_done" + // RuntimeEventTypeRunError 表示运行错误事件。 + RuntimeEventTypeRunError RuntimeEventType = "run_error" +) + +// RunInput 表示网关向下游运行端口发起 run 动作时的输入。 +type RunInput struct { + // RequestID 是客户端请求标识。 + RequestID string + // SessionID 是会话标识。 + SessionID string + // RunID 是运行标识。 + RunID string + // InputText 是文本输入。 + InputText string + // InputParts 是多模态输入分片。 + InputParts []InputPart + // Workdir 是请求级工作目录覆盖值。 + Workdir string +} + +// CompactInput 表示网关向下游运行端口发起 compact 动作时的输入。 +type CompactInput struct { + // RequestID 是客户端请求标识。 + RequestID string + // SessionID 是会话标识。 + SessionID string + // RunID 是运行标识。 + RunID string +} + +// CompactResult 表示 compact 动作完成后返回的结果。 +type CompactResult struct { + // Applied 表示是否实际应用压缩结果。 + Applied bool + // BeforeChars 是压缩前字符数。 + BeforeChars int + // AfterChars 是压缩后字符数。 + AfterChars int + // SavedRatio 是压缩节省比例。 + SavedRatio float64 + // TriggerMode 是触发模式标识。 + TriggerMode string + // TranscriptID 是压缩产物标识。 + TranscriptID string + // TranscriptPath 是压缩产物路径。 + TranscriptPath string +} + +// RuntimeEvent 表示运行端口推送给网关的统一事件。 +type RuntimeEvent struct { + // Type 是事件类型。 + Type RuntimeEventType `json:"type"` + // RunID 是运行标识。 + RunID string `json:"run_id,omitempty"` + // SessionID 是会话标识。 + SessionID string `json:"session_id,omitempty"` + // Payload 是事件扩展负载。 + Payload any `json:"payload,omitempty"` +} + +// SessionMessage 表示会话消息快照中的单条消息。 +type SessionMessage struct { + // Role 是消息角色。 + Role string `json:"role"` + // Content 是消息内容。 + Content string `json:"content"` + // ToolCallID 是工具消息关联的调用标识。 + ToolCallID string `json:"tool_call_id,omitempty"` + // IsError 表示该消息是否为错误结果。 + IsError bool `json:"is_error,omitempty"` +} + +// Session 表示网关视角的会话详情。 +type Session struct { + // ID 是会话标识。 + ID string `json:"id"` + // Title 是会话标题。 + Title string `json:"title"` + // CreatedAt 是会话创建时间。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 是会话更新时间。 + UpdatedAt time.Time `json:"updated_at"` + // Workdir 是会话工作目录。 + Workdir string `json:"workdir,omitempty"` + // Messages 是会话消息快照。 + Messages []SessionMessage `json:"messages,omitempty"` +} + +// SessionSummary 表示会话列表项摘要。 +type SessionSummary struct { + // ID 是会话标识。 + ID string `json:"id"` + // Title 是会话标题。 + Title string `json:"title"` + // CreatedAt 是会话创建时间。 + CreatedAt time.Time `json:"created_at"` + // UpdatedAt 是会话更新时间。 + UpdatedAt time.Time `json:"updated_at"` +} + +// RuntimePort 定义网关访问运行时编排的下游端口契约。 +type RuntimePort interface { + // Run 启动一次运行编排。 + Run(ctx context.Context, input RunInput) error + // Compact 对指定会话触发一次手动压缩。 + Compact(ctx context.Context, input CompactInput) (CompactResult, error) + // CancelActiveRun 取消当前活跃运行。 + CancelActiveRun() bool + // Events 返回统一运行事件流。 + Events() <-chan RuntimeEvent + // ListSessions 返回会话摘要列表。 + ListSessions(ctx context.Context) ([]SessionSummary, error) + // LoadSession 加载指定会话详情。 + LoadSession(ctx context.Context, id string) (Session, error) + // SetSessionWorkdir 更新会话工作目录。 + SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (Session, error) +} + +// Gateway 定义网关主契约。 +type Gateway interface { + // Serve 启动网关服务并绑定运行端口。 + Serve(ctx context.Context, runtimePort RuntimePort) error + // Close 优雅关闭网关服务。 + Close(ctx context.Context) error +} diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go new file mode 100644 index 00000000..c5e72aba --- /dev/null +++ b/internal/gateway/errors.go @@ -0,0 +1,54 @@ +package gateway + +import "fmt" + +// ErrorCode 表示网关协议层稳定错误码。 +type ErrorCode string + +const ( + // ErrorCodeInvalidFrame 表示帧结构或帧类型非法。 + ErrorCodeInvalidFrame ErrorCode = "invalid_frame" + // ErrorCodeInvalidAction 表示动作值非法。 + ErrorCodeInvalidAction ErrorCode = "invalid_action" + // ErrorCodeInvalidMultimodalPayload 表示多模态输入负载非法。 + ErrorCodeInvalidMultimodalPayload ErrorCode = "invalid_multimodal_payload" + // ErrorCodeMissingRequiredField 表示缺少必填字段。 + ErrorCodeMissingRequiredField ErrorCode = "missing_required_field" + // ErrorCodeUnsupportedAction 表示动作暂不支持。 + ErrorCodeUnsupportedAction ErrorCode = "unsupported_action" + // ErrorCodeInternalError 表示网关内部错误。 + ErrorCodeInternalError ErrorCode = "internal_error" +) + +var stableErrorCodes = map[string]struct{}{ + string(ErrorCodeInvalidFrame): {}, + string(ErrorCodeInvalidAction): {}, + string(ErrorCodeInvalidMultimodalPayload): {}, + string(ErrorCodeMissingRequiredField): {}, + string(ErrorCodeUnsupportedAction): {}, + string(ErrorCodeInternalError): {}, +} + +// String 返回错误码的字符串值。 +func (c ErrorCode) String() string { + return string(c) +} + +// NewFrameError 创建统一格式的协议错误对象。 +func NewFrameError(code ErrorCode, message string) *FrameError { + return &FrameError{ + Code: code.String(), + Message: message, + } +} + +// NewMissingRequiredFieldError 创建缺少必填字段的错误对象。 +func NewMissingRequiredFieldError(field string) *FrameError { + return NewFrameError(ErrorCodeMissingRequiredField, fmt.Sprintf("missing required field: %s", field)) +} + +// IsStableErrorCode 判断给定字符串是否为网关稳定错误码。 +func IsStableErrorCode(code string) bool { + _, exists := stableErrorCodes[code] + return exists +} diff --git a/internal/gateway/errors_test.go b/internal/gateway/errors_test.go new file mode 100644 index 00000000..1c07cc5d --- /dev/null +++ b/internal/gateway/errors_test.go @@ -0,0 +1,37 @@ +package gateway + +import "testing" + +func TestStableErrorCodes(t *testing.T) { + codes := []ErrorCode{ + ErrorCodeInvalidFrame, + ErrorCodeInvalidAction, + ErrorCodeInvalidMultimodalPayload, + ErrorCodeMissingRequiredField, + ErrorCodeUnsupportedAction, + ErrorCodeInternalError, + } + + for _, code := range codes { + if !IsStableErrorCode(code.String()) { + t.Fatalf("expected code %q to be stable", code) + } + } + + if IsStableErrorCode("unknown_code") { + t.Fatalf("unknown code should not be stable") + } +} + +func TestNewMissingRequiredFieldError(t *testing.T) { + err := NewMissingRequiredFieldError("session_id") + if err == nil { + t.Fatalf("expected non-nil error") + } + if err.Code != ErrorCodeMissingRequiredField.String() { + t.Fatalf("error code mismatch: got %q", err.Code) + } + if err.Message == "" { + t.Fatalf("error message should not be empty") + } +} diff --git a/internal/gateway/types.go b/internal/gateway/types.go new file mode 100644 index 00000000..0d6984aa --- /dev/null +++ b/internal/gateway/types.go @@ -0,0 +1,95 @@ +package gateway + +// FrameType 表示网关协议帧类型。 +type FrameType string + +const ( + // FrameTypeRequest 表示客户端发往网关的请求帧。 + FrameTypeRequest FrameType = "request" + // FrameTypeEvent 表示网关推送给客户端的事件帧。 + FrameTypeEvent FrameType = "event" + // FrameTypeError 表示网关推送给客户端的错误帧。 + FrameTypeError FrameType = "error" + // FrameTypeAck 表示网关对请求的接收确认帧。 + FrameTypeAck FrameType = "ack" +) + +// FrameAction 表示请求动作类型。 +type FrameAction string + +const ( + // FrameActionRun 表示发起一次运行。 + FrameActionRun FrameAction = "run" + // FrameActionCompact 表示触发一次手动压缩。 + FrameActionCompact FrameAction = "compact" + // FrameActionCancel 表示取消当前活跃运行。 + FrameActionCancel FrameAction = "cancel" + // FrameActionListSessions 表示获取会话摘要列表。 + FrameActionListSessions FrameAction = "list_sessions" + // FrameActionLoadSession 表示加载指定会话详情。 + FrameActionLoadSession FrameAction = "load_session" + // FrameActionSetSessionWorkdir 表示设置会话工作目录。 + FrameActionSetSessionWorkdir FrameAction = "set_session_workdir" +) + +// InputPartType 表示多模态输入分片类型。 +type InputPartType string + +const ( + // InputPartTypeText 表示文本分片。 + InputPartTypeText InputPartType = "text" + // InputPartTypeImage 表示图片分片。 + InputPartTypeImage InputPartType = "image" +) + +// Media 表示非文本输入的媒体描述。 +type Media struct { + // URI 是媒体资源地址。 + URI string `json:"uri"` + // MimeType 是媒体 MIME 类型。 + MimeType string `json:"mime_type"` + // FileName 是媒体文件名。 + FileName string `json:"file_name,omitempty"` +} + +// InputPart 表示网关协议中的多模态输入分片。 +type InputPart struct { + // Type 表示分片类型,如 text / image。 + Type InputPartType `json:"type"` + // Text 是文本分片内容,仅 text 类型使用。 + Text string `json:"text,omitempty"` + // Media 是非文本分片媒体信息,仅 image 等类型使用。 + Media *Media `json:"media,omitempty"` +} + +// FrameError 表示协议帧中的错误信息。 +type FrameError struct { + // Code 是稳定错误码,供客户端做分支判断。 + Code string `json:"code"` + // Message 是面向用户或日志的错误描述。 + Message string `json:"message"` +} + +// MessageFrame 是网关与客户端之间的统一通信帧。 +type MessageFrame struct { + // Type 是帧类型。 + Type FrameType `json:"type"` + // Action 是请求动作,非 request 帧可为空。 + Action FrameAction `json:"action,omitempty"` + // RequestID 是客户端请求幂等标识。 + RequestID string `json:"request_id,omitempty"` + // RunID 是运行标识。 + RunID string `json:"run_id,omitempty"` + // SessionID 是会话标识。 + SessionID string `json:"session_id,omitempty"` + // InputText 是文本输入内容。 + InputText string `json:"input_text,omitempty"` + // InputParts 是多模态输入分片。 + InputParts []InputPart `json:"input_parts,omitempty"` + // Workdir 是本次请求的工作目录覆盖值。 + Workdir string `json:"workdir,omitempty"` + // Payload 是动作扩展负载或事件负载。 + Payload any `json:"payload,omitempty"` + // Error 是错误帧负载。 + Error *FrameError `json:"error,omitempty"` +} diff --git a/internal/gateway/types_test.go b/internal/gateway/types_test.go new file mode 100644 index 00000000..581a655a --- /dev/null +++ b/internal/gateway/types_test.go @@ -0,0 +1,68 @@ +package gateway + +import ( + "encoding/json" + "testing" +) + +func TestMessageFrameJSONRoundTrip(t *testing.T) { + original := MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + RequestID: "req_001", + RunID: "run_123", + SessionID: "sess_abc", + InputText: "请分析这张图", + InputParts: []InputPart{ + { + Type: InputPartTypeText, + Text: "请先读取图片中的文字", + }, + { + Type: InputPartTypeImage, + Media: &Media{ + URI: "file:///workspace/assets/screen.png", + MimeType: "image/png", + FileName: "screen.png", + }, + }, + }, + Workdir: "/workspace/project", + Error: &FrameError{ + Code: ErrorCodeInvalidFrame.String(), + Message: "invalid frame", + }, + } + + payload, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded MessageFrame + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if decoded.Type != original.Type { + t.Fatalf("type mismatch: got %q want %q", decoded.Type, original.Type) + } + if decoded.Action != original.Action { + t.Fatalf("action mismatch: got %q want %q", decoded.Action, original.Action) + } + if decoded.RequestID != original.RequestID { + t.Fatalf("request_id mismatch: got %q want %q", decoded.RequestID, original.RequestID) + } + if decoded.SessionID != original.SessionID { + t.Fatalf("session_id mismatch: got %q want %q", decoded.SessionID, original.SessionID) + } + if len(decoded.InputParts) != 2 { + t.Fatalf("input_parts mismatch: got %d want %d", len(decoded.InputParts), 2) + } + if decoded.InputParts[1].Media == nil || decoded.InputParts[1].Media.MimeType != "image/png" { + t.Fatalf("media mime_type mismatch in image part") + } + if decoded.Error == nil || decoded.Error.Code != original.Error.Code { + t.Fatalf("error code mismatch") + } +} diff --git a/internal/gateway/validate.go b/internal/gateway/validate.go new file mode 100644 index 00000000..ca9d63b9 --- /dev/null +++ b/internal/gateway/validate.go @@ -0,0 +1,128 @@ +package gateway + +import "strings" + +// ValidateFrame 校验网关协议帧是否满足基础契约约束。 +func ValidateFrame(frame MessageFrame) *FrameError { + if !isValidFrameType(frame.Type) { + return NewFrameError(ErrorCodeInvalidFrame, "invalid frame type") + } + + if strings.TrimSpace(string(frame.Action)) != "" && !isValidFrameAction(frame.Action) { + return NewFrameError(ErrorCodeInvalidAction, "invalid action") + } + + if frame.Type == FrameTypeRequest { + return validateRequestFrame(frame) + } + + return nil +} + +// validateRequestFrame 校验 request 帧的动作及动作所需字段。 +func validateRequestFrame(frame MessageFrame) *FrameError { + if strings.TrimSpace(string(frame.Action)) == "" { + return NewMissingRequiredFieldError("action") + } + + switch frame.Action { + case FrameActionRun: + return validateRunFrame(frame) + case FrameActionCompact, FrameActionLoadSession: + if strings.TrimSpace(frame.SessionID) == "" { + return NewMissingRequiredFieldError("session_id") + } + case FrameActionSetSessionWorkdir: + if strings.TrimSpace(frame.SessionID) == "" { + return NewMissingRequiredFieldError("session_id") + } + if strings.TrimSpace(frame.Workdir) == "" { + return NewMissingRequiredFieldError("workdir") + } + case FrameActionCancel, FrameActionListSessions: + return nil + default: + return NewFrameError(ErrorCodeInvalidAction, "invalid action") + } + + if len(frame.InputParts) > 0 { + return validateInputParts(frame.InputParts) + } + + return nil +} + +// validateRunFrame 校验 run 动作的输入字段是否完整且合法。 +func validateRunFrame(frame MessageFrame) *FrameError { + hasText := strings.TrimSpace(frame.InputText) != "" + hasParts := len(frame.InputParts) > 0 + if !hasText && !hasParts { + return NewMissingRequiredFieldError("input_text_or_input_parts") + } + + if hasParts { + return validateInputParts(frame.InputParts) + } + + return nil +} + +// validateInputParts 校验多模态输入分片数组。 +func validateInputParts(parts []InputPart) *FrameError { + for index := range parts { + if err := validateInputPart(parts[index], index); err != nil { + return err + } + } + return nil +} + +// validateInputPart 校验单个多模态输入分片。 +func validateInputPart(part InputPart, index int) *FrameError { + switch part.Type { + case InputPartTypeText: + if strings.TrimSpace(part.Text) == "" { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[text] requires non-empty text") + } + case InputPartTypeImage: + if part.Media == nil { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[image] requires media") + } + if strings.TrimSpace(part.Media.URI) == "" { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[image] requires media.uri") + } + if strings.TrimSpace(part.Media.MimeType) == "" { + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts[image] requires media.mime_type") + } + default: + _ = index + return NewFrameError(ErrorCodeInvalidMultimodalPayload, "input_parts contains unsupported type") + } + + return nil +} + +// isValidFrameType 判断帧类型是否属于协议定义集合。 +func isValidFrameType(frameType FrameType) bool { + switch frameType { + case FrameTypeRequest, FrameTypeEvent, FrameTypeError, FrameTypeAck: + return true + default: + return false + } +} + +// isValidFrameAction 判断动作是否属于协议定义集合。 +func isValidFrameAction(action FrameAction) bool { + switch action { + case FrameActionRun, + FrameActionCompact, + FrameActionCancel, + FrameActionListSessions, + FrameActionLoadSession, + FrameActionSetSessionWorkdir: + return true + default: + return false + } +} diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go new file mode 100644 index 00000000..8aaaca2c --- /dev/null +++ b/internal/gateway/validate_test.go @@ -0,0 +1,242 @@ +package gateway + +import ( + "strings" + "testing" +) + +func TestValidateFrame_BasicRules(t *testing.T) { + tests := []struct { + name string + frame MessageFrame + wantNil bool + wantCode string + wantField string + }{ + { + name: "valid run with input_text", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputText: "hello", + }, + wantNil: true, + }, + { + name: "invalid frame type", + frame: MessageFrame{ + Type: FrameType("unknown"), + }, + wantCode: ErrorCodeInvalidFrame.String(), + }, + { + name: "request missing action", + frame: MessageFrame{ + Type: FrameTypeRequest, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "action", + }, + { + name: "request invalid action", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameAction("foo"), + }, + wantCode: ErrorCodeInvalidAction.String(), + }, + { + name: "run missing both input_text and input_parts", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "input_text_or_input_parts", + }, + { + name: "compact missing session_id", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionCompact, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "session_id", + }, + { + name: "load_session missing session_id", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionLoadSession, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "session_id", + }, + { + name: "set_session_workdir missing workdir", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionSetSessionWorkdir, + SessionID: "sess_1", + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "workdir", + }, + { + name: "set_session_workdir valid", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionSetSessionWorkdir, + SessionID: "sess_1", + Workdir: "/workspace", + }, + wantNil: true, + }, + { + name: "event frame allows empty action", + frame: MessageFrame{ + Type: FrameTypeEvent, + }, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFrame(tt.frame) + if tt.wantNil { + if err != nil { + t.Fatalf("expected nil error, got: %#v", err) + } + return + } + + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if err.Code != tt.wantCode { + t.Fatalf("error code mismatch: got %q want %q", err.Code, tt.wantCode) + } + if tt.wantField != "" && !strings.Contains(err.Message, tt.wantField) { + t.Fatalf("expected message to contain %q, got %q", tt.wantField, err.Message) + } + }) + } +} + +func TestValidateFrame_MultimodalPayloadRules(t *testing.T) { + tests := []struct { + name string + frame MessageFrame + wantNil bool + wantCode string + }{ + { + name: "valid text part", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartTypeText, Text: "hello"}, + }, + }, + wantNil: true, + }, + { + name: "valid image part", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + { + Type: InputPartTypeImage, + Media: &Media{ + URI: "file:///a.png", + MimeType: "image/png", + }, + }, + }, + }, + wantNil: true, + }, + { + name: "text part with empty text", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartTypeText, Text: " "}, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "image part missing media", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartTypeImage}, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "image part missing media.uri", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + { + Type: InputPartTypeImage, + Media: &Media{MimeType: "image/png"}, + }, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "image part missing media.mime_type", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + { + Type: InputPartTypeImage, + Media: &Media{URI: "file:///a.png"}, + }, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + { + name: "unsupported part type", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionRun, + InputParts: []InputPart{ + {Type: InputPartType("audio")}, + }, + }, + wantCode: ErrorCodeInvalidMultimodalPayload.String(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateFrame(tt.frame) + if tt.wantNil { + if err != nil { + t.Fatalf("expected nil error, got: %#v", err) + } + return + } + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if err.Code != tt.wantCode { + t.Fatalf("error code mismatch: got %q want %q", err.Code, tt.wantCode) + } + }) + } +} From cdf4473a4fb93ec2396c86097ca9a5c8912b0c22 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 9 Apr 2026 00:24:56 +0800 Subject: [PATCH 26/54] =?UTF-8?q?fix(mcp):=20=E4=BF=AE=E6=AD=A3=E7=BC=BA?= =?UTF-8?q?=E7=9C=81type=E6=97=B6schema=E5=BD=92=E4=B8=80=E5=8C=96?= =?UTF-8?q?=E8=AF=AF=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/mcp/adapter.go | 12 ++++++++++- internal/tools/mcp/adapter_test.go | 33 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/internal/tools/mcp/adapter.go b/internal/tools/mcp/adapter.go index f27545a3..a0082f3f 100644 --- a/internal/tools/mcp/adapter.go +++ b/internal/tools/mcp/adapter.go @@ -132,7 +132,17 @@ func ensureObjectSchema(schema map[string]any) map[string]any { } } - if !strings.EqualFold(strings.TrimSpace(fmt.Sprintf("%v", cloned["type"])), "object") { + rawType, hasType := cloned["type"] + trimmedType := strings.TrimSpace(fmt.Sprintf("%v", rawType)) + if !hasType || rawType == nil || trimmedType == "" || strings.EqualFold(trimmedType, "") { + cloned["type"] = "object" + if _, ok := cloned["properties"].(map[string]any); !ok { + cloned["properties"] = map[string]any{} + } + return cloned + } + + if !strings.EqualFold(trimmedType, "object") { cloned["type"] = "object" cloned["properties"] = map[string]any{} return cloned diff --git a/internal/tools/mcp/adapter_test.go b/internal/tools/mcp/adapter_test.go index 4650807b..20d8a381 100644 --- a/internal/tools/mcp/adapter_test.go +++ b/internal/tools/mcp/adapter_test.go @@ -252,3 +252,36 @@ func TestAdapterEnsureObjectSchemaNormalizesInvalidType(t *testing.T) { t.Fatalf("expected normalized properties object, got %+v", schema["properties"]) } } + +func TestAdapterEnsureObjectSchemaKeepsPropertiesWhenTypeMissing(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + adapter, err := NewAdapter(registry, "docs", ToolDescriptor{ + Name: "search", + InputSchema: map[string]any{ + "properties": map[string]any{ + "q": map[string]any{"type": "string"}, + }, + }, + }) + if err != nil { + t.Fatalf("NewAdapter() error = %v", err) + } + + schema := adapter.Schema() + if schema["type"] != "object" { + t.Fatalf("expected type object, got %v", schema["type"]) + } + properties, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("expected properties object, got %+v", schema["properties"]) + } + querySchema, ok := properties["q"].(map[string]any) + if !ok { + t.Fatalf("expected q schema map, got %+v", properties["q"]) + } + if querySchema["type"] != "string" { + t.Fatalf("expected q type string, got %v", querySchema["type"]) + } +} From 31dbdd6d59a331eca72f63d46456d64a92c85a5a Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Thu, 9 Apr 2026 13:36:58 +0800 Subject: [PATCH 27/54] update contributor --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 95135439..cc30f1a9 100644 --- a/README.md +++ b/README.md @@ -206,3 +206,5 @@ go run ./cmd/neocode --workdir /path/to/workspace - `--workdir` 只影响当前进程,不会写回 `config.yaml` - 当前工作区会同时用于工具执行根目录与 session 存储分桶 - session 历史现在按工作区隔离存储,不同工作区默认互不可见 + +[![Contributors](https://hub-io-mcells-projects.vercel.app/r/1024XEngineer/neo-code)](https://github.com/1024XEngineer/neo-code/graphs/contributors) From 67caf161e62cfbf7a0157aa5bb1a1e7d349df146 Mon Sep 17 00:00:00 2001 From: pionxe Date: Thu, 9 Apr 2026 15:55:14 +0800 Subject: [PATCH 28/54] =?UTF-8?q?fix(gateway):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=AE=A1=E6=89=B9=E9=97=AD=E7=8E=AF=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=B9=B6=E4=BF=9D=E7=95=99=20session=20tool=5Fcalls?= =?UTF-8?q?=20=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/contracts.go | 34 ++++++++++++++++ internal/gateway/contracts_test.go | 40 ++++++++++++++++++ internal/gateway/types.go | 2 + internal/gateway/types_test.go | 52 ++++++++++++++++++++++++ internal/gateway/validate.go | 65 +++++++++++++++++++++++++++++- internal/gateway/validate_test.go | 45 +++++++++++++++++++++ 6 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 internal/gateway/contracts_test.go diff --git a/internal/gateway/contracts.go b/internal/gateway/contracts.go index 7ebd0366..88eba546 100644 --- a/internal/gateway/contracts.go +++ b/internal/gateway/contracts.go @@ -17,6 +17,26 @@ const ( RuntimeEventTypeRunError RuntimeEventType = "run_error" ) +// PermissionResolutionDecision 表示权限审批最终决策。 +type PermissionResolutionDecision string + +const ( + // PermissionResolutionAllowOnce 表示仅本次允许。 + PermissionResolutionAllowOnce PermissionResolutionDecision = "allow_once" + // PermissionResolutionAllowSession 表示在当前会话中持续允许。 + PermissionResolutionAllowSession PermissionResolutionDecision = "allow_session" + // PermissionResolutionReject 表示拒绝本次审批。 + PermissionResolutionReject PermissionResolutionDecision = "reject" +) + +// PermissionResolutionInput 表示一次权限审批决策输入。 +type PermissionResolutionInput struct { + // RequestID 是待审批请求标识。 + RequestID string `json:"request_id"` + // Decision 是审批决策值。 + Decision PermissionResolutionDecision `json:"decision"` +} + // RunInput 表示网关向下游运行端口发起 run 动作时的输入。 type RunInput struct { // RequestID 是客户端请求标识。 @@ -73,12 +93,24 @@ type RuntimeEvent struct { Payload any `json:"payload,omitempty"` } +// ToolCall 表示助手消息中的工具调用元数据。 +type ToolCall struct { + // ID 是工具调用标识。 + ID string `json:"id"` + // Name 是工具名。 + Name string `json:"name"` + // Arguments 是工具参数 JSON 字符串。 + Arguments string `json:"arguments"` +} + // SessionMessage 表示会话消息快照中的单条消息。 type SessionMessage struct { // Role 是消息角色。 Role string `json:"role"` // Content 是消息内容。 Content string `json:"content"` + // ToolCalls 是 assistant 发起的工具调用元数据。 + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ToolCallID 是工具消息关联的调用标识。 ToolCallID string `json:"tool_call_id,omitempty"` // IsError 表示该消息是否为错误结果。 @@ -119,6 +151,8 @@ type RuntimePort interface { Run(ctx context.Context, input RunInput) error // Compact 对指定会话触发一次手动压缩。 Compact(ctx context.Context, input CompactInput) (CompactResult, error) + // ResolvePermission 向运行时提交一次权限审批决策。 + ResolvePermission(ctx context.Context, input PermissionResolutionInput) error // CancelActiveRun 取消当前活跃运行。 CancelActiveRun() bool // Events 返回统一运行事件流。 diff --git a/internal/gateway/contracts_test.go b/internal/gateway/contracts_test.go new file mode 100644 index 00000000..e4a4e50c --- /dev/null +++ b/internal/gateway/contracts_test.go @@ -0,0 +1,40 @@ +package gateway + +import "context" + +// runtimePortCompileStub 用于编译期验证 RuntimePort 契约完整性。 +type runtimePortCompileStub struct{} + +func (s *runtimePortCompileStub) Run(_ context.Context, _ RunInput) error { + return nil +} + +func (s *runtimePortCompileStub) Compact(_ context.Context, _ CompactInput) (CompactResult, error) { + return CompactResult{}, nil +} + +func (s *runtimePortCompileStub) ResolvePermission(_ context.Context, _ PermissionResolutionInput) error { + return nil +} + +func (s *runtimePortCompileStub) CancelActiveRun() bool { + return false +} + +func (s *runtimePortCompileStub) Events() <-chan RuntimeEvent { + return nil +} + +func (s *runtimePortCompileStub) ListSessions(_ context.Context) ([]SessionSummary, error) { + return nil, nil +} + +func (s *runtimePortCompileStub) LoadSession(_ context.Context, _ string) (Session, error) { + return Session{}, nil +} + +func (s *runtimePortCompileStub) SetSessionWorkdir(_ context.Context, _, _ string) (Session, error) { + return Session{}, nil +} + +var _ RuntimePort = (*runtimePortCompileStub)(nil) diff --git a/internal/gateway/types.go b/internal/gateway/types.go index 0d6984aa..281c8132 100644 --- a/internal/gateway/types.go +++ b/internal/gateway/types.go @@ -30,6 +30,8 @@ const ( FrameActionLoadSession FrameAction = "load_session" // FrameActionSetSessionWorkdir 表示设置会话工作目录。 FrameActionSetSessionWorkdir FrameAction = "set_session_workdir" + // FrameActionResolvePermission 表示提交一次权限审批决策。 + FrameActionResolvePermission FrameAction = "resolve_permission" ) // InputPartType 表示多模态输入分片类型。 diff --git a/internal/gateway/types_test.go b/internal/gateway/types_test.go index 581a655a..8641b0f9 100644 --- a/internal/gateway/types_test.go +++ b/internal/gateway/types_test.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/json" "testing" + "time" ) func TestMessageFrameJSONRoundTrip(t *testing.T) { @@ -66,3 +67,54 @@ func TestMessageFrameJSONRoundTrip(t *testing.T) { t.Fatalf("error code mismatch") } } + +func TestSessionMessageToolCallsRoundTrip(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + original := Session{ + ID: "sess_1", + Title: "demo", + CreatedAt: now, + UpdatedAt: now, + Messages: []SessionMessage{ + { + Role: "assistant", + Content: "我准备调用工具", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Name: "filesystem_read", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + { + Role: "tool", + Content: "tool result", + ToolCallID: "call_1", + }, + }, + } + + encoded, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal session failed: %v", err) + } + + var decoded Session + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal session failed: %v", err) + } + + if len(decoded.Messages) != 2 { + t.Fatalf("message count mismatch: got %d want %d", len(decoded.Messages), 2) + } + if len(decoded.Messages[0].ToolCalls) != 1 { + t.Fatalf("assistant tool_calls count mismatch: got %d want %d", len(decoded.Messages[0].ToolCalls), 1) + } + if decoded.Messages[0].ToolCalls[0].Name != "filesystem_read" { + t.Fatalf("tool call name mismatch: got %q", decoded.Messages[0].ToolCalls[0].Name) + } + if decoded.Messages[0].ToolCalls[0].Arguments != `{"path":"README.md"}` { + t.Fatalf("tool call arguments mismatch: got %q", decoded.Messages[0].ToolCalls[0].Arguments) + } +} diff --git a/internal/gateway/validate.go b/internal/gateway/validate.go index ca9d63b9..3be96857 100644 --- a/internal/gateway/validate.go +++ b/internal/gateway/validate.go @@ -1,6 +1,10 @@ package gateway -import "strings" +import ( + "encoding/json" + "errors" + "strings" +) // ValidateFrame 校验网关协议帧是否满足基础契约约束。 func ValidateFrame(frame MessageFrame) *FrameError { @@ -39,6 +43,8 @@ func validateRequestFrame(frame MessageFrame) *FrameError { if strings.TrimSpace(frame.Workdir) == "" { return NewMissingRequiredFieldError("workdir") } + case FrameActionResolvePermission: + return validateResolvePermissionFrame(frame) case FrameActionCancel, FrameActionListSessions: return nil default: @@ -67,6 +73,60 @@ func validateRunFrame(frame MessageFrame) *FrameError { return nil } +// validateResolvePermissionFrame 校验 resolve_permission 动作所需字段。 +func validateResolvePermissionFrame(frame MessageFrame) *FrameError { + if frame.Payload == nil { + return NewMissingRequiredFieldError("payload") + } + + input, err := decodePermissionResolutionInput(frame.Payload) + if err != nil { + return NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission payload") + } + if strings.TrimSpace(input.RequestID) == "" { + return NewMissingRequiredFieldError("payload.request_id") + } + if !isValidPermissionResolutionDecision(input.Decision) { + return NewFrameError(ErrorCodeInvalidAction, "invalid resolve_permission decision") + } + + return nil +} + +// decodePermissionResolutionInput 将 payload 解析为权限审批决策输入。 +func decodePermissionResolutionInput(payload any) (PermissionResolutionInput, error) { + if direct, ok := payload.(PermissionResolutionInput); ok { + return direct, nil + } + if ptr, ok := payload.(*PermissionResolutionInput); ok { + if ptr == nil { + return PermissionResolutionInput{}, errors.New("permission payload is nil") + } + return *ptr, nil + } + + raw, err := json.Marshal(payload) + if err != nil { + return PermissionResolutionInput{}, err + } + + var input PermissionResolutionInput + if err := json.Unmarshal(raw, &input); err != nil { + return PermissionResolutionInput{}, err + } + return input, nil +} + +// isValidPermissionResolutionDecision 判断审批决策是否属于受支持集合。 +func isValidPermissionResolutionDecision(decision PermissionResolutionDecision) bool { + switch decision { + case PermissionResolutionAllowOnce, PermissionResolutionAllowSession, PermissionResolutionReject: + return true + default: + return false + } +} + // validateInputParts 校验多模态输入分片数组。 func validateInputParts(parts []InputPart) *FrameError { for index := range parts { @@ -120,7 +180,8 @@ func isValidFrameAction(action FrameAction) bool { FrameActionCancel, FrameActionListSessions, FrameActionLoadSession, - FrameActionSetSessionWorkdir: + FrameActionSetSessionWorkdir, + FrameActionResolvePermission: return true default: return false diff --git a/internal/gateway/validate_test.go b/internal/gateway/validate_test.go index 8aaaca2c..d1e86c92 100644 --- a/internal/gateway/validate_test.go +++ b/internal/gateway/validate_test.go @@ -92,6 +92,51 @@ func TestValidateFrame_BasicRules(t *testing.T) { }, wantNil: true, }, + { + name: "resolve_permission valid struct payload", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: PermissionResolutionInput{ + RequestID: "perm-1", + Decision: PermissionResolutionAllowOnce, + }, + }, + wantNil: true, + }, + { + name: "resolve_permission missing payload", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "payload", + }, + { + name: "resolve_permission missing request_id", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "decision": "allow_session", + }, + }, + wantCode: ErrorCodeMissingRequiredField.String(), + wantField: "payload.request_id", + }, + { + name: "resolve_permission invalid decision", + frame: MessageFrame{ + Type: FrameTypeRequest, + Action: FrameActionResolvePermission, + Payload: map[string]any{ + "request_id": "perm-1", + "decision": "allow_forever", + }, + }, + wantCode: ErrorCodeInvalidAction.String(), + }, { name: "event frame allows empty action", frame: MessageFrame{ From 560a7161d814db953f226b45938def0af69a7c31 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:05:11 +0800 Subject: [PATCH 29/54] =?UTF-8?q?fix(tools):=20=E9=87=8D=E6=9E=84EmitChunk?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=BC=A0=E6=92=AD=E4=B8=8E=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/permission.go | 9 ++++- internal/runtime/runtime_test.go | 6 +-- internal/tools/filesystem/read_file.go | 15 ++++++- internal/tools/filesystem/read_file_test.go | 43 ++++++++++++++++++++- internal/tools/types.go | 12 +++++- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 0ffe62ee..484a75a0 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -100,8 +100,15 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi Arguments: []byte(input.Call.Arguments), Workdir: input.Workdir, SessionID: input.SessionID, - EmitChunk: func(chunk []byte) { + EmitChunk: func(chunk []byte) error { + if err := ctx.Err(); err != nil { + return err + } s.emit(ctx, EventToolChunk, input.RunID, input.SessionID, string(chunk)) + if err := ctx.Err(); err != nil { + return err + } + return nil }, } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index ac40bf8a..51fa37b1 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -221,7 +221,7 @@ func (t *stubTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return t.executeFn(ctx, input) } if input.EmitChunk != nil { - input.EmitChunk([]byte("chunk")) + _ = input.EmitChunk([]byte("chunk")) } return tools.ToolResult{ Name: t.name, @@ -1728,7 +1728,7 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { name: "filesystem_edit", executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if input.EmitChunk != nil { - input.EmitChunk([]byte("chunk")) + _ = input.EmitChunk([]byte("chunk")) } close(toolStarted) <-ctx.Done() @@ -1788,7 +1788,7 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { name: "filesystem_edit", executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if input.EmitChunk != nil { - input.EmitChunk([]byte("chunk")) + _ = input.EmitChunk([]byte("chunk")) } close(toolStarted) <-ctx.Done() diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index 7919fdeb..1b6b3c45 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -100,12 +100,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( if input.EmitChunk != nil { content := []byte(result.Content) + emittedBytes := 0 for start := 0; start < len(content); start += emitChunkSize { end := start + emitChunkSize if end > len(content) { end = len(content) } - input.EmitChunk(content[start:end]) + if emitErr := input.EmitChunk(content[start:end]); emitErr != nil { + err := errors.New(readFileToolName + ": emit chunk failed: " + emitErr.Error()) + return tools.NewErrorResult( + t.Name(), + tools.NormalizeErrorReason(t.Name(), err), + "", + map[string]any{ + "path": target, + "emitted_bytes": emittedBytes, + }, + ), err + } + emittedBytes += end - start } } diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index 739a5ebd..e70beefb 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -3,6 +3,7 @@ package filesystem import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -80,10 +81,11 @@ func TestReadFileToolExecute(t *testing.T) { Name: tool.Name(), Arguments: args, Workdir: workspace, - EmitChunk: func(chunk []byte) { + EmitChunk: func(chunk []byte) error { if len(chunk) > 0 { chunks++ } + return nil }, }) @@ -151,10 +153,11 @@ func TestReadFileToolErrorFormattingAndTruncation(t *testing.T) { Name: tool.Name(), Arguments: tt.arguments, Workdir: workspace, - EmitChunk: func(chunk []byte) { + EmitChunk: func(chunk []byte) error { if len(chunk) > 0 { chunks++ } + return nil }, }) @@ -179,3 +182,39 @@ func TestReadFileToolErrorFormattingAndTruncation(t *testing.T) { }) } } + +func TestReadFileToolExecuteStopsOnEmitChunkError(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + content := strings.Repeat("chunk-data-", 500) + if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte(content), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + tool := New(workspace) + args := mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}) + + emitCount := 0 + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + EmitChunk: func(chunk []byte) error { + emitCount++ + if emitCount == 1 { + return errors.New("consumer closed") + } + return nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "emit chunk failed") { + t.Fatalf("expected emit chunk failure, got %v", err) + } + if !result.IsError { + t.Fatalf("expected error result, got %+v", result) + } + if result.Metadata["emitted_bytes"] != 0 { + t.Fatalf("expected emitted_bytes=0 before first successful emit, got %+v", result.Metadata) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 77bd75bb..020a67c6 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -15,7 +15,14 @@ type Tool interface { Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) } -type ChunkEmitter func(chunk []byte) +// ChunkEmitter 是工具执行过程中向上游发送流式分片的回调。 +// 并发语义: +// - 回调可能在一次执行内被调用 0 次或多次; +// - 回调在工具执行 goroutine 中调用; +// - 调用方若返回非 nil error,工具应停止后续分片发送并尽快中止执行。 +// 内存语义: +// - 回调返回后不得继续持有传入的 chunk 引用,若需异步使用必须先复制。 +type ChunkEmitter func(chunk []byte) error type ToolCallInput struct { ID string @@ -24,7 +31,8 @@ type ToolCallInput struct { SessionID string Workdir string WorkspacePlan *security.WorkspaceExecutionPlan - EmitChunk ChunkEmitter + // EmitChunk 为流式分片回调,语义见 ChunkEmitter 注释。 + EmitChunk ChunkEmitter } type ToolResult struct { From 05ad18443da180956a0aeb0514f75ff85bb31131 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:56:47 +0800 Subject: [PATCH 30/54] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=81?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E8=AF=AF=E5=88=A4=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/openai/openai_test.go | 63 +++++++++++++++++++++ internal/provider/openai/response.go | 29 ++++++---- internal/runtime/permission_test.go | 52 +++++++++++++++++ internal/tools/filesystem/read_file_test.go | 60 ++++++++++++++++++++ 4 files changed, 193 insertions(+), 11 deletions(-) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index d16edc56..242a83b7 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -555,6 +555,46 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { } } +func TestConsumeStream_ContextCancellationOnReadErrorReturnsCanceled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + body := &cancelThenErrorReader{cancel: cancel, err: io.ErrClosedPipe} + err = p.consumeStream(ctx, body, make(chan providertypes.StreamEvent, 1)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + sseData := `data: {"id":"a","choices":[{"delta":{"content":"hello"}}]} + +` + body := &cancelOnEOFReader{ + reader: strings.NewReader(sseData), + cancel: cancel, + } + events := make(chan providertypes.StreamEvent, 8) + + err = p.consumeStream(ctx, body, events) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + func TestConsumeStream_FinishReasonAccumulation(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") @@ -1266,6 +1306,29 @@ type errReader struct{ err error } func (e *errReader) Read(_ []byte) (int, error) { return 0, e.err } +type cancelThenErrorReader struct { + cancel func() + err error +} + +func (r *cancelThenErrorReader) Read(_ []byte) (int, error) { + r.cancel() + return 0, r.err +} + +type cancelOnEOFReader struct { + reader io.Reader + cancel func() +} + +func (r *cancelOnEOFReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if errors.Is(err, io.EOF) { + r.cancel() + } + return n, err +} + type failingReadCloser struct{ err error } func (f *failingReadCloser) Read(_ []byte) (int, error) { return 0, f.err } diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 5026a15d..c9cb41d5 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -13,7 +13,7 @@ import ( providertypes "neo-code/internal/provider/types" ) -// consumeStream 消费 SSE 响应流,使用有界读取器防止缓冲区溢出。 +// consumeStream 消费 SSE 响应流,并在 [DONE] 或 message_done 时完成收尾。 func (p *Provider) consumeStream( ctx context.Context, body io.Reader, @@ -30,7 +30,7 @@ func (p *Provider) consumeStream( dataLines := make([]string, 0, 4) - // processChunk 解析单个 SSE data payload,发送事件。 + // processChunk 解析单个 SSE data payload,并发出增量事件。 processChunk := func(payload string) error { if strings.TrimSpace(payload) == "[DONE]" { done = true @@ -66,23 +66,31 @@ func (p *Provider) consumeStream( return nil } - // finishStream 统一的流结束处理:发送 message_done 事件。 + // finishStream 统一输出 message_done 收尾事件。 finishStream := func() error { log.Printf("[DEBUG-STREAM] finishStream called: finishReason=%q, done=%v", finishReason, done) return emitMessageDone(ctx, events, finishReason, &usage) } + // flushPendingData 刷新积累的 data 行,保证多行 data payload 正确拼接。 flushPendingData := func() error { defer func() { dataLines = dataLines[:0] }() return flushDataLines(dataLines, processChunk) } for { - line, err := reader.ReadLine() + // 每次读取前优先响应上下文取消,避免取消请求被误判为流中断。 + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + line, err := reader.ReadLine() if err != nil && !errors.Is(err, io.EOF) { - // 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断, - // 避免中断前最后一段数据丢失。 + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } if flushErr := flushPendingData(); flushErr != nil { return flushErr } @@ -90,12 +98,9 @@ func (p *Provider) consumeStream( } trimmed := line - switch { case strings.HasPrefix(trimmed, "data:"): data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - // data: [DONE] 需要立即处理:先刷新已缓冲的 data 行,再标记结束, - // 避免与前面的合法 JSON 拼接后导致 json.Unmarshal 失败。 if data == "[DONE]" { if flushErr := flushPendingData(); flushErr != nil { return flushErr @@ -116,10 +121,12 @@ func (p *Provider) consumeStream( } if errors.Is(err, io.EOF) { - // [DEBUG] 流 EOF 时打印关键状态,用于诊断截断原因 log.Printf("[DEBUG-STREAM] EOF reached: done=%v, finishReason=%q, totalRead=%d, toolCallCount=%d", done, finishReason, reader.totalRead, len(toolCalls)) if !done { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } log.Printf("[DEBUG-STREAM] WARNING: stream ended WITHOUT [DONE] marker — treating as interruption") if flushErr := flushPendingData(); flushErr != nil { return flushErr @@ -134,7 +141,7 @@ func (p *Provider) consumeStream( } } -// extractStreamUsage 从 OpenAI usage 响应提取并覆盖累积的 token 统计。 +// extractStreamUsage 将 OpenAI usage 响应覆盖到累计 token 统计。 func extractStreamUsage(usage *providertypes.Usage, raw *openAIUsage) { if raw == nil { return diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index c82a9526..7a960698 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -335,3 +335,55 @@ func TestResolvePermissionCanceledContext(t *testing.T) { t.Fatalf("expected context canceled, got %v", err) } } + +func TestExecuteToolCallWithPermissionReturnsContextCanceledFromEmitChunk(t *testing.T) { + t.Parallel() + + registry := tools.NewRegistry() + registry.Register(&stubTool{ + name: "filesystem_read_file", + executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if input.EmitChunk == nil { + t.Fatalf("expected EmitChunk callback") + } + if err := input.EmitChunk([]byte("stream-chunk")); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled from emitter, got %v", err) + } + return tools.NewErrorResult(input.Name, "emit failed", "", nil), context.Canceled + }, + }) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: "run-canceled", + SessionID: "session-canceled", + Call: providertypes.ToolCall{ + ID: "call-canceled", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + ToolTimeout: time.Second, + }) + if !errors.Is(execErr, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", execErr) + } +} diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index e70beefb..5834b6b9 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -218,3 +218,63 @@ func TestReadFileToolExecuteStopsOnEmitChunkError(t *testing.T) { t.Fatalf("expected emitted_bytes=0 before first successful emit, got %+v", result.Metadata) } } + +func TestReadFileToolExecuteEmitsProgressBeforeChunkFailure(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + content := strings.Repeat("chunk-data-", 500) + if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte(content), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + tool := New(workspace) + args := mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}) + + emitCount := 0 + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + EmitChunk: func(chunk []byte) error { + emitCount++ + if emitCount == 2 { + return errors.New("consumer closed on second chunk") + } + return nil + }, + }) + if err == nil || !strings.Contains(err.Error(), "emit chunk failed") { + t.Fatalf("expected emit chunk failure, got %v", err) + } + if !result.IsError { + t.Fatalf("expected error result, got %+v", result) + } + if result.Metadata["emitted_bytes"] != emitChunkSize { + t.Fatalf("expected emitted_bytes=%d after first successful chunk, got %+v", emitChunkSize, result.Metadata) + } +} + +func TestReadFileToolExecuteWithoutChunkEmitter(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "small.txt"), []byte("hello without stream"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + tool := New(workspace) + args := mustMarshalFSArgs(t, map[string]string{"path": "small.txt"}) + + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Content != "hello without stream" { + t.Fatalf("unexpected content: %q", result.Content) + } +} From b57403663df9713adffe68711695d401e97205a7 Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 8 Apr 2026 21:09:47 +0800 Subject: [PATCH 31/54] =?UTF-8?q?fix(test):=E8=A1=A5=E5=85=85=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/bootstrap/builder_test.go | 132 +++ internal/tui/core/app/app.go | 37 +- internal/tui/core/app/command_menu_test.go | 133 +++ internal/tui/core/app/commands.go | 40 +- internal/tui/core/app/commands_test.go | 154 +++ internal/tui/core/app/update.go | 126 +++ internal/tui/core/app/update_test.go | 1066 ++++++++++++++++++++ internal/tui/services/runtime_service.go | 17 + internal/tui/services/services_test.go | 25 + internal/tui/state/messages.go | 7 + 10 files changed, 1703 insertions(+), 34 deletions(-) create mode 100644 internal/tui/core/app/update_test.go diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..4ed2e903 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "errors" "testing" "neo-code/internal/config" @@ -164,3 +165,134 @@ func TestNormalizeMode(t *testing.T) { }) } } + +type errorFactory struct { + runtimeErr error + providerErr error + runtimeNil bool + providerNil bool +} + +func (f errorFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + if f.runtimeErr != nil { + return nil, f.runtimeErr + } + if f.runtimeNil { + return nil, nil + } + return current, nil +} + +func (f errorFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + if f.providerErr != nil { + return nil, f.providerErr + } + if f.providerNil { + return nil, nil + } + return current, nil +} + +type noopRuntime struct{} + +func (r noopRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r noopRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r noopRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r noopRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r noopRuntime) CancelActiveRun() bool { + return false +} + +func (r noopRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r noopRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r noopRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +type noopProviderService struct{} + +func (s noopProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s noopProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s noopProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s noopProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s noopProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func TestBuildFactoryErrors(t *testing.T) { + manager := &config.Manager{} + runtimeSvc := noopRuntime{} + providerSvc := noopProviderService{} + + _, err := Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{runtimeErr: errors.New("runtime boom")}, + }) + if err == nil { + t.Fatalf("expected runtime factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{providerErr: errors.New("provider boom")}, + }) + if err == nil { + t.Fatalf("expected provider factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{runtimeNil: true}, + }) + if err == nil { + t.Fatalf("expected nil runtime factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{providerNil: true}, + }) + if err == nil { + t.Fatalf("expected nil provider factory error") + } +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index f290ce30..ca856676 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -44,6 +44,7 @@ type RuntimeClosedMsg = tuistate.RuntimeClosedMsg type runFinishedMsg = tuistate.RunFinishedMsg type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg type compactFinishedMsg = tuistate.CompactFinishedMsg +type permissionResolvedMsg = tuistate.PermissionResolvedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg @@ -83,22 +84,26 @@ type appComponents struct { // appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 type appRuntimeState struct { - codeCopyBlocks map[int]string - pendingCopyID int - nowFn func() time.Time - lastInputEditAt time.Time - lastPasteLikeAt time.Time - inputBurstStart time.Time - inputBurstCount int - pasteMode bool - activeMessages []providertypes.Message - activities []tuistate.ActivityEntry - fileCandidates []string - modelRefreshID string - focus panel - runProgressValue float64 - runProgressKnown bool - runProgressLabel string + codeCopyBlocks map[int]string + pendingCopyID int + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool + activeMessages []providertypes.Message + activities []tuistate.ActivityEntry + fileCandidates []string + modelRefreshID string + focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string + pendingPermissionID string + pendingPermissionTool string + pendingPermissionHint string + pendingPermissionSubmitted bool } type App struct { diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go index fa1ea274..1aae7bac 100644 --- a/internal/tui/core/app/command_menu_test.go +++ b/internal/tui/core/app/command_menu_test.go @@ -1,8 +1,11 @@ package tui import ( + "path/filepath" "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestCommandMenuItem(t *testing.T) { @@ -80,3 +83,133 @@ func TestCommandMenuView(t *testing.T) { t.Error("View() returned empty string") } } + +func TestBuildCommandMenuItemsForWorkspaceCommand(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentWorkdir = "/workspace/root" + + items, meta := app.buildCommandMenuItems("&", 80) + if meta.Title != shellMenuTitle { + t.Fatalf("expected shell menu title, got %q", meta.Title) + } + if len(items) != 1 { + t.Fatalf("expected one item, got %d", len(items)) + } + if !items[0].useReplaceRange || items[0].replacement != workspaceCommandPrefix+" " { + t.Fatalf("expected workspace replace range") + } +} + +func TestBuildCommandMenuItemsForSlashCommands(t *testing.T) { + app, _ := newTestApp(t) + + items, meta := app.buildCommandMenuItems("/he", 80) + if meta.Title != commandMenuTitle { + t.Fatalf("expected command menu title, got %q", meta.Title) + } + if len(items) == 0 { + t.Fatalf("expected slash command suggestions") + } + found := false + for _, item := range items { + if item.replacement == slashUsageHelp { + found = true + } + } + if !found { + t.Fatalf("expected help suggestion to appear") + } +} + +func TestFileMenuSuggestionsEmptyQueryIncludesBrowse(t *testing.T) { + app, _ := newTestApp(t) + app.fileCandidates = []string{"README.md", "docs/guide.md"} + + items := app.fileMenuSuggestions("@") + if len(items) == 0 || !items[0].openFileBrowser { + t.Fatalf("expected browse file entry") + } +} + +func TestFileMenuSuggestionsMatchesQuery(t *testing.T) { + app, _ := newTestApp(t) + app.fileCandidates = []string{"README.md", "docs/guide.md"} + + items := app.fileMenuSuggestions("@read") + if len(items) == 0 { + t.Fatalf("expected file suggestions") + } + if items[0].replacement == "" { + t.Fatalf("expected replacement to be set") + } +} + +func TestApplySelectedCommandSuggestionReplacesInput(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/he") + app.state.InputText = "/he" + app.transcript.Width = 80 + app.refreshCommandMenu() + + if !app.commandMenuHasSuggestions() { + t.Fatalf("expected suggestions") + } + if !app.applySelectedCommandSuggestion() { + t.Fatalf("expected suggestion to apply") + } + if app.input.Value() == "/he" { + t.Fatalf("expected input to change") + } +} + +func TestApplySelectedCommandSuggestionOpenFileBrowser(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentWorkdir = t.TempDir() + app.fileCandidates = []string{"README.md"} + app.input.SetValue("@") + app.transcript.Width = 80 + app.refreshCommandMenu() + + if !app.commandMenuHasSuggestions() { + t.Fatalf("expected suggestions") + } + applied := app.applySelectedCommandSuggestion() + if !applied { + t.Fatalf("expected browse action to apply") + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker to open") + } +} + +func TestUpdateCommandMenuSelectionHandlesNavigationKeys(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/he") + app.transcript.Width = 80 + app.refreshCommandMenu() + + _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyDown}) + if !handled { + t.Fatalf("expected navigation key to be handled") + } + _, handled = app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if handled { + t.Fatalf("expected non-navigation key to be ignored") + } +} + +func TestOpenFileBrowserUsesAbsoluteWorkdir(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + app.state.CurrentWorkdir = root + + app.openFileBrowser() + + expected, _ := filepath.Abs(root) + if app.fileBrowser.CurrentDirectory != expected { + t.Fatalf("expected absolute directory, got %q", app.fileBrowser.CurrentDirectory) + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker to be active") + } +} diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ad373587..824d0f03 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -52,24 +52,28 @@ const ( emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." emptyMessageText = "(empty)" - 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" - statusApplyingCommand = "Applying local command" - statusRunningCommand = "Running command" - statusCommandDone = "Command finished" - statusCompacting = "Compacting context" - statusChooseProvider = "Choose a provider" - statusChooseModel = "Choose a model" - statusBrowseFile = "Browse workspace files" + 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" + statusApplyingCommand = "Applying local command" + statusRunningCommand = "Running command" + statusCommandDone = "Command finished" + statusCompacting = "Compacting context" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" + statusBrowseFile = "Browse workspace files" + statusAwaitingPermission = "Awaiting permission (y=once, a=session, n=deny)" + statusPermissionApproved = "Permission approved" + statusPermissionDenied = "Permission denied" + statusPermissionFailed = "Permission approval failed" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index cef5e8b4..ead1adb4 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -1,9 +1,15 @@ package tui import ( + "context" + "errors" + "strings" "testing" "github.com/charmbracelet/bubbles/list" + + "neo-code/internal/config" + tuistatus "neo-code/internal/tui/core/status" ) func TestBuiltinSlashCommands(t *testing.T) { @@ -142,3 +148,151 @@ func TestMaxActivityEntries(t *testing.T) { t.Error("maxActivityEntries should not be zero") } } + +type errorProviderService struct { + err error +} + +func (s errorProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, s.err +} + +func (s errorProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, s.err +} + +func (s errorProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, s.err +} + +func TestExecuteLocalCommandErrors(t *testing.T) { + app, _ := newTestApp(t) + snapshot := app.currentStatusSnapshot() + + if _, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, ""); err == nil { + t.Fatalf("expected empty command error") + } + if _, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/unknown"); err == nil { + t.Fatalf("expected unknown command error") + } +} + +func TestExecuteLocalCommandHelpAndStatus(t *testing.T) { + app, _ := newTestApp(t) + snapshot := app.currentStatusSnapshot() + + helpText, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/help") + if err != nil { + t.Fatalf("executeLocalCommand(/help) error = %v", err) + } + if !strings.Contains(helpText, "Available slash commands:") { + t.Fatalf("expected help output, got %q", helpText) + } + + statusText, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/status") + if err != nil { + t.Fatalf("executeLocalCommand(/status) error = %v", err) + } + if !strings.Contains(statusText, "Status:") { + t.Fatalf("expected status output, got %q", statusText) + } +} + +func TestExecuteProviderCommandValidation(t *testing.T) { + app, _ := newTestApp(t) + if _, err := executeProviderCommand(context.Background(), app.providerSvc, ""); err == nil { + t.Fatalf("expected usage error") + } +} + +func TestExecuteProviderCommandSuccess(t *testing.T) { + app, _ := newTestApp(t) + value := app.state.CurrentProvider + if strings.TrimSpace(value) == "" { + t.Fatalf("expected provider id to be set") + } + + message, err := executeProviderCommand(context.Background(), app.providerSvc, value) + if err != nil { + t.Fatalf("executeProviderCommand error = %v", err) + } + if !strings.Contains(message, value) { + t.Fatalf("expected provider id in message, got %q", message) + } +} + +func TestExecuteProviderCommandPropagatesError(t *testing.T) { + providerSvc := errorProviderService{err: errors.New("boom")} + if _, err := executeProviderCommand(context.Background(), providerSvc, "any"); err == nil { + t.Fatalf("expected provider error") + } +} + +func TestRunProviderSelectionCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runProviderSelection(app.providerSvc, app.state.CurrentProvider) + if cmd == nil { + t.Fatalf("expected cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !result.ProviderChanged || !strings.Contains(result.Notice, app.state.CurrentProvider) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestRunModelSelectionCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runModelSelection(app.providerSvc, app.state.CurrentModel) + if cmd == nil { + t.Fatalf("expected cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !result.ModelChanged || !strings.Contains(result.Notice, app.state.CurrentModel) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestRunModelCatalogRefreshCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runModelCatalogRefresh(app.providerSvc, app.state.CurrentProvider) + if cmd == nil { + t.Fatalf("expected refresh cmd") + } + msg := cmd() + result, ok := msg.(modelCatalogRefreshMsg) + if !ok { + t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) + } + if !strings.EqualFold(result.ProviderID, app.state.CurrentProvider) { + t.Fatalf("unexpected provider id: %s", result.ProviderID) + } +} + +func TestExecuteStatusCommandFormatting(t *testing.T) { + snapshot := tuistatus.Snapshot{ + ActiveSessionTitle: "Draft", + CurrentProvider: "test-provider", + CurrentModel: "test-model", + CurrentWorkdir: "/tmp", + } + output := executeStatusCommand(snapshot) + if !strings.Contains(output, "Status:") { + t.Fatalf("expected Status header, got %q", output) + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index e9face16..cc815975 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -131,6 +131,30 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.rebuildTranscript() a.transcript.GotoBottom() return a, tea.Batch(cmds...) + case permissionResolvedMsg: + if typed.RequestID != "" && typed.RequestID != a.pendingPermissionID { + return a, tea.Batch(cmds...) + } + a.pendingPermissionSubmitted = false + if typed.Err != nil { + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = statusPermissionFailed + a.appendActivity("permission", "Permission approval failed", typed.Err.Error(), true) + return a, tea.Batch(cmds...) + } + a.state.ExecutionError = "" + decision := strings.ToLower(strings.TrimSpace(typed.Decision)) + switch decision { + case "allow_once", "allow_session": + a.state.StatusText = statusPermissionApproved + default: + a.state.StatusText = statusPermissionDenied + } + a.appendActivity("permission", "Permission resolved", decision, false) + a.pendingPermissionID = "" + a.pendingPermissionTool = "" + a.pendingPermissionHint = "" + return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.Err != nil { a.state.ExecutionError = typed.Err.Error() @@ -213,6 +237,21 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) } case tea.KeyMsg: + if decision, ok := resolvePermissionDecisionKey(typed); ok && strings.TrimSpace(a.pendingPermissionID) != "" { + if a.pendingPermissionSubmitted { + return a, tea.Batch(cmds...) + } + if a.runtime == nil { + a.state.ExecutionError = "runtime is not available" + a.state.StatusText = statusPermissionFailed + return a, tea.Batch(cmds...) + } + a.pendingPermissionSubmitted = true + a.state.ExecutionError = "" + a.state.StatusText = statusAwaitingPermission + cmds = append(cmds, runPermissionResolve(a.runtime, a.pendingPermissionID, decision)) + return a, tea.Batch(cmds...) + } if key.Matches(typed, a.keys.Quit) { return a, tea.Quit } @@ -717,6 +756,8 @@ var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentrun agentruntime.EventType(tuiservices.RuntimeEventRunContext): runtimeEventRunContextHandler, agentruntime.EventType(tuiservices.RuntimeEventToolStatus): runtimeEventToolStatusHandler, agentruntime.EventType(tuiservices.RuntimeEventUsage): runtimeEventUsageHandler, + agentruntime.EventPermissionRequest: runtimeEventPermissionRequestHandler, + agentruntime.EventPermissionResolved: runtimeEventPermissionResolvedHandler, agentruntime.EventToolCallThinking: runtimeEventToolCallThinkingHandler, agentruntime.EventToolStart: runtimeEventToolStartHandler, agentruntime.EventToolResult: runtimeEventToolResultHandler, @@ -807,6 +848,58 @@ func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { return false } +// runtimeEventPermissionRequestHandler 处理权限审批请求并提示用户输入。 +func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.PermissionRequestPayload) + if !ok { + return false + } + a.pendingPermissionID = strings.TrimSpace(payload.RequestID) + a.pendingPermissionTool = strings.TrimSpace(payload.ToolName) + a.pendingPermissionHint = formatPermissionPrompt(payload) + a.pendingPermissionSubmitted = false + a.state.ExecutionError = "" + a.state.StatusText = statusAwaitingPermission + a.appendActivity("permission", "Permission required", a.pendingPermissionHint, false) + return false +} + +// runtimeEventPermissionResolvedHandler 处理权限审批结果并更新状态。 +func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := event.Payload.(agentruntime.PermissionResolvedPayload) + if !ok { + return false + } + a.pendingPermissionID = "" + a.pendingPermissionTool = "" + a.pendingPermissionHint = "" + a.pendingPermissionSubmitted = false + if strings.EqualFold(payload.Decision, "allow") { + a.state.StatusText = statusPermissionApproved + } else { + a.state.StatusText = statusPermissionDenied + } + a.appendActivity("permission", "Permission resolved", fmt.Sprintf("%s %s", payload.Decision, payload.ToolName), false) + return false +} + +// formatPermissionPrompt 组装权限审批提示内容。 +func formatPermissionPrompt(payload agentruntime.PermissionRequestPayload) string { + target := strings.TrimSpace(payload.Target) + operation := strings.TrimSpace(payload.Operation) + toolName := strings.TrimSpace(payload.ToolName) + if operation == "" && target == "" { + return toolName + } + if operation == "" { + return fmt.Sprintf("%s %s", toolName, target) + } + if target == "" { + return fmt.Sprintf("%s %s", toolName, operation) + } + return fmt.Sprintf("%s %s %s", toolName, operation, target) +} + // runtimeEventToolCallThinkingHandler 处理工具规划阶段事件。 func runtimeEventToolCallThinkingHandler(a *App, event agentruntime.RuntimeEvent) bool { if payload, ok := event.Payload.(string); ok && strings.TrimSpace(payload) != "" { @@ -1554,6 +1647,21 @@ func ListenForRuntimeEvent(sub <-chan agentruntime.RuntimeEvent) tea.Cmd { ) } +// resolvePermissionDecisionKey 将按键映射为权限审批决策。 +func resolvePermissionDecisionKey(msg tea.KeyMsg) (agentruntime.PermissionResolutionDecision, bool) { + typed := strings.ToLower(strings.TrimSpace(msg.String())) + switch typed { + case "y": + return agentruntime.PermissionResolutionAllowOnce, true + case "a": + return agentruntime.PermissionResolutionAllowSession, true + case "n": + return agentruntime.PermissionResolutionReject, true + default: + return "", false + } +} + func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, workdir string, content string) tea.Cmd { return tuiservices.RunAgentCmd( runtime, @@ -1567,6 +1675,24 @@ func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, work ) } +// runPermissionResolve 触发权限审批回传并封装 TUI 消息。 +func runPermissionResolve(runtime agentruntime.Runtime, requestID string, decision agentruntime.PermissionResolutionDecision) tea.Cmd { + return tuiservices.RunPermissionResolveCmd( + runtime, + agentruntime.PermissionResolutionInput{ + RequestID: requestID, + Decision: decision, + }, + func(err error) tea.Msg { + return permissionResolvedMsg{ + RequestID: requestID, + Decision: string(decision), + Err: err, + } + }, + ) +} + func runSessionWorkdirCommand( runtime agentruntime.Runtime, sessionID string, diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go new file mode 100644 index 00000000..4eb81a47 --- /dev/null +++ b/internal/tui/core/app/update_test.go @@ -0,0 +1,1066 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + "neo-code/internal/tools" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" +) + +type stubProviderService struct { + providers []config.ProviderCatalogItem + models []config.ModelDescriptor +} + +func (s stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return s.providers, nil +} + +func (s stubProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + modelID := "" + if len(s.models) > 0 { + modelID = s.models[0].ID + } + return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil +} + +func (s stubProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, nil +} + +func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, nil +} + +func (s stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + providerID := "" + if len(s.providers) > 0 { + providerID = s.providers[0].ID + } + return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil +} + +type stubRuntime struct { + events chan agentruntime.RuntimeEvent + resolveCalls []agentruntime.PermissionResolutionInput + resolveErr error + cancelInvoked bool +} + +func newStubRuntime() *stubRuntime { + return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} +} + +func (s *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (s *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (s *stubRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + s.resolveCalls = append(s.resolveCalls, input) + return s.resolveErr +} + +func (s *stubRuntime) CancelActiveRun() bool { + s.cancelInvoked = true + return true +} + +func (s *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { + return s.events +} + +func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.NewWithWorkdir("draft", ""), nil +} + +func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.NewWithWorkdir("draft", workdir), nil +} + +func newTestApp(t *testing.T) (App, *stubRuntime) { + t.Helper() + + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + runtime := newStubRuntime() + app, err := newApp(tuibootstrap.Container{ + Config: *cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{providers: providers, models: models}, + }) + if err != nil { + t.Fatalf("newApp() error = %v", err) + } + + return app, runtime +} + +func TestAppUpdateBasic(t *testing.T) { + app, _ := newTestApp(t) + + windowMsg := tea.WindowSizeMsg{Width: 100, Height: 30} + model, cmd := app.Update(windowMsg) + if model == nil { + t.Error("Update returned nil model for WindowSizeMsg") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for WindowSizeMsg") + } + + app.state.StatusText = "" + closedMsg := RuntimeClosedMsg{} + model, cmd = app.Update(closedMsg) + if model == nil { + t.Error("Update returned nil model for RuntimeClosedMsg") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for RuntimeClosedMsg") + } + if app.state.StatusText != statusRuntimeClosed { + t.Errorf("Expected status %s, got %s", statusRuntimeClosed, app.state.StatusText) + } + + runErrMsg := runFinishedMsg{Err: errors.New("test error")} + model, cmd = app.Update(runErrMsg) + if model == nil { + t.Error("Update returned nil model for runFinishedMsg with error") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for runFinishedMsg with error") + } + + canceledMsg := runFinishedMsg{Err: context.Canceled} + model, cmd = app.Update(canceledMsg) + if model == nil { + t.Error("Update returned nil model for runFinishedMsg with canceled error") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for runFinishedMsg with canceled error") + } +} + +func TestResolvePermissionDecisionKey(t *testing.T) { + if decision, ok := resolvePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}); !ok || decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("expected allow_once, got %v (ok=%v)", decision, ok) + } + if decision, ok := resolvePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}); !ok || decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("expected allow_session, got %v (ok=%v)", decision, ok) + } + if decision, ok := resolvePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}); !ok || decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject, got %v (ok=%v)", decision, ok) + } + if _, ok := resolvePermissionDecisionKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}); ok { + t.Fatalf("expected unsupported key to return false") + } +} + +func TestRuntimeEventPermissionRequestHandler(t *testing.T) { + app, _ := newTestApp(t) + + payload := agentruntime.PermissionRequestPayload{ + RequestID: "perm-1", + ToolName: "bash", + Operation: "write", + Target: "file.txt", + } + handled := runtimeEventPermissionRequestHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected handler to return false") + } + if app.pendingPermissionID != "perm-1" { + t.Fatalf("expected pending permission id to be set") + } + if app.state.StatusText != statusAwaitingPermission { + t.Fatalf("expected awaiting permission status, got %s", app.state.StatusText) + } + if app.pendingPermissionHint == "" { + t.Fatalf("expected pending permission hint to be set") + } +} + +func TestRuntimeEventPermissionResolvedHandler(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-2" + + payload := agentruntime.PermissionResolvedPayload{ + RequestID: "perm-2", + ToolName: "bash", + Decision: "allow", + } + handled := runtimeEventPermissionResolvedHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected handler to return false") + } + if app.pendingPermissionID != "" { + t.Fatalf("expected pending permission id to be cleared") + } + if app.state.StatusText != statusPermissionApproved { + t.Fatalf("expected approved status, got %s", app.state.StatusText) + } +} + +func TestUpdatePermissionResolveFlow(t *testing.T) { + app, runtime := newTestApp(t) + app.pendingPermissionID = "perm-3" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if model == nil { + t.Fatalf("expected non-nil model") + } + app = model.(App) + if cmd == nil { + t.Fatalf("expected command to resolve permission") + } + + msg := cmd() + if len(runtime.resolveCalls) != 1 || runtime.resolveCalls[0].RequestID != "perm-3" { + t.Fatalf("expected ResolvePermission to be called") + } + switch typed := msg.(type) { + case tea.BatchMsg: + for _, item := range typed { + next, _ := app.Update(item) + app = next.(App) + } + default: + next, _ := app.Update(msg) + app = next.(App) + } + + if app.state.StatusText != statusPermissionApproved { + t.Fatalf("expected approved status, got %s", app.state.StatusText) + } +} + +func TestUpdatePermissionResolvedError(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-4" + + model, _ := app.Update(permissionResolvedMsg{ + RequestID: "perm-4", + Decision: "allow_once", + Err: errors.New("boom"), + }) + app = model.(App) + + if app.state.StatusText != statusPermissionFailed { + t.Fatalf("expected failure status, got %s", app.state.StatusText) + } +} + +func TestRunPermissionResolveCommand(t *testing.T) { + runtime := newStubRuntime() + cmd := runPermissionResolve(runtime, "perm-5", agentruntime.PermissionResolutionAllowSession) + if cmd == nil { + t.Fatalf("expected command") + } + msg := cmd() + resolved, ok := msg.(permissionResolvedMsg) + if !ok { + t.Fatalf("expected permissionResolvedMsg, got %T", msg) + } + if resolved.RequestID != "perm-5" || resolved.Decision != string(agentruntime.PermissionResolutionAllowSession) { + t.Fatalf("unexpected resolved msg: %#v", resolved) + } + if len(runtime.resolveCalls) != 1 { + t.Fatalf("expected resolve call recorded") + } +} + +func TestFormatPermissionPrompt(t *testing.T) { + payload := agentruntime.PermissionRequestPayload{ + ToolName: "bash", + Operation: "write", + Target: "file.txt", + } + got := formatPermissionPrompt(payload) + if got == "" || got == "bash" { + t.Fatalf("expected formatted prompt, got %q", got) + } +} + +func TestUpdatePermissionResolvedMsgIgnoresMismatch(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-6" + model, cmd := app.Update(permissionResolvedMsg{ + RequestID: "perm-7", + Decision: "allow_once", + }) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd") + } + if app.pendingPermissionID != "perm-6" { + t.Fatalf("expected pending permission to remain") + } +} + +func TestRuntimeEventPermissionRequestUsesToolName(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.PermissionRequestPayload{ + RequestID: "perm-8", + ToolName: "webfetch", + } + runtimeEventPermissionRequestHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.pendingPermissionTool != "webfetch" { + t.Fatalf("expected pending permission tool to be set") + } +} + +func TestUpdatePermissionRejectFlow(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-9" + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + if cmd == nil { + t.Fatalf("expected resolve cmd") + } + app = model.(App) + msg := cmd() + next, _ := app.Update(msg) + app = next.(App) + if app.state.StatusText != statusPermissionDenied { + t.Fatalf("expected denied status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventToolResultHandlerUpdatesMessages(t *testing.T) { + app, _ := newTestApp(t) + result := tools.ToolResult{ + Name: "bash", + Content: "ok", + IsError: false, + ToolCallID: "tool-1", + } + handled := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{Payload: result}) + if !handled { + t.Fatalf("expected handler to return true") + } + last := app.activeMessages[len(app.activeMessages)-1] + if last.Role != roleTool || last.Content != "ok" { + t.Fatalf("unexpected tool message: %#v", last) + } +} + +func TestRuntimeEventToolResultHandlerError(t *testing.T) { + app, _ := newTestApp(t) + result := tools.ToolResult{ + Name: "bash", + Content: "boom", + IsError: true, + ToolCallID: "tool-2", + } + handled := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{Payload: result}) + if !handled { + t.Fatalf("expected handler to return true") + } + if app.state.StatusText != statusToolError { + t.Fatalf("expected tool error status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventAgentDoneHandlerAppendsMessage(t *testing.T) { + app, _ := newTestApp(t) + payload := providertypes.Message{Role: roleAssistant, Content: "done"} + handled := runtimeEventAgentDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected handler to return true") + } + if len(app.activeMessages) == 0 { + t.Fatalf("expected message appended") + } +} + +func TestParseFenceOpenLine(t *testing.T) { + info, ok := parseFenceOpenLine("```go") + if !ok || info != "go" { + t.Fatalf("expected fence info, got %q ok=%v", info, ok) + } + info, ok = parseFenceOpenLine(" not a fence") + if ok || info != "" { + t.Fatalf("expected no fence") + } +} + +func TestIsFenceCloseLine(t *testing.T) { + if !isFenceCloseLine("```") { + t.Fatalf("expected fence close") + } + if isFenceCloseLine("```go") { + t.Fatalf("expected not fence close") + } +} + +func TestIsIndentedCodeLine(t *testing.T) { + if !isIndentedCodeLine("\tcode") { + t.Fatalf("expected tab-indented code") + } + if !isIndentedCodeLine(" code") { + t.Fatalf("expected space-indented code") + } + if isIndentedCodeLine("code") { + t.Fatalf("expected non-indented line") + } +} + +func TestTrimCodeIndent(t *testing.T) { + if got := trimCodeIndent("\tcode"); got != "code" { + t.Fatalf("expected trimmed tab indent, got %q", got) + } + if got := trimCodeIndent(" code"); got != "code" { + t.Fatalf("expected trimmed space indent, got %q", got) + } + if got := trimCodeIndent("code"); got != "code" { + t.Fatalf("expected unchanged line, got %q", got) + } +} + +func TestSplitMarkdownSegmentsFenced(t *testing.T) { + content := "hello\n```go\nfmt.Println(\"ok\")\n```\nworld" + segments := splitMarkdownSegments(content) + if len(segments) < 2 { + t.Fatalf("expected multiple segments, got %d", len(segments)) + } + if segments[1].Kind != markdownSegmentCode || segments[1].Code == "" { + t.Fatalf("expected code segment") + } +} + +func TestSplitMarkdownSegmentsIndented(t *testing.T) { + content := "hello\n code line\nworld" + segments := splitMarkdownSegments(content) + if len(segments) < 2 { + t.Fatalf("expected multiple segments, got %d", len(segments)) + } + foundCode := false + for _, seg := range segments { + if seg.Kind == markdownSegmentCode && seg.Code != "" { + foundCode = true + } + } + if !foundCode { + t.Fatalf("expected indented code segment") + } +} + +func TestExtractFencedCodeBlocks(t *testing.T) { + content := "text\n```go\nfmt.Println(\"ok\")\n```\nend" + blocks := extractFencedCodeBlocks(content) + if len(blocks) != 1 || blocks[0] == "" { + t.Fatalf("expected one code block") + } +} + +func TestParseCopyCodeButton(t *testing.T) { + id, start, end, ok := parseCopyCodeButton("[Copy code #12]") + if !ok || id != 12 || start >= end { + t.Fatalf("unexpected parse result: id=%d start=%d end=%d ok=%v", id, start, end, ok) + } + if _, _, _, ok := parseCopyCodeButton("no button"); ok { + t.Fatalf("expected no button parse") + } +} + +func TestCopyCodeBlockByIDSuccess(t *testing.T) { + app, _ := newTestApp(t) + + var got string + originalClipboard := clipboardWriteAll + clipboardWriteAll = func(text string) error { + got = text + return nil + } + defer func() { clipboardWriteAll = originalClipboard }() + + app.setCodeCopyBlocks([]copyCodeButtonBinding{{ID: 1, Code: "code"}}) + ok := app.copyCodeBlockByID(1) + if !ok { + t.Fatalf("expected handled copy") + } + if got != "code" { + t.Fatalf("expected clipboard content, got %q", got) + } + if app.state.StatusText == "" { + t.Fatalf("expected status text to be set") + } +} + +func TestCopyCodeBlockByIDMissing(t *testing.T) { + app, _ := newTestApp(t) + + ok := app.copyCodeBlockByID(99) + if !ok { + t.Fatalf("expected handled copy") + } + if app.state.StatusText != statusCodeCopyError { + t.Fatalf("expected error status, got %s", app.state.StatusText) + } +} + +func TestCopyCodeBlockByIDClipboardError(t *testing.T) { + app, _ := newTestApp(t) + + originalClipboard := clipboardWriteAll + clipboardWriteAll = func(text string) error { + return errors.New("fail") + } + defer func() { clipboardWriteAll = originalClipboard }() + + app.setCodeCopyBlocks([]copyCodeButtonBinding{{ID: 2, Code: "code"}}) + ok := app.copyCodeBlockByID(2) + if !ok { + t.Fatalf("expected handled copy") + } + if app.state.StatusText != statusCodeCopyError { + t.Fatalf("expected error status, got %s", app.state.StatusText) + } +} + +func TestIsWorkspaceCommandInput(t *testing.T) { + if !isWorkspaceCommandInput("& ls -la") { + t.Fatalf("expected workspace command prefix to be detected") + } + if isWorkspaceCommandInput("ls -la") { + t.Fatalf("expected non-workspace command to be false") + } +} + +func TestExtractWorkspaceCommand(t *testing.T) { + command, err := extractWorkspaceCommand("& git status") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if command != "git status" { + t.Fatalf("expected command to be extracted, got %q", command) + } + + if _, err := extractWorkspaceCommand("&"); err == nil { + t.Fatalf("expected error for empty command") + } + if _, err := extractWorkspaceCommand("git status"); err == nil { + t.Fatalf("expected error for missing prefix") + } +} + +func TestFormatWorkspaceCommandResult(t *testing.T) { + output := "clean\n" + got := formatWorkspaceCommandResult("git status", output, nil) + if !strings.Contains(got, "Command: & git status") { + t.Fatalf("expected success header, got %q", got) + } + if !strings.Contains(got, "clean") { + t.Fatalf("expected output to be included") + } + + errResult := formatWorkspaceCommandResult("git status", "", errors.New("boom")) + if !strings.Contains(errResult, "Command Failed: & git status") { + t.Fatalf("expected failure header, got %q", errResult) + } + if !strings.Contains(errResult, "boom") { + t.Fatalf("expected error message in result") + } +} + +func TestTokenRangeFirstToken(t *testing.T) { + start, end, token, ok := tokenRange(" /help now", tokenSelectorFirst) + if !ok { + t.Fatalf("expected token range to be found") + } + if token != "/help" { + t.Fatalf("expected first token to be /help, got %q", token) + } + if start < 0 || end <= start { + t.Fatalf("expected valid range, got %d-%d", start, end) + } +} + +func TestTokenRangeLastToken(t *testing.T) { + start, end, token, ok := tokenRange("one two three", tokenSelectorLast) + if !ok { + t.Fatalf("expected token range to be found") + } + if token != "three" { + t.Fatalf("expected last token to be three, got %q", token) + } + if start < 0 || end <= start { + t.Fatalf("expected valid range, got %d-%d", start, end) + } +} + +func TestCollectFileSuggestionMatches(t *testing.T) { + candidates := []string{"README.md", "docs/guide.md", "internal/app.go"} + matches := collectFileSuggestionMatches("read", candidates, 2) + if len(matches) == 0 { + t.Fatalf("expected matches for read") + } +} + +func TestShellArgsAndPowerShellUTF8(t *testing.T) { + args := shellArgs("bash", "echo hi") + if len(args) == 0 { + t.Fatalf("expected shell args to be returned") + } + utf8 := powershellUTF8Command("echo hi") + if utf8 == "" { + t.Fatalf("expected powershell utf8 command") + } +} + +func TestSanitizeAndDecodeWorkspaceOutput(t *testing.T) { + raw := []byte("hello\u0000world") + sanitized := sanitizeWorkspaceOutput(raw) + if sanitized == "" { + t.Fatalf("expected sanitized output") + } + decoded := decodeWorkspaceOutput(raw) + if decoded == "" { + t.Fatalf("expected decoded output") + } +} + +func TestViewSmallWindow(t *testing.T) { + app, _ := newTestApp(t) + app.width = 60 + app.height = 20 + + view := app.View() + if !strings.Contains(view, "Window too small") { + t.Fatalf("expected small window warning, got %q", view) + } +} + +func TestComputeLayoutStackedAndWide(t *testing.T) { + app, _ := newTestApp(t) + + app.width = 90 + app.height = 40 + layout := app.computeLayout() + if !layout.stacked { + t.Fatalf("expected stacked layout for narrow width") + } + if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { + t.Fatalf("expected positive layout widths, got %+v", layout) + } + + app.width = 140 + app.height = 40 + layout = app.computeLayout() + if layout.stacked { + t.Fatalf("expected non-stacked layout for wide width") + } + if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { + t.Fatalf("expected positive layout widths, got %+v", layout) + } +} + +func TestStatusBadgeVariants(t *testing.T) { + app, _ := newTestApp(t) + + errorBadge := app.statusBadge("Error occurred") + if strings.TrimSpace(errorBadge) == "" { + t.Fatalf("expected error badge to render") + } + + cancelBadge := app.statusBadge("Canceled") + if strings.TrimSpace(cancelBadge) == "" { + t.Fatalf("expected cancel badge to render") + } + + app.state.IsAgentRunning = true + runningBadge := app.statusBadge("Running") + if strings.TrimSpace(runningBadge) == "" { + t.Fatalf("expected running badge to render") + } + + app.state.IsAgentRunning = false + okBadge := app.statusBadge("Ready") + if strings.TrimSpace(okBadge) == "" { + t.Fatalf("expected success badge to render") + } +} + +func TestHelpHeightAndRenderHelp(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + + app.state.ShowHelp = false + helpHeight := app.helpHeight(80) + if helpHeight <= 0 { + t.Fatalf("expected help height to be positive") + } + rendered := app.renderHelp(80) + if strings.TrimSpace(rendered) == "" { + t.Fatalf("expected renderHelp output") + } + + app.state.ShowHelp = true + helpHeight = app.helpHeight(80) + if helpHeight <= 0 { + t.Fatalf("expected help height to be positive when help is shown") + } +} + +func TestNewWithBootstrapSuccess(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + runtime := newStubRuntime() + app, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{providers: providers, models: models}, + }) + if err != nil { + t.Fatalf("NewWithBootstrap() error = %v", err) + } + + cmd := app.Init() + if cmd == nil { + t.Fatalf("expected Init() to return command") + } +} + +func TestNewWithBootstrapMissingDependencies(t *testing.T) { + cfg := config.DefaultConfig() + + manager := config.NewManager(config.NewLoader(t.TempDir(), cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: manager, + Runtime: nil, + ProviderService: stubProviderService{}, + }); err == nil { + t.Fatalf("expected error for nil runtime") + } + + if _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: nil, + Runtime: newStubRuntime(), + ProviderService: stubProviderService{}, + }); err == nil { + t.Fatalf("expected error for nil config manager") + } +} + +func TestNewUsesBootstrap(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + app, err := New(cfg, manager, newStubRuntime(), stubProviderService{providers: providers, models: models}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if app.state.CurrentProvider == "" { + t.Fatalf("expected CurrentProvider to be set") + } +} + +func TestRuntimeEventUserMessageHandler(t *testing.T) { + app, _ := newTestApp(t) + event := agentruntime.RuntimeEvent{RunID: "run-1"} + handled := runtimeEventUserMessageHandler(&app, event) + if handled { + t.Fatalf("expected false") + } + if app.state.ActiveRunID != "run-1" { + t.Fatalf("expected run id to be set") + } + if app.state.StatusText != statusThinking { + t.Fatalf("expected thinking status") + } +} + +func TestRuntimeEventRunContextHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeRunContextPayload{ + Provider: "p1", + Model: "m1", + Workdir: "/tmp", + } + event := agentruntime.RuntimeEvent{RunID: "run-2", SessionID: "s1", Payload: payload} + handled := runtimeEventRunContextHandler(&app, event) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentProvider != "p1" || app.state.CurrentModel != "m1" { + t.Fatalf("expected provider/model to update") + } +} + +func TestRuntimeEventToolStatusHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeToolStatusPayload{ToolCallID: "tool-1", ToolName: "bash", Status: string(tuistate.ToolLifecyclePlanned)} + handled := runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentTool != "bash" { + t.Fatalf("expected current tool to be set") + } + payload.Status = string(tuistate.ToolLifecycleSucceeded) + _ = runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.state.CurrentTool != "" { + t.Fatalf("expected current tool to be cleared") + } +} + +func TestRuntimeEventUsageHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeUsagePayload{Run: tuiservices.RuntimeUsageSnapshot{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}} + handled := runtimeEventUsageHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected false") + } + if app.state.TokenUsage.RunTotalTokens != 3 { + t.Fatalf("expected token usage to update") + } +} + +func TestRuntimeEventToolCallThinkingHandler(t *testing.T) { + app, _ := newTestApp(t) + handled := runtimeEventToolCallThinkingHandler(&app, agentruntime.RuntimeEvent{Payload: "bash"}) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentTool != "bash" { + t.Fatalf("expected current tool to be set") + } +} + +func TestRuntimeEventToolStartHandler(t *testing.T) { + app, _ := newTestApp(t) + call := providertypes.ToolCall{Name: "bash"} + handled := runtimeEventToolStartHandler(&app, agentruntime.RuntimeEvent{Payload: call}) + if handled { + t.Fatalf("expected false") + } + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status") + } +} + +func TestRuntimeEventToolChunkHandler(t *testing.T) { + app, _ := newTestApp(t) + _ = runtimeEventToolChunkHandler(&app, agentruntime.RuntimeEvent{Payload: "chunk"}) + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status") + } +} + +func TestRuntimeEventAgentChunkHandler(t *testing.T) { + app, _ := newTestApp(t) + handled := runtimeEventAgentChunkHandler(&app, agentruntime.RuntimeEvent{Payload: "hello"}) + if !handled { + t.Fatalf("expected true") + } + if len(app.activeMessages) == 0 { + t.Fatalf("expected message appended") + } +} + +func TestRuntimeEventRunCanceledHandler(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveRunID = "run-3" + runtimeEventRunCanceledHandler(&app, agentruntime.RuntimeEvent{}) + if app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled status") + } + if app.state.ActiveRunID != "" { + t.Fatalf("expected run id cleared") + } +} + +func TestRuntimeEventErrorHandler(t *testing.T) { + app, _ := newTestApp(t) + runtimeEventErrorHandler(&app, agentruntime.RuntimeEvent{Payload: "boom"}) + if app.state.StatusText != "boom" { + t.Fatalf("expected status to be set to error") + } +} + +func TestRuntimeEventProviderRetryHandler(t *testing.T) { + app, _ := newTestApp(t) + runtimeEventProviderRetryHandler(&app, agentruntime.RuntimeEvent{Payload: "retry"}) + if app.state.StatusText != statusThinking { + t.Fatalf("expected thinking status") + } +} + +func TestRuntimeEventCompactDoneHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.CompactDonePayload{TriggerMode: "auto", SavedRatio: 0.5, BeforeChars: 10, AfterChars: 5, TranscriptPath: "path"} + handled := runtimeEventCompactDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected true") + } + if !strings.Contains(app.state.StatusText, "Compact(") { + t.Fatalf("expected compact status") + } +} + +func TestRuntimeEventCompactErrorHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.CompactErrorPayload{TriggerMode: "auto", Message: "fail"} + handled := runtimeEventCompactErrorHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected true") + } + if app.state.ExecutionError == "" { + t.Fatalf("expected error message") + } +} + +func TestAppendAssistantAndInlineMessage(t *testing.T) { + app, _ := newTestApp(t) + app.appendAssistantChunk("hi") + app.appendAssistantChunk(" there") + if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "there") { + t.Fatalf("expected assistant chunk to append") + } + app.appendInlineMessage(roleSystem, " note ") + if len(app.activeMessages) < 2 { + t.Fatalf("expected inline message appended") + } +} + +func TestShouldHandleTabAsInput(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelInput + app.state.ActivePicker = pickerNone + app.input.SetValue("/he") + if !app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be handled as input") + } + app.input.SetValue("") + if app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be ignored for empty input") + } +} + +func TestFocusNextPrev(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelSessions + app.focusNext() + if app.focus == panelSessions { + t.Fatalf("expected focus to move") + } + app.focusPrev() +} + +func TestHandleViewportKeys(t *testing.T) { + app, _ := newTestApp(t) + app.transcript.SetContent("line1\nline2\nline3") + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) +} diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go index c151bad6..48c93eda 100644 --- a/internal/tui/services/runtime_service.go +++ b/internal/tui/services/runtime_service.go @@ -18,6 +18,11 @@ type Compactor interface { Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) } +// PermissionResolver 定义执行权限审批回传所需最小能力。 +type PermissionResolver interface { + ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error +} + // ListenForRuntimeEventCmd 监听 runtime 事件通道,并将结果映射为 UI 消息。 func ListenForRuntimeEventCmd( sub <-chan agentruntime.RuntimeEvent, @@ -56,3 +61,15 @@ func RunCompactCmd( return doneMsg(err) } } + +// RunPermissionResolveCmd 执行权限审批回传,并将结果映射为 UI 消息。 +func RunPermissionResolveCmd( + runtime PermissionResolver, + input agentruntime.PermissionResolutionInput, + doneMsg func(error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + err := runtime.ResolvePermission(context.Background(), input) + return doneMsg(err) + } +} diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index cde184c9..2f80cb91 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -33,6 +33,16 @@ func (s *stubCompactor) Compact(ctx context.Context, input agentruntime.CompactI return agentruntime.CompactResult{}, s.err } +type stubPermissionResolver struct { + lastInput agentruntime.PermissionResolutionInput + err error +} + +func (s *stubPermissionResolver) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + s.lastInput = input + return s.err +} + type stubProvider struct { selection config.ProviderSelection models []config.ModelDescriptor @@ -101,6 +111,21 @@ func TestRunCompactCmd(t *testing.T) { } } +func TestRunPermissionResolveCmd(t *testing.T) { + resolver := &stubPermissionResolver{err: errors.New("permission failed")} + input := agentruntime.PermissionResolutionInput{ + RequestID: "perm-1", + Decision: agentruntime.PermissionResolutionAllowOnce, + } + msg := RunPermissionResolveCmd(resolver, input, func(err error) tea.Msg { return err })() + if resolver.lastInput.RequestID != "perm-1" || resolver.lastInput.Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("unexpected permission input: %+v", resolver.lastInput) + } + if err, ok := msg.(error); !ok || err == nil || err.Error() != "permission failed" { + t.Fatalf("expected forwarded permission error, got %T %#v", msg, msg) + } +} + func TestProviderCmds(t *testing.T) { svc := &stubProvider{ selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 9016281d..41184721 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -30,6 +30,13 @@ type CompactFinishedMsg struct { Err error } +// PermissionResolvedMsg 表示权限审批结果已回传。 +type PermissionResolvedMsg struct { + RequestID string + Decision string + Err error +} + // LocalCommandResultMsg 表示本地命令执行结果。 type LocalCommandResultMsg struct { Notice string From c31d7e950b1cef6f6f5103c44f608afc576ee0a0 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 16:07:13 +0800 Subject: [PATCH 32/54] =?UTF-8?q?fix=EF=BC=9A=E4=BB=A3=E7=A0=81=E5=9D=97?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/copy_code.go | 49 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/internal/tui/core/app/copy_code.go b/internal/tui/core/app/copy_code.go index 2bdb89ad..3f043d35 100644 --- a/internal/tui/core/app/copy_code.go +++ b/internal/tui/core/app/copy_code.go @@ -34,6 +34,15 @@ var ( copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) copyCodeANSIPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) clipboardWriteAll = tuiinfra.CopyText + + codeFeaturePatterns = []*regexp.Regexp{ + regexp.MustCompile(`^[[:space:]]*(func|if|for|while|switch|case|return|class|def|const|let|var|import|export|package|struct|enum|interface|public|private|static|void|int|string|bool|nil|null|true|false)\b`), + regexp.MustCompile(`=>|->|::`), + regexp.MustCompile(`[})];?\s*$`), + regexp.MustCompile(`^\s*(//|#|/\*|\*)`), + regexp.MustCompile(`:=|=>`), + regexp.MustCompile(`\([a-zA-Z_][a-zA-Z0-9_]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_]*)*\)\s*{?$`), + } ) func splitMarkdownSegments(content string) []markdownSegment { @@ -123,6 +132,7 @@ func splitIndentedCodeSegments(content string) []markdownSegment { textLines := make([]string, 0, len(lines)) codeLines := make([]string, 0, len(lines)) inCode := false + codeFeatureCount := 0 flushText := func() { if len(textLines) == 0 { @@ -150,27 +160,38 @@ func splitIndentedCodeSegments(content string) []markdownSegment { Code: code, }) codeLines = codeLines[:0] + codeFeatureCount = 0 } for _, line := range lines { indented := isIndentedCodeLine(line) if inCode { - if indented { + if indented || hasCodeFeatures(line) { codeLines = append(codeLines, trimCodeIndent(line)) + if hasCodeFeatures(line) { + codeFeatureCount++ + } continue } if strings.TrimSpace(line) == "" { codeLines = append(codeLines, "") continue } - flushCode() + if len(codeLines) > 0 { + flushCode() + } inCode = false } - if indented { - flushText() - inCode = true + if indented || hasCodeFeatures(line) { + if !inCode { + flushText() + inCode = true + } codeLines = append(codeLines, trimCodeIndent(line)) + if hasCodeFeatures(line) { + codeFeatureCount++ + } continue } @@ -213,7 +234,23 @@ func isFenceCloseLine(line string) bool { } func isIndentedCodeLine(line string) bool { - return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") + if strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") { + return true + } + return hasCodeFeatures(line) +} + +func hasCodeFeatures(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + return false + } + for _, pattern := range codeFeaturePatterns { + if pattern.MatchString(line) { + return true + } + } + return false } func trimCodeIndent(line string) string { From 6f9dd4c2f497eab8f7b0da8d5fbdae7f4bebcb44 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 22:35:49 +0800 Subject: [PATCH 33/54] =?UTF-8?q?fix=EF=BC=88tui=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E8=BE=93=E5=85=A5=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/command_menu.go | 53 ++++++++------- internal/tui/core/app/update.go | 23 +++++-- internal/tui/core/app/view.go | 14 +++- internal/tui/docs/LAYERING.md | 94 --------------------------- 4 files changed, 58 insertions(+), 126 deletions(-) delete mode 100644 internal/tui/docs/LAYERING.md diff --git a/internal/tui/core/app/command_menu.go b/internal/tui/core/app/command_menu.go index 9e593b79..25db49d1 100644 --- a/internal/tui/core/app/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -186,11 +186,36 @@ func (a *App) resizeCommandMenu() { } func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, tuistate.CommandMenuMeta) { + trimmed := strings.TrimSpace(input) + + // 1. 优先检查 Slash 命令 + if strings.HasPrefix(trimmed, slashPrefix) { + suggestions := a.matchingSlashCommands(trimmed) + if len(suggestions) > 0 { + start, end, _, _ := tokenRange(input, tokenSelectorFirst) + items := make([]commandMenuItem, 0, len(suggestions)) + for _, suggestion := range suggestions { + items = append(items, commandMenuItem{ + title: suggestion.Command.Usage, + description: suggestion.Command.Description, + filter: suggestion.Command.Usage + " " + suggestion.Command.Description, + highlight: suggestion.Match, + replacement: suggestion.Command.Usage, + useReplaceRange: true, + replaceStart: start, + replaceEnd: end, + }) + } + return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} + } + } + + // 2. 检查文件建议 (如果 Slash 命令不匹配) if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 { return suggestions, tuistate.CommandMenuMeta{Title: fileMenuTitle} } - trimmed := strings.TrimSpace(input) + // 3. 检查工作区命令 (如果 Slash 命令和文件建议都不匹配) if isWorkspaceCommandInput(trimmed) { replacement := trimmed item := commandMenuItem{ @@ -209,26 +234,8 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, return []commandMenuItem{item}, tuistate.CommandMenuMeta{Title: shellMenuTitle} } - suggestions := a.matchingSlashCommands(trimmed) - if len(suggestions) == 0 { - return nil, tuistate.CommandMenuMeta{} - } - - start, end, _, _ := tokenRange(input, tokenSelectorFirst) - items := make([]commandMenuItem, 0, len(suggestions)) - for _, suggestion := range suggestions { - items = append(items, commandMenuItem{ - title: suggestion.Command.Usage, - description: suggestion.Command.Description, - filter: suggestion.Command.Usage + " " + suggestion.Command.Description, - highlight: suggestion.Match, - replacement: suggestion.Command.Usage, - useReplaceRange: true, - replaceStart: start, - replaceEnd: end, - }) - } - return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} + // 如果没有任何匹配的建议 + return nil, tuistate.CommandMenuMeta{} } func (a App) fileMenuSuggestions(input string) []commandMenuItem { @@ -309,7 +316,7 @@ func (a *App) applySelectedCommandSuggestion() bool { func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { if !a.commandMenuHasSuggestions() { - return nil, false + return nil, false // 让按键继续传递 } switch msg.Type { @@ -318,7 +325,7 @@ func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { a.commandMenu, cmd = a.commandMenu.Update(msg) return cmd, true default: - return nil, false + return nil, false // 非导航键,让它们继续传递 } } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index cc815975..11eac526 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -351,19 +351,26 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - a.input.Reset() - a.state.InputText = "" - a.applyComponentLayout(true) - a.refreshCommandMenu() - a.resetPasteHeuristics() - + // 先检查是否是立即执行的命令,如果处理了,就直接返回 if handled, cmd := a.handleImmediateSlashCommand(input); handled { + a.input.Reset() // 只有在命令被处理后才清空输入 + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() if cmd != nil { cmds = append(cmds, cmd) } return a, tea.Batch(cmds...) } + // 如果不是立即执行的命令,再执行常规的输入重置 + a.input.Reset() + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() + switch strings.ToLower(input) { case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { @@ -1427,7 +1434,9 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) a.input.SetHeight(a.composerHeight()) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) - a.transcript.Height = max(6, lay.rightHeight-activityHeight-menuHeight-promptHeight) + availableHeight := lay.rightHeight - activityHeight - menuHeight - promptHeight + minTranscriptHeight := max(6, lay.rightHeight/2) + a.transcript.Height = max(minTranscriptHeight, availableHeight) if activityHeight > 0 { panelStyle := a.styles.panelFocused diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 10a47c10..213642cc 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -140,7 +140,12 @@ func (a App) renderWaterfall(width int, height int) string { ) } - transcript := a.styles.streamContent.Width(width).Height(a.transcript.Height).Render(a.transcript.View()) + activityHeight := a.activityPreviewHeight() + menuHeight := a.commandMenuHeight(width) + promptHeight := lipgloss.Height(a.renderPrompt(width)) + transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) + + transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) parts := []string{transcript} if activity := a.renderActivityPreview(width); activity != "" { @@ -151,7 +156,12 @@ func (a App) renderWaterfall(width int, height int) string { } parts = append(parts, a.renderPrompt(width)) - return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, parts...)) + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + contentHeight := lipgloss.Height(content) + if contentHeight < height { + content = content + "\n" + lipgloss.NewStyle().Height(height-contentHeight).Render("") + } + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } func (a App) renderPicker(width int, height int) string { diff --git a/internal/tui/docs/LAYERING.md b/internal/tui/docs/LAYERING.md deleted file mode 100644 index 4ff7b040..00000000 --- a/internal/tui/docs/LAYERING.md +++ /dev/null @@ -1,94 +0,0 @@ -# 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 本地推导。 From 0e4af344e9456a3d78383c35184bb020eac0ee93 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 22:50:02 +0800 Subject: [PATCH 34/54] =?UTF-8?q?refactor:=E7=BE=8E=E5=8C=96/help=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/app.go | 6 ++ internal/tui/core/app/commands.go | 46 ++++++++++++ internal/tui/core/app/commands_test.go | 22 ++++++ internal/tui/core/app/update.go | 73 +++++++++++++++++++ internal/tui/core/app/update_test.go | 74 ++++++++++++++++++++ internal/tui/core/app/view.go | 5 ++ internal/tui/core/utils/view_helpers.go | 2 + internal/tui/core/utils/view_helpers_test.go | 1 + internal/tui/state/state_test.go | 11 ++- internal/tui/state/ui_state.go | 1 + 10 files changed, 239 insertions(+), 2 deletions(-) diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index ca856676..5d70c1f9 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -37,6 +37,7 @@ const ( pickerProvider pickerMode = tuistate.PickerProvider pickerModel pickerMode = tuistate.PickerModel pickerFile pickerMode = tuistate.PickerFile + pickerHelp pickerMode = tuistate.PickerHelp ) type RuntimeMsg = tuistate.RuntimeMsg @@ -74,6 +75,7 @@ type appComponents struct { commandMenuMeta tuistate.CommandMenuMeta providerPicker list.Model modelPicker list.Model + helpPicker list.Model fileBrowser filepicker.Model progress progress.Model transcript viewport.Model @@ -226,6 +228,7 @@ func newApp(container tuibootstrap.Container) (App, error) { commandMenu: commandMenu, providerPicker: newSelectionPickerItems(nil), modelPicker: newSelectionPickerItems(nil), + helpPicker: newHelpPickerItems(nil), fileBrowser: fileBrowser, progress: progressBar, transcript: viewport.New(0, 0), @@ -260,6 +263,9 @@ func newApp(container tuibootstrap.Container) (App, error) { if err := app.refreshModelPicker(); err != nil { return App{}, err } + if err := app.refreshHelpPicker(); err != nil { + return App{}, err + } app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.modelRefreshID = cfg.SelectedProvider diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 824d0f03..a70d0057 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -39,6 +39,8 @@ const ( providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + helpPickerTitle = "Slash Commands" + helpPickerSubtitle = "Up/Down choose, Enter run, Esc cancel" filePickerTitle = "Browse Files" filePickerSubtitle = "Navigate folders, Enter choose file, Esc cancel" @@ -69,6 +71,7 @@ const ( statusCompacting = "Compacting context" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusChooseHelp = "Choose a slash command" statusBrowseFile = "Browse workspace files" statusAwaitingPermission = "Awaiting permission (y=once, a=session, n=deny)" statusPermissionApproved = "Permission approved" @@ -123,6 +126,13 @@ func newSelectionPicker(items []list.Item) list.Model { return picker } +// newHelpPicker 创建 /help 专用选择器,禁用分页以保持单页展示体验。 +func newHelpPicker(items []list.Item) list.Model { + picker := newSelectionPicker(items) + picker.SetShowPagination(false) + return picker +} + func newCommandMenuModel(uiStyles styles) list.Model { delegate := commandMenuDelegate{styles: uiStyles} menu := list.New([]list.Item{}, delegate, 0, 0) @@ -145,6 +155,15 @@ func newSelectionPickerItems(items []selectionItem) list.Model { return newSelectionPicker(listItems) } +// newHelpPickerItems 将 slash 命令映射为 /help 弹层列表项。 +func newHelpPickerItems(items []selectionItem) list.Model { + listItems := make([]list.Item, 0, len(items)) + for _, item := range items { + listItems = append(listItems, item) + } + return newHelpPicker(listItems) +} + func mapProviderItems(items []config.ProviderCatalogItem) []selectionItem { mapped := make([]selectionItem, 0, len(items)) for _, item := range items { @@ -175,6 +194,13 @@ func replacePickerItems(current *list.Model, items []selectionItem) { *current = next } +// replaceHelpPickerItems 替换 /help 弹层条目并保持尺寸。 +func replaceHelpPickerItems(current *list.Model, items []selectionItem) { + next := newHelpPickerItems(items) + next.SetSize(current.Width(), current.Height()) + *current = next +} + func (a *App) refreshProviderPicker() error { items, err := a.providerSvc.ListProviders(context.Background()) if err != nil { @@ -197,6 +223,21 @@ func (a *App) refreshModelPicker() error { return nil } +// refreshHelpPicker 刷新 /help 弹层中的 slash 命令列表。 +func (a *App) refreshHelpPicker() error { + items := make([]selectionItem, 0, len(builtinSlashCommands)) + for _, command := range builtinSlashCommands { + items = append(items, selectionItem{ + id: command.Usage, + name: command.Usage, + description: command.Description, + }) + } + replaceHelpPickerItems(&a.helpPicker, items) + selectPickerItemByID(&a.helpPicker, "") + return nil +} + func (a *App) openProviderPicker() { a.openPicker(pickerProvider, statusChooseProvider, &a.providerPicker, a.state.CurrentProvider) } @@ -205,6 +246,11 @@ func (a *App) openModelPicker() { a.openPicker(pickerModel, statusChooseModel, &a.modelPicker, a.state.CurrentModel) } +// openHelpPicker 打开 slash 命令帮助弹层并进入可选择状态。 +func (a *App) openHelpPicker() { + a.openPicker(pickerHelp, statusChooseHelp, &a.helpPicker, "") +} + func (a *App) openPicker(mode pickerMode, statusText string, picker *list.Model, selectedID string) { a.state.ActivePicker = mode a.state.StatusText = statusText diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index ead1adb4..051f38e7 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -74,6 +74,7 @@ func TestStatusConstants(t *testing.T) { {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -296,3 +297,24 @@ func TestExecuteStatusCommandFormatting(t *testing.T) { t.Fatalf("expected Status header, got %q", output) } } + +func TestRefreshHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + if err := app.refreshHelpPicker(); err != nil { + t.Fatalf("refreshHelpPicker() error = %v", err) + } + if len(app.helpPicker.Items()) != len(builtinSlashCommands) { + t.Fatalf("expected %d help items, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) + } +} + +func TestOpenHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.openHelpPicker() + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected help picker to open") + } + if app.state.StatusText != statusChooseHelp { + t.Fatalf("expected help picker status, got %q", app.state.StatusText) + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 11eac526..38979712 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -372,6 +372,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.resetPasteHeuristics() switch strings.ToLower(input) { + case slashCommandHelp: + if err := a.refreshHelpPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.openHelpPicker() + return a, tea.Batch(cmds...) case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() @@ -572,6 +581,13 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerHelp: + item, ok := a.helpPicker.SelectedItem().(selectionItem) + a.closePicker() + if !ok { + return a, nil + } + return a, a.runSlashCommandSelection(item.id) } } @@ -581,6 +597,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerHelp: + a.helpPicker, cmd = a.helpPicker.Update(msg) case pickerFile: a.fileBrowser, cmd = a.fileBrowser.Update(msg) if didSelect, path := a.fileBrowser.DidSelectFile(msg); didSelect { @@ -1455,6 +1473,12 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + helpPickerMaxHeight := max(8, lay.rightHeight-6) + helpPickerDesiredHeight := (len(a.helpPicker.Items()) * 3) + 1 + a.helpPicker.SetSize( + max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), + max(6, tuiutils.Clamp(helpPickerDesiredHeight, 6, helpPickerMaxHeight)), + ) a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.rightHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() @@ -1602,6 +1626,55 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } +// runSlashCommandSelection 根据 /help 弹层选中的命令执行对应 slash 行为。 +func (a *App) runSlashCommandSelection(command string) tea.Cmd { + command = strings.ToLower(strings.TrimSpace(command)) + if command == "" { + return nil + } + + if handled, cmd := a.handleImmediateSlashCommand(command); handled { + return cmd + } + + switch command { + case slashCommandHelp: + if err := a.refreshHelpPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) + return nil + } + a.openHelpPicker() + return nil + case slashCommandProvider: + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) + return nil + } + a.openProviderPicker() + return nil + case slashCommandModelPick: + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh models", err.Error(), true) + return nil + } + a.openModelPicker() + return a.requestModelCatalogRefresh(a.state.CurrentProvider) + default: + a.state.StatusText = statusApplyingCommand + a.state.ExecutionError = "" + if isWorkspaceSlashCommand(command) { + return runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, command) + } + return runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), command) + } +} + func (a App) currentStatusSnapshot() tuistatus.Snapshot { return tuistatus.BuildFromUIState( a.state, diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 4eb81a47..37b4b469 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1064,3 +1064,77 @@ func TestHandleViewportKeys(t *testing.T) { app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) } + +func TestUpdateEnterHelpOpensHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/help") + app.state.InputText = "/help" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected non-nil model") + } + app = model.(App) + if cmd != nil { + t.Fatalf("expected no async cmd when opening help picker") + } + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected help picker to be active") + } + if app.state.StatusText != statusChooseHelp { + t.Fatalf("expected status %q, got %q", statusChooseHelp, app.state.StatusText) + } + if len(app.helpPicker.Items()) != len(builtinSlashCommands) { + t.Fatalf("expected %d help options, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) + } +} + +func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { + app, _ := newTestApp(t) + if err := app.refreshHelpPicker(); err != nil { + t.Fatalf("refreshHelpPicker() error = %v", err) + } + app.openHelpPicker() + selectPickerItemByID(&app.helpPicker, slashCommandModelPick) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if cmd != nil { + _ = cmd() + } + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected model picker to open from help selection") + } +} + +func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { + app, _ := newTestApp(t) + if err := app.refreshHelpPicker(); err != nil { + t.Fatalf("refreshHelpPicker() error = %v", err) + } + app.openHelpPicker() + selectPickerItemByID(&app.helpPicker, slashCommandStatus) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected help picker to close after selecting /status") + } + if cmd == nil { + t.Fatalf("expected local slash command cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !strings.Contains(result.Notice, "Status:") { + t.Fatalf("expected status output in slash result, got %q", result.Notice) + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 213642cc..0836705d 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -179,6 +179,11 @@ func (a App) renderPicker(width int, height int) string { subtitle = filePickerSubtitle body = a.fileBrowser.View() } + if a.state.ActivePicker == pickerHelp { + title = helpPickerTitle + subtitle = helpPickerSubtitle + body = a.helpPicker.View() + } content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(title), diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 4c0d029a..58c0373c 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -15,6 +15,8 @@ func PickerLabelFromMode(mode tuistate.PickerMode) string { return "model" case tuistate.PickerFile: return "file" + case tuistate.PickerHelp: + return "help" default: return "none" } diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index 9a37e084..d700f1fc 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -14,6 +14,7 @@ func TestPickerLabelFromMode(t *testing.T) { {tuistate.PickerProvider, "provider"}, {tuistate.PickerModel, "model"}, {tuistate.PickerFile, "file"}, + {tuistate.PickerHelp, "help"}, {tuistate.PickerMode(999), "none"}, } diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go index 73e34cda..599ddfe1 100644 --- a/internal/tui/state/state_test.go +++ b/internal/tui/state/state_test.go @@ -6,8 +6,15 @@ 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) + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 || PickerHelp != 4 { + t.Fatalf( + "unexpected picker constants: %d %d %d %d %d", + PickerNone, + PickerProvider, + PickerModel, + PickerFile, + PickerHelp, + ) } } diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index 9fa071a3..706b99dc 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -20,6 +20,7 @@ const ( PickerProvider PickerModel PickerFile + PickerHelp ) // UIState 保存顶层界面状态快照,仅作为数据容器使用。 From 55c08a4bb89af8db81c2da7b4a3dd41cc84734e7 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 23:09:57 +0800 Subject: [PATCH 35/54] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=20/help=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E4=B8=8E=E8=A6=86=E7=9B=96=E7=8E=87=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=88UTF-8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/app.go | 4 +-- internal/tui/core/app/commands.go | 3 +- internal/tui/core/app/commands_test.go | 4 +-- internal/tui/core/app/update.go | 14 ++------- internal/tui/core/app/update_test.go | 39 ++++++++++++++++++++++---- internal/tui/core/app/view.go | 4 --- internal/tui/core/app/view_test.go | 33 ++++++++++++++++++++++ 7 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 internal/tui/core/app/view_test.go diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 5d70c1f9..3299a2de 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -263,9 +263,7 @@ func newApp(container tuibootstrap.Container) (App, error) { if err := app.refreshModelPicker(); err != nil { return App{}, err } - if err := app.refreshHelpPicker(); err != nil { - return App{}, err - } + app.refreshHelpPicker() app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.modelRefreshID = cfg.SelectedProvider diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index a70d0057..832d5aeb 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -224,7 +224,7 @@ func (a *App) refreshModelPicker() error { } // refreshHelpPicker 刷新 /help 弹层中的 slash 命令列表。 -func (a *App) refreshHelpPicker() error { +func (a *App) refreshHelpPicker() { items := make([]selectionItem, 0, len(builtinSlashCommands)) for _, command := range builtinSlashCommands { items = append(items, selectionItem{ @@ -235,7 +235,6 @@ func (a *App) refreshHelpPicker() error { } replaceHelpPickerItems(&a.helpPicker, items) selectPickerItemByID(&a.helpPicker, "") - return nil } func (a *App) openProviderPicker() { diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index 051f38e7..db1b0f6a 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -300,9 +300,7 @@ func TestExecuteStatusCommandFormatting(t *testing.T) { func TestRefreshHelpPicker(t *testing.T) { app, _ := newTestApp(t) - if err := app.refreshHelpPicker(); err != nil { - t.Fatalf("refreshHelpPicker() error = %v", err) - } + app.refreshHelpPicker() if len(app.helpPicker.Items()) != len(builtinSlashCommands) { t.Fatalf("expected %d help items, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 38979712..43102a71 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -373,12 +373,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te switch strings.ToLower(input) { case slashCommandHelp: - if err := a.refreshHelpPicker(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) - return a, tea.Batch(cmds...) - } + a.refreshHelpPicker() a.openHelpPicker() return a, tea.Batch(cmds...) case slashCommandProvider: @@ -1639,12 +1634,7 @@ func (a *App) runSlashCommandSelection(command string) tea.Cmd { switch command { case slashCommandHelp: - if err := a.refreshHelpPicker(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) - return nil - } + a.refreshHelpPicker() a.openHelpPicker() return nil case slashCommandProvider: diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 37b4b469..3ef3a89f 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -496,6 +496,20 @@ func TestSplitMarkdownSegmentsIndented(t *testing.T) { } } +func TestSplitIndentedCodeSegmentsDetectsCodeFeaturesInCodeMode(t *testing.T) { + content := "func main() {\nreturn 1\n}\nplain text" + segments := splitIndentedCodeSegments(content) + if len(segments) < 2 { + t.Fatalf("expected code and text segments, got %d", len(segments)) + } + if segments[0].Kind != markdownSegmentCode { + t.Fatalf("expected first segment to be code") + } + if !strings.Contains(segments[0].Code, "return 1") { + t.Fatalf("expected code segment to include return statement, got %q", segments[0].Code) + } +} + func TestExtractFencedCodeBlocks(t *testing.T) { content := "text\n```go\nfmt.Println(\"ok\")\n```\nend" blocks := extractFencedCodeBlocks(content) @@ -1091,9 +1105,7 @@ func TestUpdateEnterHelpOpensHelpPicker(t *testing.T) { func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { app, _ := newTestApp(t) - if err := app.refreshHelpPicker(); err != nil { - t.Fatalf("refreshHelpPicker() error = %v", err) - } + app.refreshHelpPicker() app.openHelpPicker() selectPickerItemByID(&app.helpPicker, slashCommandModelPick) @@ -1112,9 +1124,7 @@ func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { app, _ := newTestApp(t) - if err := app.refreshHelpPicker(); err != nil { - t.Fatalf("refreshHelpPicker() error = %v", err) - } + app.refreshHelpPicker() app.openHelpPicker() selectPickerItemByID(&app.helpPicker, slashCommandStatus) @@ -1138,3 +1148,20 @@ func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { t.Fatalf("expected status output in slash result, got %q", result.Notice) } } + +func TestRunSlashCommandSelectionModelReturnsRefreshCmd(t *testing.T) { + app, _ := newTestApp(t) + app.modelRefreshID = "" + + cmd := app.runSlashCommandSelection(slashCommandModelPick) + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected model picker to open") + } + if cmd == nil { + t.Fatalf("expected model refresh cmd") + } + msg := cmd() + if _, ok := msg.(modelCatalogRefreshMsg); !ok { + t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 0836705d..b41a0a78 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -157,10 +157,6 @@ func (a App) renderWaterfall(width int, height int) string { parts = append(parts, a.renderPrompt(width)) content := lipgloss.JoinVertical(lipgloss.Left, parts...) - contentHeight := lipgloss.Height(content) - if contentHeight < height { - content = content + "\n" + lipgloss.NewStyle().Height(height-contentHeight).Render("") - } return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go new file mode 100644 index 00000000..42345994 --- /dev/null +++ b/internal/tui/core/app/view_test.go @@ -0,0 +1,33 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderPickerHelpMode(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + app.state.ActivePicker = pickerHelp + + view := app.renderPicker(48, 14) + if !strings.Contains(view, helpPickerTitle) { + t.Fatalf("expected help picker title in view") + } + if !strings.Contains(view, helpPickerSubtitle) { + t.Fatalf("expected help picker subtitle in view") + } +} + +func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerNone + app.state.InputText = "test" + app.input.SetValue("test") + app.transcript.SetContent("line1\nline2") + + view := app.renderWaterfall(80, 24) + if strings.TrimSpace(view) == "" { + t.Fatalf("expected non-empty waterfall view") + } +} From 8ca48f0559d9e4558fd12d4da2eeb8e15504c5fd Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 23:27:25 +0800 Subject: [PATCH 36/54] =?UTF-8?q?test(tui):=E8=A1=A5=E5=85=85=20update=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=A6=86=E7=9B=96=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20codecov=20patch=20=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/update_test.go | 142 +++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 3ef3a89f..35434320 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1165,3 +1165,145 @@ func TestRunSlashCommandSelectionModelReturnsRefreshCmd(t *testing.T) { t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) } } + +func TestRunSlashCommandSelectionProviderRefreshError(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("provider refresh failed")} + + cmd := app.runSlashCommandSelection(slashCommandProvider) + if cmd != nil { + t.Fatalf("expected nil cmd when provider refresh fails") + } + if !strings.Contains(app.state.StatusText, "provider refresh failed") { + t.Fatalf("expected provider refresh error status, got %q", app.state.StatusText) + } +} + +func TestRunSlashCommandSelectionModelRefreshError(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("model refresh failed")} + + cmd := app.runSlashCommandSelection(slashCommandModelPick) + if cmd != nil { + t.Fatalf("expected nil cmd when model refresh fails") + } + if !strings.Contains(app.state.StatusText, "model refresh failed") { + t.Fatalf("expected model refresh error status, got %q", app.state.StatusText) + } +} + +func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveSessionID = "" + app.state.CurrentWorkdir = t.TempDir() + + workspaceCmd := app.runSlashCommandSelection("/cwd") + if workspaceCmd == nil { + t.Fatalf("expected workspace slash cmd") + } + workspaceMsg := workspaceCmd() + workspaceResult, ok := workspaceMsg.(sessionWorkdirResultMsg) + if !ok { + t.Fatalf("expected sessionWorkdirResultMsg, got %T", workspaceMsg) + } + if workspaceResult.Err != nil { + t.Fatalf("expected no workspace error, got %v", workspaceResult.Err) + } + + localCmd := app.runSlashCommandSelection(slashCommandStatus) + if localCmd == nil { + t.Fatalf("expected local slash cmd") + } + localMsg := localCmd() + localResult, ok := localMsg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", localMsg) + } + if !strings.Contains(localResult.Notice, "Status:") { + t.Fatalf("expected status output in local command result") + } +} + +func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + + handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") + if !handled || cmd != nil { + t.Fatalf("expected compact with args to be handled without cmd") + } + if !strings.Contains(app.state.StatusText, "usage:") { + t.Fatalf("expected usage error for compact with args") + } + + app.state.ExecutionError = "" + app.state.IsCompacting = true + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd != nil { + t.Fatalf("expected compact busy branch to return handled with nil cmd") + } + if !strings.Contains(app.state.StatusText, "already running") { + t.Fatalf("expected busy message") + } + + app.state.IsCompacting = false + app.state.IsAgentRunning = false + app.state.StatusText = "" + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd == nil { + t.Fatalf("expected compact success branch to return cmd") + } + msg := cmd() + if _, ok := msg.(compactFinishedMsg); !ok { + t.Fatalf("expected compactFinishedMsg, got %T", msg) + } + if len(runtime.resolveCalls) != 0 { + t.Fatalf("compact should not resolve permissions") + } +} + +func TestHandleImmediateSlashCommandDefault(t *testing.T) { + app, _ := newTestApp(t) + handled, cmd := app.handleImmediateSlashCommand("/unknown") + if handled || cmd != nil { + t.Fatalf("expected unknown slash command to be ignored") + } +} + +func TestFormatPermissionPromptToolOnly(t *testing.T) { + got := formatPermissionPrompt(agentruntime.PermissionRequestPayload{ToolName: "bash"}) + if got != "bash" { + t.Fatalf("expected tool-only prompt, got %q", got) + } +} + +func TestStartDraftSessionResetsRunState(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveSessionID = "session-1" + app.state.ActiveSessionTitle = "Session 1" + app.state.ActiveRunID = "run-1" + app.state.CurrentTool = "bash" + app.state.ToolStates = []tuistate.ToolState{{ToolCallID: "tool-1", ToolName: "bash"}} + app.state.RunContext = tuistate.ContextWindowState{Provider: "openai"} + app.state.TokenUsage = tuistate.TokenUsageState{RunTotalTokens: 123} + app.activities = []tuistate.ActivityEntry{{Title: "activity"}} + app.state.CurrentWorkdir = t.TempDir() + + app.startDraftSession() + + if app.state.ActiveRunID != "" { + t.Fatalf("expected run id to be reset") + } + if app.state.CurrentTool != "" { + t.Fatalf("expected current tool to be reset") + } + if len(app.state.ToolStates) != 0 { + t.Fatalf("expected tool states to be reset") + } + if app.state.ActiveSessionID != "" || app.state.ActiveSessionTitle != draftSessionTitle { + t.Fatalf("expected draft session state") + } + if len(app.activities) != 0 { + t.Fatalf("expected activities to be cleared") + } +} From 6b5a25347b41e41f0853936ab9ae89c40dcfc166 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:25:21 +0800 Subject: [PATCH 37/54] =?UTF-8?q?feat(tui/runtime):=20=E6=89=93=E9=80=9A?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=AE=A1=E6=89=B9=E9=80=89=E6=8B=A9=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E9=97=AD=E7=8E=AF=E5=B9=B6=E5=A2=9E=E5=BC=BAask?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/prompt.go | 2 + internal/context/prompt_test.go | 27 ++- internal/tui/core/app/app.go | 34 ++-- internal/tui/core/app/permission_prompt.go | 115 +++++++++++ .../tui/core/app/permission_prompt_test.go | 102 ++++++++++ internal/tui/core/app/update.go | 179 ++++++++++++++++++ internal/tui/core/app/view.go | 4 +- internal/tui/services/runtime_service.go | 17 ++ internal/tui/services/services_test.go | 45 +++++ internal/tui/state/messages.go | 7 + 10 files changed, 514 insertions(+), 18 deletions(-) create mode 100644 internal/tui/core/app/permission_prompt.go create mode 100644 internal/tui/core/app/permission_prompt_test.go diff --git a/internal/context/prompt.go b/internal/context/prompt.go index d480e5bf..dc32245b 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -16,6 +16,8 @@ var defaultPromptSections = []promptSection{ { title: "Tool Usage", content: "- Use tools when they reduce uncertainty or are required to complete the task safely.\n" + + "- For risky operations, call the relevant tool first and let the runtime permission layer decide ask/allow/deny.\n" + + "- Do not self-reject a user-requested operation before attempting the proper tool call and permission flow.\n" + "- Stay within the current workspace unless the user clearly asks for something else.\n" + "- Do not claim work is done unless the needed files, commands, or verification actually succeeded.", }, diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go index df9c3fd7..1eb0b729 100644 --- a/internal/context/prompt_test.go +++ b/internal/context/prompt_test.go @@ -1,6 +1,9 @@ package context -import "testing" +import ( + "strings" + "testing" +) func TestDefaultSystemPromptSectionsReturnsCachedSections(t *testing.T) { t.Parallel() @@ -90,3 +93,25 @@ func TestComposeSystemPromptSkipsEmptySections(t *testing.T) { t.Fatalf("composeSystemPrompt() = %q, want %q", got, want) } } + +func TestDefaultToolUsagePromptEncouragesAskFlow(t *testing.T) { + t.Parallel() + + sections := defaultSystemPromptSections() + var toolUsage string + for _, section := range sections { + if section.title == "Tool Usage" { + toolUsage = section.content + break + } + } + if toolUsage == "" { + t.Fatalf("expected Tool Usage section to exist") + } + if !strings.Contains(toolUsage, "permission layer") { + t.Fatalf("expected Tool Usage to mention permission layer, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "Do not self-reject") { + t.Fatalf("expected Tool Usage to discourage self-reject, got %q", toolUsage) + } +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index f290ce30..4c8e778c 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -47,6 +47,7 @@ type compactFinishedMsg = tuistate.CompactFinishedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg +type permissionResolutionFinishedMsg = tuistate.PermissionResolutionFinishedMsg type ProviderController interface { ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) @@ -83,22 +84,23 @@ type appComponents struct { // appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 type appRuntimeState struct { - codeCopyBlocks map[int]string - pendingCopyID int - nowFn func() time.Time - lastInputEditAt time.Time - lastPasteLikeAt time.Time - inputBurstStart time.Time - inputBurstCount int - pasteMode bool - activeMessages []providertypes.Message - activities []tuistate.ActivityEntry - fileCandidates []string - modelRefreshID string - focus panel - runProgressValue float64 - runProgressKnown bool - runProgressLabel string + codeCopyBlocks map[int]string + pendingCopyID int + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool + activeMessages []providertypes.Message + activities []tuistate.ActivityEntry + fileCandidates []string + modelRefreshID string + focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string + pendingPermission *permissionPromptState } type App struct { diff --git a/internal/tui/core/app/permission_prompt.go b/internal/tui/core/app/permission_prompt.go new file mode 100644 index 00000000..1aea5268 --- /dev/null +++ b/internal/tui/core/app/permission_prompt.go @@ -0,0 +1,115 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + + agentruntime "neo-code/internal/runtime" +) + +// permissionPromptOption 表示权限审批面板中的一个可选项。 +type permissionPromptOption struct { + Label string + Hint string + Decision agentruntime.PermissionResolutionDecision +} + +var permissionPromptOptions = []permissionPromptOption{ + { + Label: "Allow once", + Hint: "仅本次放行", + Decision: agentruntime.PermissionResolutionAllowOnce, + }, + { + Label: "Allow session", + Hint: "本会话同类请求持续放行", + Decision: agentruntime.PermissionResolutionAllowSession, + }, + { + Label: "Reject", + Hint: "拒绝本次请求(可记忆拒绝)", + Decision: agentruntime.PermissionResolutionReject, + }, +} + +// permissionPromptState 保存当前待审批请求与选项状态。 +type permissionPromptState struct { + Request agentruntime.PermissionRequestPayload + Selected int + Submitting bool +} + +// normalizePermissionPromptSelection 保证选项下标始终落在有效范围。 +func normalizePermissionPromptSelection(selected int) int { + if len(permissionPromptOptions) == 0 { + return 0 + } + if selected < 0 { + return len(permissionPromptOptions) - 1 + } + if selected >= len(permissionPromptOptions) { + return 0 + } + return selected +} + +// permissionPromptOptionAt 返回指定下标对应的审批选项。 +func permissionPromptOptionAt(selected int) permissionPromptOption { + index := normalizePermissionPromptSelection(selected) + return permissionPromptOptions[index] +} + +// parsePermissionShortcut 将快捷输入映射为审批决策。 +func parsePermissionShortcut(input string) (agentruntime.PermissionResolutionDecision, bool) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "y", "yes", "once", "allow_once": + return agentruntime.PermissionResolutionAllowOnce, true + case "a", "always", "allow_session": + return agentruntime.PermissionResolutionAllowSession, true + case "n", "no", "reject", "deny": + return agentruntime.PermissionResolutionReject, true + default: + return "", false + } +} + +// formatPermissionPromptLines 构造权限审批面板展示文本。 +func formatPermissionPromptLines(state permissionPromptState) []string { + lines := []string{ + fmt.Sprintf("权限审批:%s (%s)", fallbackText(state.Request.ToolName, "unknown_tool"), fallbackText(state.Request.Operation, "unknown")), + fmt.Sprintf("目标:%s", fallbackText(state.Request.Target, "(empty)")), + "使用 ↑/↓ 选择,Enter 确认(快捷键:y=once, a=session, n=reject)", + } + + for index, item := range permissionPromptOptions { + prefix := " " + if normalizePermissionPromptSelection(state.Selected) == index { + prefix = "> " + } + lines = append(lines, fmt.Sprintf("%s%s - %s", prefix, item.Label, item.Hint)) + } + + if state.Submitting { + lines = append(lines, "正在提交审批结果...") + } + return lines +} + +// fallbackText 返回去空格后的值;为空时返回默认文案。 +func fallbackText(value string, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fallback + } + return trimmed +} + +// renderPermissionPrompt 渲染审批输入框内容,替代普通输入框文本编辑状态。 +func (a App) renderPermissionPrompt() string { + if a.pendingPermission == nil { + return a.input.View() + } + return lipgloss.JoinVertical(lipgloss.Left, formatPermissionPromptLines(*a.pendingPermission)...) +} diff --git a/internal/tui/core/app/permission_prompt_test.go b/internal/tui/core/app/permission_prompt_test.go new file mode 100644 index 00000000..7166f2d0 --- /dev/null +++ b/internal/tui/core/app/permission_prompt_test.go @@ -0,0 +1,102 @@ +package tui + +import ( + "strings" + "testing" + + agentruntime "neo-code/internal/runtime" +) + +func TestNormalizePermissionPromptSelectionWrap(t *testing.T) { + if got := normalizePermissionPromptSelection(-1); got != len(permissionPromptOptions)-1 { + t.Fatalf("expected -1 to wrap to last index, got %d", got) + } + if got := normalizePermissionPromptSelection(len(permissionPromptOptions)); got != 0 { + t.Fatalf("expected overflow index to wrap to 0, got %d", got) + } +} + +func TestPermissionPromptOptionAt(t *testing.T) { + option := permissionPromptOptionAt(-1) + if option.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected wrapped option to be reject, got %q", option.Decision) + } +} + +func TestParsePermissionShortcut(t *testing.T) { + tests := map[string]agentruntime.PermissionResolutionDecision{ + "y": agentruntime.PermissionResolutionAllowOnce, + "once": agentruntime.PermissionResolutionAllowOnce, + "a": agentruntime.PermissionResolutionAllowSession, + "always": agentruntime.PermissionResolutionAllowSession, + "n": agentruntime.PermissionResolutionReject, + "deny": agentruntime.PermissionResolutionReject, + } + for input, want := range tests { + got, ok := parsePermissionShortcut(input) + if !ok || got != want { + t.Fatalf("parsePermissionShortcut(%q) = (%q,%v), want (%q,true)", input, got, ok, want) + } + } + if _, ok := parsePermissionShortcut("unknown"); ok { + t.Fatalf("expected unknown shortcut to fail") + } +} + +func TestFormatPermissionPromptLines(t *testing.T) { + lines := formatPermissionPromptLines(permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + ToolName: "bash", + Operation: "exec", + Target: "git status", + }, + Selected: 1, + Submitting: true, + }) + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "权限审批") { + t.Fatalf("expected prompt header, got %q", joined) + } + if !strings.Contains(joined, "> Allow session") { + t.Fatalf("expected selected option marker, got %q", joined) + } + if !strings.Contains(joined, "正在提交审批结果") { + t.Fatalf("expected submitting hint, got %q", joined) + } +} + +func TestRenderPermissionPrompt(t *testing.T) { + app := App{ + appRuntimeState: appRuntimeState{ + pendingPermission: &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + ToolName: "bash", + Target: "git status", + }, + Selected: 0, + }, + }, + } + rendered := app.renderPermissionPrompt() + if !strings.Contains(rendered, "权限审批") { + t.Fatalf("expected rendered permission prompt, got %q", rendered) + } +} + +func TestParsePermissionPayloadHelpers(t *testing.T) { + req := agentruntime.PermissionRequestPayload{RequestID: "perm-1"} + if got, ok := parsePermissionRequestPayload(req); !ok || got.RequestID != "perm-1" { + t.Fatalf("unexpected parsePermissionRequestPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionRequestPayload((*agentruntime.PermissionRequestPayload)(nil)); ok { + t.Fatalf("expected nil request pointer to fail parsing") + } + + resolved := agentruntime.PermissionResolvedPayload{RequestID: "perm-2"} + if got, ok := parsePermissionResolvedPayload(resolved); !ok || got.RequestID != "perm-2" { + t.Fatalf("unexpected parsePermissionResolvedPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionResolvedPayload((*agentruntime.PermissionResolvedPayload)(nil)); ok { + t.Fatalf("expected nil resolved pointer to fail parsing") + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index e9face16..42ba7047 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -67,6 +67,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() a.state.IsCompacting = false if strings.TrimSpace(a.state.StatusText) == "" { @@ -77,6 +78,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if typed.Err != nil { a.state.IsAgentRunning = false a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() a.state.StreamingReply = false a.state.CurrentTool = "" @@ -94,6 +96,20 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = a.refreshSessions() a.syncActiveSessionTitle() return a, tea.Batch(cmds...) + case permissionResolutionFinishedMsg: + if a.pendingPermission != nil && strings.EqualFold(a.pendingPermission.Request.RequestID, typed.RequestID) { + if typed.Err != nil { + a.pendingPermission.Submitting = false + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("permission", "Permission decision submit failed", typed.Err.Error(), true) + } else { + a.state.ExecutionError = "" + a.state.StatusText = "Permission decision submitted" + a.appendActivity("permission", "Permission decision submitted", string(typed.Decision), false) + } + } + return a, tea.Batch(cmds...) case modelCatalogRefreshMsg: if strings.EqualFold(a.modelRefreshID, typed.ProviderID) { a.modelRefreshID = "" @@ -301,6 +317,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te now := a.now() effectiveTyped := typed + if a.pendingPermission != nil { + if cmd, handled := a.updatePendingPermissionInput(typed); handled { + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + } + if key.Matches(typed, a.keys.Send) { if a.shouldTreatEnterAsNewline(typed, now) { a.growComposerForNewline() @@ -412,6 +437,55 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } +// updatePendingPermissionInput 处理权限审批面板上的键盘交互(上下选择与回车确认)。 +func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { + if a.pendingPermission == nil { + return nil, false + } + if a.pendingPermission.Submitting { + return nil, true + } + + switch { + case key.Matches(typed, a.keys.ScrollUp): + a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected - 1) + a.state.StatusText = "Permission required: choose decision and press Enter" + return nil, true + case key.Matches(typed, a.keys.ScrollDown): + a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected + 1) + a.state.StatusText = "Permission required: choose decision and press Enter" + return nil, true + case key.Matches(typed, a.keys.Send): + option := permissionPromptOptionAt(a.pendingPermission.Selected) + return a.submitPermissionDecision(option.Decision), true + } + + if typed.Type == tea.KeyRunes && len(typed.Runes) > 0 { + if decision, ok := parsePermissionShortcut(string(typed.Runes)); ok { + return a.submitPermissionDecision(decision), true + } + } + return nil, true +} + +// submitPermissionDecision 触发一次权限审批提交命令。 +func (a *App) submitPermissionDecision(decision agentruntime.PermissionResolutionDecision) tea.Cmd { + if a.pendingPermission == nil { + return nil + } + + requestID := strings.TrimSpace(a.pendingPermission.Request.RequestID) + if requestID == "" { + return nil + } + + a.pendingPermission.Submitting = true + a.state.StatusText = "Submitting permission decision..." + a.appendActivity("permission", "Submitting permission decision", string(decision), false) + + return runResolvePermission(a.runtime, requestID, decision) +} + func (a App) now() time.Time { if a.nowFn == nil { return time.Now() @@ -726,6 +800,8 @@ var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentrun agentruntime.EventRunCanceled: runtimeEventRunCanceledHandler, agentruntime.EventError: runtimeEventErrorHandler, agentruntime.EventProviderRetry: runtimeEventProviderRetryHandler, + agentruntime.EventPermissionRequest: runtimeEventPermissionRequestHandler, + agentruntime.EventPermissionResolved: runtimeEventPermissionResolvedHandler, agentruntime.EventCompactDone: runtimeEventCompactDoneHandler, agentruntime.EventCompactError: runtimeEventCompactErrorHandler, } @@ -882,6 +958,7 @@ func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady @@ -899,6 +976,7 @@ func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) boo a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.state.ExecutionError = "" a.state.StatusText = statusCanceled a.clearRunProgress() @@ -913,6 +991,7 @@ func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() if payload, ok := event.Payload.(string); ok { a.state.ExecutionError = payload @@ -932,6 +1011,83 @@ func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) b return false } +// parsePermissionRequestPayload 解析权限请求事件载荷。 +func parsePermissionRequestPayload(payload any) (agentruntime.PermissionRequestPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionRequestPayload: + return typed, true + case *agentruntime.PermissionRequestPayload: + if typed == nil { + return agentruntime.PermissionRequestPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionRequestPayload{}, false + } +} + +// parsePermissionResolvedPayload 解析权限决议事件载荷。 +func parsePermissionResolvedPayload(payload any) (agentruntime.PermissionResolvedPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionResolvedPayload: + return typed, true + case *agentruntime.PermissionResolvedPayload: + if typed == nil { + return agentruntime.PermissionResolvedPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionResolvedPayload{}, false + } +} + +// runtimeEventPermissionRequestHandler 处理 permission_request 事件并激活审批面板。 +func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := parsePermissionRequestPayload(event.Payload) + if !ok { + return false + } + + a.pendingPermission = &permissionPromptState{ + Request: payload, + Selected: 0, + Submitting: false, + } + a.focus = panelInput + a.applyFocus() + a.state.StatusText = "Permission required: choose decision and press Enter" + a.state.ExecutionError = "" + a.appendActivity( + "permission", + "Permission request", + fmt.Sprintf("%s -> %s", fallbackText(payload.ToolName, "tool"), fallbackText(payload.Target, "(empty target)")), + false, + ) + a.applyComponentLayout(false) + return false +} + +// runtimeEventPermissionResolvedHandler 处理 permission_resolved 事件并清理审批面板状态。 +func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := parsePermissionResolvedPayload(event.Payload) + if !ok { + return false + } + + if a.pendingPermission != nil && strings.EqualFold(a.pendingPermission.Request.RequestID, payload.RequestID) { + a.pendingPermission = nil + } + a.state.StatusText = fmt.Sprintf("Permission %s", fallbackText(payload.ResolvedAs, "resolved")) + a.appendActivity( + "permission", + "Permission resolved", + fmt.Sprintf("%s (%s)", fallbackText(payload.Decision, "unknown"), fallbackText(payload.RememberScope, "once")), + false, + ) + a.applyComponentLayout(false) + return false +} + // runtimeEventCompactDoneHandler 处理 compact 完成事件。 func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactDonePayload) @@ -1522,6 +1678,7 @@ func (a *App) startDraftSession() { a.state.ToolStates = nil a.state.RunContext = tuistate.ContextWindowState{} a.state.TokenUsage = tuistate.TokenUsageState{} + a.pendingPermission = nil a.clearRunProgress() a.input.Reset() a.state.InputText = "" @@ -1567,6 +1724,28 @@ func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, work ) } +// runResolvePermission 提交一次权限审批决定到 runtime。 +func runResolvePermission( + runtime agentruntime.Runtime, + requestID string, + decision agentruntime.PermissionResolutionDecision, +) tea.Cmd { + return tuiservices.RunResolvePermissionCmd( + runtime, + agentruntime.PermissionResolutionInput{ + RequestID: strings.TrimSpace(requestID), + Decision: decision, + }, + func(input agentruntime.PermissionResolutionInput, err error) tea.Msg { + return permissionResolutionFinishedMsg{ + RequestID: input.RequestID, + Decision: input.Decision, + Err: err, + } + }, + ) +} + func runSessionWorkdirCommand( runtime agentruntime.Runtime, sessionID string, diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 10a47c10..315cea44 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -190,7 +190,9 @@ func (a App) renderPrompt(width int) string { // Account for frame and padding when sizing the composer container. boxWidth := a.composerBoxWidth(width) - + if a.pendingPermission != nil { + return box.Width(boxWidth).Render(a.renderPermissionPrompt()) + } return box.Width(boxWidth).Render(a.input.View()) } diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go index c151bad6..95d6d70a 100644 --- a/internal/tui/services/runtime_service.go +++ b/internal/tui/services/runtime_service.go @@ -18,6 +18,11 @@ type Compactor interface { Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) } +// PermissionResolver 定义权限审批提交所需最小能力。 +type PermissionResolver interface { + ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error +} + // ListenForRuntimeEventCmd 监听 runtime 事件通道,并将结果映射为 UI 消息。 func ListenForRuntimeEventCmd( sub <-chan agentruntime.RuntimeEvent, @@ -56,3 +61,15 @@ func RunCompactCmd( return doneMsg(err) } } + +// RunResolvePermissionCmd 提交权限审批决定,并将结果映射为 UI 消息。 +func RunResolvePermissionCmd( + runtime PermissionResolver, + input agentruntime.PermissionResolutionInput, + doneMsg func(agentruntime.PermissionResolutionInput, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + err := runtime.ResolvePermission(context.Background(), input) + return doneMsg(input, err) + } +} diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index cde184c9..ce882222 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -33,6 +33,16 @@ func (s *stubCompactor) Compact(ctx context.Context, input agentruntime.CompactI return agentruntime.CompactResult{}, s.err } +type stubPermissionResolver struct { + lastInput agentruntime.PermissionResolutionInput + err error +} + +func (s *stubPermissionResolver) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + s.lastInput = input + return s.err +} + type stubProvider struct { selection config.ProviderSelection models []config.ModelDescriptor @@ -101,6 +111,41 @@ func TestRunCompactCmd(t *testing.T) { } } +func TestRunResolvePermissionCmd(t *testing.T) { + resolver := &stubPermissionResolver{err: errors.New("permission failed")} + input := agentruntime.PermissionResolutionInput{ + RequestID: "perm-1", + Decision: agentruntime.PermissionResolutionAllowSession, + } + msg := RunResolvePermissionCmd( + resolver, + input, + func(in agentruntime.PermissionResolutionInput, err error) tea.Msg { + return struct { + Input agentruntime.PermissionResolutionInput + Err error + }{Input: in, Err: err} + }, + )() + + got, ok := msg.(struct { + Input agentruntime.PermissionResolutionInput + Err error + }) + if !ok { + t.Fatalf("expected wrapped permission result message, got %T %#v", msg, msg) + } + if got.Input.RequestID != "perm-1" || got.Input.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("unexpected permission input forwarded: %+v", got.Input) + } + if got.Err == nil || got.Err.Error() != "permission failed" { + t.Fatalf("expected forwarded permission error, got %#v", got.Err) + } + if resolver.lastInput.RequestID != "perm-1" || resolver.lastInput.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("unexpected resolver input: %+v", resolver.lastInput) + } +} + func TestProviderCmds(t *testing.T) { svc := &stubProvider{ selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 9016281d..6bd8d47f 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -51,3 +51,10 @@ type WorkspaceCommandResultMsg struct { Output string Err error } + +// PermissionResolutionFinishedMsg 表示一次权限审批提交完成结果。 +type PermissionResolutionFinishedMsg struct { + RequestID string + Decision agentruntime.PermissionResolutionDecision + Err error +} From 29b97025e95312f81ce9202d0184283056a209a0 Mon Sep 17 00:00:00 2001 From: Cai_Tang <106404101+Cai-Tang-www@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:06:48 +0800 Subject: [PATCH 38/54] =?UTF-8?q?test(tui):=20=E8=A1=A5=E9=BD=90=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=AE=A1=E6=89=B9=E9=97=AD=E7=8E=AF=E5=88=86=E6=94=AF?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tui/core/app/permission_prompt_test.go | 62 ++++ internal/tui/core/app/update.go | 12 +- .../tui/core/app/update_permission_test.go | 282 ++++++++++++++++++ 3 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 internal/tui/core/app/update_permission_test.go diff --git a/internal/tui/core/app/permission_prompt_test.go b/internal/tui/core/app/permission_prompt_test.go index 7166f2d0..4cb92b36 100644 --- a/internal/tui/core/app/permission_prompt_test.go +++ b/internal/tui/core/app/permission_prompt_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" + "github.com/charmbracelet/bubbles/textarea" + agentruntime "neo-code/internal/runtime" ) @@ -16,6 +18,16 @@ func TestNormalizePermissionPromptSelectionWrap(t *testing.T) { } } +func TestNormalizePermissionPromptSelectionEmptyOptions(t *testing.T) { + original := permissionPromptOptions + permissionPromptOptions = nil + defer func() { permissionPromptOptions = original }() + + if got := normalizePermissionPromptSelection(99); got != 0 { + t.Fatalf("expected empty options to return 0, got %d", got) + } +} + func TestPermissionPromptOptionAt(t *testing.T) { option := permissionPromptOptionAt(-1) if option.Decision != agentruntime.PermissionResolutionReject { @@ -67,6 +79,7 @@ func TestFormatPermissionPromptLines(t *testing.T) { func TestRenderPermissionPrompt(t *testing.T) { app := App{ + appComponents: appComponents{input: textarea.New()}, appRuntimeState: appRuntimeState{ pendingPermission: &permissionPromptState{ Request: agentruntime.PermissionRequestPayload{ @@ -81,6 +94,13 @@ func TestRenderPermissionPrompt(t *testing.T) { if !strings.Contains(rendered, "权限审批") { t.Fatalf("expected rendered permission prompt, got %q", rendered) } + + app.pendingPermission = nil + app.input.SetValue("plain input") + rendered = app.renderPermissionPrompt() + if !strings.Contains(rendered, "plain input") { + t.Fatalf("expected fallback to input view, got %q", rendered) + } } func TestParsePermissionPayloadHelpers(t *testing.T) { @@ -91,6 +111,13 @@ func TestParsePermissionPayloadHelpers(t *testing.T) { if _, ok := parsePermissionRequestPayload((*agentruntime.PermissionRequestPayload)(nil)); ok { t.Fatalf("expected nil request pointer to fail parsing") } + reqPtr := &agentruntime.PermissionRequestPayload{RequestID: "perm-1-ptr"} + if got, ok := parsePermissionRequestPayload(reqPtr); !ok || got.RequestID != "perm-1-ptr" { + t.Fatalf("unexpected pointer parsePermissionRequestPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionRequestPayload("bad"); ok { + t.Fatalf("expected unsupported request payload type to fail parsing") + } resolved := agentruntime.PermissionResolvedPayload{RequestID: "perm-2"} if got, ok := parsePermissionResolvedPayload(resolved); !ok || got.RequestID != "perm-2" { @@ -99,4 +126,39 @@ func TestParsePermissionPayloadHelpers(t *testing.T) { if _, ok := parsePermissionResolvedPayload((*agentruntime.PermissionResolvedPayload)(nil)); ok { t.Fatalf("expected nil resolved pointer to fail parsing") } + resolvedPtr := &agentruntime.PermissionResolvedPayload{RequestID: "perm-2-ptr"} + if got, ok := parsePermissionResolvedPayload(resolvedPtr); !ok || got.RequestID != "perm-2-ptr" { + t.Fatalf("unexpected pointer parsePermissionResolvedPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionResolvedPayload(123); ok { + t.Fatalf("expected unsupported resolved payload type to fail parsing") + } +} + +func TestRenderPromptWithPendingPermission(t *testing.T) { + input := textarea.New() + input.SetValue("normal message") + + app := App{ + appComponents: appComponents{ + input: input, + }, + styles: newStyles(), + appRuntimeState: appRuntimeState{ + pendingPermission: &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ToolName: "bash", Target: "git status"}, + Selected: 0, + }, + }, + } + rendered := app.renderPrompt(80) + if !strings.Contains(rendered, "权限审批") { + t.Fatalf("expected permission prompt rendering branch, got %q", rendered) + } + + app.pendingPermission = nil + rendered = app.renderPrompt(80) + if !strings.Contains(rendered, "normal message") { + t.Fatalf("expected normal input rendering branch, got %q", rendered) + } } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 42ba7047..f8383450 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -1063,7 +1063,7 @@ func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEven fmt.Sprintf("%s -> %s", fallbackText(payload.ToolName, "tool"), fallbackText(payload.Target, "(empty target)")), false, ) - a.applyComponentLayout(false) + a.refreshPermissionPromptLayout() return false } @@ -1084,10 +1084,18 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve fmt.Sprintf("%s (%s)", fallbackText(payload.Decision, "unknown"), fallbackText(payload.RememberScope, "once")), false, ) - a.applyComponentLayout(false) + a.refreshPermissionPromptLayout() return false } +// refreshPermissionPromptLayout 在布局已初始化时刷新权限面板相关排版。 +func (a *App) refreshPermissionPromptLayout() { + if a.width <= 0 || a.height <= 0 { + return + } + a.applyComponentLayout(false) +} + // runtimeEventCompactDoneHandler 处理 compact 完成事件。 func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactDonePayload) diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go new file mode 100644 index 00000000..645a771f --- /dev/null +++ b/internal/tui/core/app/update_permission_test.go @@ -0,0 +1,282 @@ +package tui + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + tuistate "neo-code/internal/tui/state" +) + +type permissionTestRuntime struct { + resolveErr error + lastResolved agentruntime.PermissionResolutionInput +} + +func (r *permissionTestRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *permissionTestRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *permissionTestRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + r.lastResolved = input + return r.resolveErr +} + +func (r *permissionTestRuntime) CancelActiveRun() bool { + return false +} + +func (r *permissionTestRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *permissionTestRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r *permissionTestRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r *permissionTestRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func newPermissionTestApp(runtime agentruntime.Runtime) *App { + input := textarea.New() + spin := spinner.New() + sessionList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + app := &App{ + state: tuistate.UIState{ + Focus: panelInput, + }, + appServices: appServices{ + runtime: runtime, + }, + appComponents: appComponents{ + keys: newKeyMap(), + spinner: spin, + sessions: sessionList, + input: input, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + }, + appRuntimeState: appRuntimeState{ + nowFn: time.Now, + codeCopyBlocks: map[int]string{}, + focus: panelInput, + activities: []tuistate.ActivityEntry{ + {Kind: "test", Title: "seed"}, + }, + }, + } + return app +} + +func TestUpdatePendingPermissionInputSelectAndSubmit(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-1"}, + Selected: 0, + } + + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyDown}) + if !handled || cmd != nil { + t.Fatalf("expected handled down key without cmd, handled=%v cmd=%v", handled, cmd) + } + if app.pendingPermission.Selected != 1 { + t.Fatalf("expected selection moved to 1, got %d", app.pendingPermission.Selected) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyUp}) + if !handled || cmd != nil { + t.Fatalf("expected handled up key without cmd, handled=%v cmd=%v", handled, cmd) + } + if app.pendingPermission.Selected != 0 { + t.Fatalf("expected selection moved back to 0, got %d", app.pendingPermission.Selected) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + if !handled || cmd != nil { + t.Fatalf("expected unknown shortcut to be consumed without cmd, handled=%v cmd=%v", handled, cmd) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyEnter}) + if !handled || cmd == nil { + t.Fatalf("expected enter key to submit permission decision, handled=%v cmd=%v", handled, cmd) + } + + msg := cmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.RequestID != "perm-1" || done.Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("unexpected submitted decision: %+v", done) + } + if runtime.lastResolved.Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("runtime decision mismatch: %+v", runtime.lastResolved) + } +} + +func TestUpdatePendingPermissionInputWithoutPendingState(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyEnter}) + if handled || cmd != nil { + t.Fatalf("expected no handling when pending permission is nil, handled=%v cmd=%v", handled, cmd) + } +} + +func TestUpdatePendingPermissionInputShortcut(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-2"}, + Selected: 0, + } + + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + if !handled || cmd == nil { + t.Fatalf("expected shortcut n to trigger submit, handled=%v cmd=%v", handled, cmd) + } + msg := cmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject decision, got %q", done.Decision) + } +} + +func TestUpdatePendingPermissionInputSubmittingConsumesInput(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-3"}, + Selected: 0, + Submitting: true, + } + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyDown}) + if !handled || cmd != nil { + t.Fatalf("expected submitting state to consume key without cmd, handled=%v cmd=%v", handled, cmd) + } +} + +func TestSubmitPermissionDecisionValidation(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + if cmd := app.submitPermissionDecision(agentruntime.PermissionResolutionAllowOnce); cmd != nil { + t.Fatalf("expected nil cmd when no pending permission") + } + + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: " "}, + Selected: 0, + } + if cmd := app.submitPermissionDecision(agentruntime.PermissionResolutionAllowOnce); cmd != nil { + t.Fatalf("expected nil cmd for empty request id") + } +} + +func TestRuntimePermissionEventHandlers(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + requestEvent := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-4", + ToolName: "bash", + Target: "git status", + }, + } + if dirty := runtimeEventPermissionRequestHandler(app, requestEvent); dirty { + t.Fatalf("permission request should not mark transcript dirty") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-4" { + t.Fatalf("expected pending permission to be recorded") + } + + resolvedEvent := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionResolved, + Payload: agentruntime.PermissionResolvedPayload{ + RequestID: "perm-4", + Decision: "allow", + RememberScope: "once", + ResolvedAs: "approved", + }, + } + if dirty := runtimeEventPermissionResolvedHandler(app, resolvedEvent); dirty { + t.Fatalf("permission resolved should not mark transcript dirty") + } + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared after resolved") + } +} + +func TestUpdatePermissionResolutionFinishedMessage(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-5"}, + Selected: 0, + Submitting: true, + } + app.state.IsAgentRunning = true + app.state.IsCompacting = true + app.state.StatusText = "busy" + + model, _ := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-5", + Decision: agentruntime.PermissionResolutionAllowOnce, + Err: errors.New("network"), + }) + next := model.(App) + if next.pendingPermission == nil || next.pendingPermission.Submitting { + t.Fatalf("expected pending permission to remain and reset submitting on error") + } + if next.state.ExecutionError == "" { + t.Fatalf("expected execution error after failed permission submit") + } +} + +func TestUpdateRuntimeClosedClearsPendingPermission(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-6"}, + } + model, _ := app.Update(RuntimeClosedMsg{}) + next := model.(App) + if next.pendingPermission != nil { + t.Fatalf("expected runtime closed to clear pending permission") + } +} + +func TestRunResolvePermissionForwardsRuntimeError(t *testing.T) { + runtime := &permissionTestRuntime{resolveErr: errors.New("resolve failed")} + cmd := runResolvePermission(runtime, "perm-7", agentruntime.PermissionResolutionReject) + msg := cmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.Err == nil || done.Err.Error() != "resolve failed" { + t.Fatalf("expected forwarded resolve error, got %#v", done.Err) + } + if runtime.lastResolved.RequestID != "perm-7" || runtime.lastResolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected runtime resolve input: %+v", runtime.lastResolved) + } +} From 14ecb63641f5f1f7446bbff2f3543204d88107ce Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 03:17:06 +0000 Subject: [PATCH 39/54] feat(runtime): recover with reactive compact on context-too-long Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- docs/context-compact.md | 13 +- docs/runtime-provider-event-flow.md | 13 +- internal/provider/errors.go | 68 +++++++- internal/provider/errors_test.go | 54 ++++++ internal/provider/openai/openai_test.go | 22 +++ internal/runtime/compact.go | 28 +++- internal/runtime/events.go | 6 +- internal/runtime/runtime.go | 25 ++- internal/runtime/runtime_test.go | 210 ++++++++++++++++++++++++ 9 files changed, 406 insertions(+), 33 deletions(-) diff --git a/docs/context-compact.md b/docs/context-compact.md index 7f6fec7a..92a92c99 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -4,8 +4,8 @@ ## 概览 -- runtime 当前仅接入手动触发的 compact,不包含自动 compact。 -- `internal/context/compact` 已支持 `manual` 与 `reactive` 两种 mode,供 runtime 后续在 provider 上下文过长错误场景接入调用。 +- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 +- `internal/context/compact` 支持 `manual` 与 `reactive` 两种 mode。 - 用户通过 `/compact` 对当前会话执行一次上下文压缩。 - compact 前会先写入完整 transcript,随后生成并校验 compact summary,再回写会话消息。 @@ -69,7 +69,12 @@ context: 3. 生成并校验 `[compact_summary]`。 4. 返回压缩后的消息与 transcript 元信息。 -当前 runtime 主链尚未自动调用 `reactive` mode;后续接入时可继续复用现有 compact 事件,并通过 `trigger_mode=reactive` 区分。 +当 provider 返回“上下文过长”错误时,runtime 会: + +1. 识别 provider 归一化后的 typed error,必要时回退到错误文本匹配。 +2. 触发一次 `compact.Run(mode=reactive)`。 +3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。 +4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。 ## 摘要协议 @@ -106,7 +111,7 @@ constraints: ## 事件 -manual compact 相关 runtime 事件包括: +compact 相关 runtime 事件包括: - `compact_start` - `compact_done` diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index f8944d62..060d6c52 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -10,6 +10,9 @@ - `tool_result` - `error` - `token_usage` +- `compact_start` +- `compact_done` +- `compact_error` ## ReAct 主循环 @@ -18,10 +21,12 @@ 3. 读取最新配置快照。 4. 解析当前 provider 配置并构建 provider 实例。 5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 -6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 -7. 保存 assistant 完整回复。 -8. 执行返回的工具调用,并保存每一个工具结果。 -9. 如果仍需继续推理,则进入下一轮;否则结束。 +6. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再继续构造请求。 +7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 +8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅重试一次当前请求。 +9. 保存 assistant 完整回复。 +10. 执行返回的工具调用,并保存每一个工具结果。 +11. 如果仍需继续推理,则进入下一轮;否则结束。 ### Context Builder 输入与职责 diff --git a/internal/provider/errors.go b/internal/provider/errors.go index daff0da6..cc584e2a 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strings" ) // 通用领域错误。 @@ -20,17 +21,29 @@ var ( type ProviderErrorCode string const ( - ErrorCodeAuthFailed ProviderErrorCode = "auth_failed" // 认证失败(401) - ErrorCodeForbidden ProviderErrorCode = "forbidden" // 权限不足(403) - ErrorCodeNotFound ProviderErrorCode = "not_found" // 资源不存在(404) - ErrorCodeClient ProviderErrorCode = "client_error" // 客户端请求错误(4xx,排除上述分类) - ErrorCodeRateLimit ProviderErrorCode = "rate_limited" // 限流(429) - ErrorCodeServer ProviderErrorCode = "server_error" // 服务端错误(5xx) - ErrorCodeTimeout ProviderErrorCode = "timeout" // 超时 - ErrorCodeNetwork ProviderErrorCode = "network_error" // 网络错误(连接拒绝、DNS 失败等) - ErrorCodeUnknown ProviderErrorCode = "unknown" // 未知错误 + ErrorCodeAuthFailed ProviderErrorCode = "auth_failed" // 认证失败(401) + ErrorCodeForbidden ProviderErrorCode = "forbidden" // 权限不足(403) + ErrorCodeNotFound ProviderErrorCode = "not_found" // 资源不存在(404) + ErrorCodeClient ProviderErrorCode = "client_error" // 客户端请求错误(4xx,排除上述分类) + ErrorCodeRateLimit ProviderErrorCode = "rate_limited" // 限流(429) + ErrorCodeServer ProviderErrorCode = "server_error" // 服务端错误(5xx) + ErrorCodeTimeout ProviderErrorCode = "timeout" // 超时 + ErrorCodeNetwork ProviderErrorCode = "network_error" // 网络错误(连接拒绝、DNS 失败等) + ErrorCodeContextTooLong ProviderErrorCode = "context_too_long" // 上下文超出模型窗口 + ErrorCodeUnknown ProviderErrorCode = "unknown" // 未知错误 ) +var contextTooLongFragments = []string{ + "context length", + "context_length_exceeded", + "context window", + "maximum context length", + "maximum prompt length", + "prompt is too long", + "requested too many tokens", + "too many tokens", +} + // ProviderError 是 provider 层的领域错误类型。 type ProviderError struct { StatusCode int // HTTP 状态码,0 表示非 HTTP 错误(如网络超时) @@ -78,6 +91,9 @@ func classifyStatus(statusCode int) ProviderErrorCode { // NewProviderErrorFromStatus 根据 HTTP 状态码和消息构造 ProviderError。 func NewProviderErrorFromStatus(statusCode int, message string) *ProviderError { code := classifyStatus(statusCode) + if matchesContextTooLong(message) { + code = ErrorCodeContextTooLong + } return &ProviderError{ StatusCode: statusCode, Code: code, @@ -105,3 +121,37 @@ func NewTimeoutProviderError(message string) *ProviderError { Retryable: true, // 超时默认可重试 } } + +// IsContextTooLong 判断 provider 错误是否表示请求上下文超出模型窗口。 +// 优先识别 typed error,必要时再回退到消息文本匹配,兼容不同厂商或额外包装层。 +func IsContextTooLong(err error) bool { + if err == nil { + return false + } + + var pErr *ProviderError + if errors.As(err, &pErr) { + if pErr.Code == ErrorCodeContextTooLong { + return true + } + if matchesContextTooLong(pErr.Message) { + return true + } + } + + return matchesContextTooLong(err.Error()) +} + +// matchesContextTooLong 统一收敛常见“上下文过长”报错片段,减少 runtime 对厂商文案的感知。 +func matchesContextTooLong(message string) bool { + normalized := strings.ToLower(strings.TrimSpace(message)) + if normalized == "" { + return false + } + for _, fragment := range contextTooLongFragments { + if strings.Contains(normalized, fragment) { + return true + } + } + return false +} diff --git a/internal/provider/errors_test.go b/internal/provider/errors_test.go index e6e9ac4d..db720406 100644 --- a/internal/provider/errors_test.go +++ b/internal/provider/errors_test.go @@ -121,6 +121,11 @@ func TestNewProviderErrorFromStatus(t *testing.T) { if err.Retryable { t.Fatalf("expected 401 to not be retryable") } + + err = NewProviderErrorFromStatus(400, "This model's maximum context length is 128000 tokens.") + if err.Code != ErrorCodeContextTooLong { + t.Fatalf("expected code %q, got %q", ErrorCodeContextTooLong, err.Code) + } } func TestNewNetworkProviderError(t *testing.T) { @@ -170,3 +175,52 @@ func TestProviderError_As(t *testing.T) { t.Fatalf("expected retryable") } } + +func TestIsContextTooLong(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "typed provider error", + err: &ProviderError{ + StatusCode: 400, + Code: ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + }, + want: true, + }, + { + name: "wrapped provider message fallback", + err: fmt.Errorf("wrapped: %w", &ProviderError{ + StatusCode: 400, + Code: ErrorCodeClient, + Message: "prompt is too long for this model", + }), + want: true, + }, + { + name: "plain text fallback", + err: errors.New("context window exceeded for model"), + want: true, + }, + { + name: "non context error", + err: errors.New("invalid api key"), + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsContextTooLong(tt.err); got != tt.want { + t.Fatalf("IsContextTooLong() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 242a83b7..a41fa279 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -910,6 +910,28 @@ func TestParseError_InvalidJSONBody(t *testing.T) { } } +func TestParseError_ClassifiesContextTooLong(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + resp := &http.Response{ + Status: "400 Bad Request", + StatusCode: 400, + Body: ioNopCloser(`{"error":{"message":"This model's maximum context length is 128000 tokens. However, your messages resulted in 140000 tokens."}}`), + } + err = p.parseError(resp) + if err == nil { + t.Fatal("expected context too long error") + } + if !provider.IsContextTooLong(err) { + t.Fatalf("expected parsed error to be classified as context too long, got %v", err) + } +} + // --- 原有保留的集成测试(保持兼容) --- func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 26487f33..b694ef82 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -64,7 +64,7 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul return CompactResult{}, err } - session, result, err := s.runCompactForSession(ctx, input.RunID, session, cfg, true) + session, result, err := s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeManual, true) if err != nil { return CompactResult{}, err } @@ -86,6 +86,7 @@ func (s *Service) runCompactForSession( runID string, session agentsession.Session, cfg config.Config, + mode contextcompact.Mode, failOnError bool, ) (agentsession.Session, contextcompact.Result, error) { runner := s.compactRunner @@ -94,7 +95,7 @@ func (s *Service) runCompactForSession( runner, err = s.defaultCompactRunner(session, cfg) if err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), Message: err.Error(), }) if failOnError { @@ -105,18 +106,18 @@ func (s *Service) runCompactForSession( } originalMessages := append([]providertypes.Message(nil), session.Messages...) - s.emit(ctx, EventCompactStart, runID, session.ID, string(contextcompact.ModeManual)) + s.emit(ctx, EventCompactStart, runID, session.ID, string(mode)) result, err := runner.Run(ctx, contextcompact.Input{ - Mode: contextcompact.ModeManual, + Mode: mode, SessionID: session.ID, - Workdir: cfg.Workdir, + Workdir: effectiveSessionWorkdir(session.Workdir, cfg.Workdir), Messages: session.Messages, Config: cfg.Context.Compact, }) if err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), Message: err.Error(), }) if failOnError { @@ -130,7 +131,7 @@ func (s *Service) runCompactForSession( session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), Message: err.Error(), }) session.Messages = originalMessages @@ -146,7 +147,7 @@ func (s *Service) runCompactForSession( BeforeChars: result.Metrics.BeforeChars, AfterChars: result.Metrics.AfterChars, SavedRatio: result.Metrics.SavedRatio, - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), TranscriptID: result.TranscriptID, TranscriptPath: result.TranscriptPath, } @@ -155,6 +156,17 @@ func (s *Service) runCompactForSession( return session, result, nil } +// resetSessionTokenTotals 在 compact 成功后同步清零内存计数器与会话累计 token。 +func (s *Service) resetSessionTokenTotals(session *agentsession.Session) { + s.sessionInputTokens = 0 + s.sessionOutputTokens = 0 + if session == nil { + return + } + session.TokenInputTotal = 0 + session.TokenOutputTotal = 0 +} + // defaultCompactRunner 为手动 compact 选择摘要生成器并构造默认 runner。 func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config.Config) (contextcompact.Runner, error) { resolvedProvider, model, err := resolveCompactProviderSelection(session, cfg) diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 80117aec..97eadc6a 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -85,8 +85,8 @@ const ( // TokenUsagePayload carries token usage statistics for a single provider turn. type TokenUsagePayload struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` SessionInputTokens int `json:"session_input_tokens"` SessionOutputTokens int `json:"session_output_tokens"` -} \ No newline at end of file +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 2e13466d..4b93235d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -211,6 +211,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) autoCompacted := false + reactiveRetried := false for attempt := 0; ; attempt++ { if err := ctx.Err(); err != nil { @@ -251,12 +252,9 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { if builtContext.ShouldAutoCompact && !autoCompacted { autoCompacted = true var compactResult contextcompact.Result - session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, false) + session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeManual, false) if compactResult.Applied { - s.sessionInputTokens = 0 - s.sessionOutputTokens = 0 - session.TokenInputTotal = 0 - session.TokenOutputTotal = 0 + s.resetSessionTokenTotals(&session) } } @@ -274,6 +272,23 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { Tools: toolSpecs, }) if err != nil { + if provider.IsContextTooLong(err) && !reactiveRetried { + reactiveRetried = true + var compactResult contextcompact.Result + session, compactResult, _ = s.runCompactForSession( + ctx, + input.RunID, + session, + cfg, + contextcompact.ModeReactive, + false, + ) + if compactResult.Applied { + s.resetSessionTokenTotals(&session) + autoCompacted = true + } + continue + } return s.handleRunError(ctx, input.RunID, session.ID, err) } if err := ctx.Err(); err != nil { diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 51fa37b1..c486b65b 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3367,6 +3367,216 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { assertNoEventType(t, events, EventCompactError) } +func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := agentsession.New("reactive-compact") + session.ID = "session-reactive-compact" + session.TokenInputTotal = 220 + session.TokenOutputTotal = 70 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "reactive compact prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), + }, nil + }, + } + + callCount := 0 + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + callCount++ + if callCount == 1 { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + Retryable: false, + } + } + select { + case events <- providertypes.NewTextDeltaStreamEvent("recovered"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", nil): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 120, + AfterChars: 48, + SavedRatio: 0.6, + TriggerMode: string(contextcompact.ModeReactive), + }, + TranscriptID: "transcript_reactive", + TranscriptPath: "/tmp/reactive.jsonl", + }, + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-reactive-compact", + Content: "continue", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + compactRunner := service.compactRunner.(*stubCompactRunner) + if len(compactRunner.calls) != 1 { + t.Fatalf("expected reactive compact to run once, got %d", len(compactRunner.calls)) + } + if compactRunner.calls[0].Mode != contextcompact.ModeReactive { + t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeReactive, compactRunner.calls[0].Mode) + } + if len(builder.builds) != 2 { + t.Fatalf("expected 2 build attempts, got %d", len(builder.builds)) + } + if builder.builds[0].Metadata.SessionInputTokens != 220 { + t.Fatalf("expected first build to see pre-compact input tokens, got %d", builder.builds[0].Metadata.SessionInputTokens) + } + if builder.builds[1].Metadata.SessionInputTokens != 0 { + t.Fatalf("expected second build to see reset input tokens, got %d", builder.builds[1].Metadata.SessionInputTokens) + } + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called twice, got %d", scripted.callCount) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load compacted session: %v", err) + } + if saved.TokenInputTotal != 0 || saved.TokenOutputTotal != 0 { + t.Fatalf("expected persisted token totals to reset, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) + } + if len(saved.Messages) != 3 { + t.Fatalf("expected compacted transcript plus final assistant reply, got %+v", saved.Messages) + } + if saved.Messages[2].Content != "recovered" { + t.Fatalf("expected final assistant reply %q, got %q", "recovered", saved.Messages[2].Content) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactDone, + EventAgentDone, + }) + assertNoEventType(t, events, EventCompactError) + + foundReactiveDone := false + for _, event := range events { + if event.Type != EventCompactDone { + continue + } + payload, ok := event.Payload.(CompactDonePayload) + if !ok { + t.Fatalf("expected CompactDonePayload, got %T", event.Payload) + } + if payload.TriggerMode != string(contextcompact.ModeReactive) { + t.Fatalf("expected trigger mode %q, got %q", contextcompact.ModeReactive, payload.TriggerMode) + } + foundReactiveDone = true + } + if !foundReactiveDone { + t.Fatalf("expected reactive compact_done event in %+v", events) + } +} + +func TestServiceRunReactiveCompactRetriesOnlyOnce(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "prompt is too long", + Retryable: false, + } + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + service.compactRunner = &stubCompactRunner{ + err: errors.New("compact failed"), + } + + err := service.Run(context.Background(), UserInput{ + RunID: "run-reactive-compact-once", + Content: "continue", + }) + if err == nil || !containsError(err, "prompt is too long") { + t.Fatalf("expected final context-too-long error, got %v", err) + } + + compactRunner := service.compactRunner.(*stubCompactRunner) + if len(compactRunner.calls) != 1 { + t.Fatalf("expected reactive compact to run once, got %d", len(compactRunner.calls)) + } + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called exactly twice, got %d", scripted.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactError, + EventError, + }) + assertNoEventType(t, events, EventCompactDone) + + foundReactiveError := false + for _, event := range events { + if event.Type != EventCompactError { + continue + } + payload, ok := event.Payload.(CompactErrorPayload) + if !ok { + t.Fatalf("expected CompactErrorPayload, got %T", event.Payload) + } + if payload.TriggerMode != string(contextcompact.ModeReactive) { + t.Fatalf("expected trigger mode %q, got %q", contextcompact.ModeReactive, payload.TriggerMode) + } + foundReactiveError = true + } + if !foundReactiveError { + t.Fatalf("expected reactive compact_error event in %+v", events) + } +} + func TestRestoreSessionTokens(t *testing.T) { t.Parallel() From 494499bf6110e7755b3c508703d29a91cb5076b3 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 03:32:14 +0000 Subject: [PATCH 40/54] =?UTF-8?q?fix(runtime):=20address=20review=20issues?= =?UTF-8?q?=20=E2=80=94=20loop=20budget,=20token-reset=20persist,=20rate-l?= =?UTF-8?q?imit=20misclassification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime.go: decrement attempt before reactive-compact continue so the retry does not consume a MaxLoops slot (fixes max_loops=1 case) - compact.go: reset TokenInputTotal/TokenOutputTotal on session before saving after compact, preventing stale high totals from being restored on the next run - errors.go: guard text-based context_too_long override to only apply for generic client errors (ErrorCodeClient); 429 rate-limit errors whose message contains token-count fragments are no longer mis-routed into the reactive-compact path; same guard added in IsContextTooLong - errors_test.go: add regression cases for the 429-with-token-count-message scenarios in NewProviderErrorFromStatus and IsContextTooLong Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/provider/errors.go | 13 ++++++++++++- internal/provider/errors_test.go | 15 +++++++++++++++ internal/runtime/compact.go | 5 +++++ internal/runtime/runtime.go | 2 ++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/internal/provider/errors.go b/internal/provider/errors.go index cc584e2a..3a4e2964 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -91,7 +91,11 @@ func classifyStatus(statusCode int) ProviderErrorCode { // NewProviderErrorFromStatus 根据 HTTP 状态码和消息构造 ProviderError。 func NewProviderErrorFromStatus(statusCode int, message string) *ProviderError { code := classifyStatus(statusCode) - if matchesContextTooLong(message) { + // Only elevate to context_too_long for generic client errors (e.g. 400/413). + // Do not override specific classifications such as rate_limited (429) even if + // the message contains token-count fragments, which would mis-route throttling + // errors into the reactive-compact path. + if code == ErrorCodeClient && matchesContextTooLong(message) { code = ErrorCodeContextTooLong } return &ProviderError{ @@ -124,6 +128,7 @@ func NewTimeoutProviderError(message string) *ProviderError { // IsContextTooLong 判断 provider 错误是否表示请求上下文超出模型窗口。 // 优先识别 typed error,必要时再回退到消息文本匹配,兼容不同厂商或额外包装层。 +// 已被归类为 rate_limited (429) 的错误不会因文本片段而被误判为 context_too_long。 func IsContextTooLong(err error) bool { if err == nil { return false @@ -134,6 +139,12 @@ func IsContextTooLong(err error) bool { if pErr.Code == ErrorCodeContextTooLong { return true } + // Skip text fallback for errors that are already classified as a specific + // non-context error (e.g. rate_limited). Token-count fragments in 429 + // messages must not route the runtime into the reactive-compact path. + if pErr.Code == ErrorCodeRateLimit { + return false + } if matchesContextTooLong(pErr.Message) { return true } diff --git a/internal/provider/errors_test.go b/internal/provider/errors_test.go index db720406..bbcd247b 100644 --- a/internal/provider/errors_test.go +++ b/internal/provider/errors_test.go @@ -126,6 +126,12 @@ func TestNewProviderErrorFromStatus(t *testing.T) { if err.Code != ErrorCodeContextTooLong { t.Fatalf("expected code %q, got %q", ErrorCodeContextTooLong, err.Code) } + + // 429 with token-count message must stay rate_limited, not context_too_long. + err = NewProviderErrorFromStatus(429, "requested too many tokens for this minute") + if err.Code != ErrorCodeRateLimit { + t.Fatalf("429 with token-count message: expected code %q, got %q", ErrorCodeRateLimit, err.Code) + } } func TestNewNetworkProviderError(t *testing.T) { @@ -212,6 +218,15 @@ func TestIsContextTooLong(t *testing.T) { err: errors.New("invalid api key"), want: false, }, + { + name: "rate limited with token-count message is not context_too_long", + err: &ProviderError{ + StatusCode: 429, + Code: ErrorCodeRateLimit, + Message: "requested too many tokens for this minute", + }, + want: false, + }, } for _, tt := range tests { diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index b694ef82..3a9e426d 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -128,6 +128,11 @@ func (s *Service) runCompactForSession( if result.Applied { session.Messages = append([]providertypes.Message(nil), result.Messages...) + // Reset token totals now so the persisted session never carries stale high + // counts; if the follow-up provider call is canceled before its own save + // the next run will start from zero rather than immediately auto-compacting. + session.TokenInputTotal = 0 + session.TokenOutputTotal = 0 session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 4b93235d..882e52ae 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -287,6 +287,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { s.resetSessionTokenTotals(&session) autoCompacted = true } + // Don't count the reactive-compact retry as a new reasoning turn. + attempt-- continue } return s.handleRunError(ctx, input.RunID, session.ID, err) From c7dc253181e3223b237095bab5a0ca791b0ca55e Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 03:33:57 +0000 Subject: [PATCH 41/54] fix(tui): close permission ask review gaps Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/tui/core/app/app.go | 1 + internal/tui/core/app/commands.go | 39 +++--- internal/tui/core/app/permission_prompt.go | 79 +++++++++-- .../tui/core/app/permission_prompt_test.go | 21 ++- internal/tui/core/app/styles.go | 1 - internal/tui/core/app/update.go | 64 ++++----- .../tui/core/app/update_permission_test.go | 131 ++++++++++++++++++ internal/tui/core/app/view.go | 5 +- internal/tui/services/runtime_service.go | 8 +- internal/tui/services/services_test.go | 11 +- 10 files changed, 283 insertions(+), 77 deletions(-) diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 4c8e778c..1a32a54c 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -86,6 +86,7 @@ type appComponents struct { type appRuntimeState struct { codeCopyBlocks map[int]string pendingCopyID int + deferredEventCmd tea.Cmd nowFn func() time.Time lastInputEditAt time.Time lastPasteLikeAt time.Time diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ad373587..9cf1c67a 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -52,24 +52,27 @@ const ( emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." emptyMessageText = "(empty)" - 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" - statusApplyingCommand = "Applying local command" - statusRunningCommand = "Running command" - statusCommandDone = "Command finished" - statusCompacting = "Compacting context" - statusChooseProvider = "Choose a provider" - statusChooseModel = "Choose a model" - statusBrowseFile = "Browse workspace files" + 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" + statusApplyingCommand = "Applying local command" + statusRunningCommand = "Running command" + statusCommandDone = "Command finished" + statusCompacting = "Compacting context" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" + statusBrowseFile = "Browse workspace files" + statusPermissionRequired = "Permission required: choose a decision and press Enter" + statusPermissionSubmitting = "Submitting permission decision" + statusPermissionSubmitted = "Permission decision submitted" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" diff --git a/internal/tui/core/app/permission_prompt.go b/internal/tui/core/app/permission_prompt.go index 1aea5268..0bf31392 100644 --- a/internal/tui/core/app/permission_prompt.go +++ b/internal/tui/core/app/permission_prompt.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "unicode" "github.com/charmbracelet/lipgloss" @@ -19,17 +20,17 @@ type permissionPromptOption struct { var permissionPromptOptions = []permissionPromptOption{ { Label: "Allow once", - Hint: "仅本次放行", + Hint: "Approve this request once", Decision: agentruntime.PermissionResolutionAllowOnce, }, { Label: "Allow session", - Hint: "本会话同类请求持续放行", + Hint: "Approve similar requests for this session", Decision: agentruntime.PermissionResolutionAllowSession, }, { Label: "Reject", - Hint: "拒绝本次请求(可记忆拒绝)", + Hint: "Reject this request", Decision: agentruntime.PermissionResolutionReject, }, } @@ -64,9 +65,9 @@ func permissionPromptOptionAt(selected int) permissionPromptOption { // parsePermissionShortcut 将快捷输入映射为审批决策。 func parsePermissionShortcut(input string) (agentruntime.PermissionResolutionDecision, bool) { switch strings.ToLower(strings.TrimSpace(input)) { - case "y", "yes", "once", "allow_once": + case "y", "yes", "once": return agentruntime.PermissionResolutionAllowOnce, true - case "a", "always", "allow_session": + case "a", "always": return agentruntime.PermissionResolutionAllowSession, true case "n", "no", "reject", "deny": return agentruntime.PermissionResolutionReject, true @@ -77,22 +78,27 @@ func parsePermissionShortcut(input string) (agentruntime.PermissionResolutionDec // formatPermissionPromptLines 构造权限审批面板展示文本。 func formatPermissionPromptLines(state permissionPromptState) []string { + normalizedIdx := normalizePermissionPromptSelection(state.Selected) lines := []string{ - fmt.Sprintf("权限审批:%s (%s)", fallbackText(state.Request.ToolName, "unknown_tool"), fallbackText(state.Request.Operation, "unknown")), - fmt.Sprintf("目标:%s", fallbackText(state.Request.Target, "(empty)")), - "使用 ↑/↓ 选择,Enter 确认(快捷键:y=once, a=session, n=reject)", + fmt.Sprintf( + "Permission request: %s (%s)", + fallbackText(sanitizePermissionDisplayText(state.Request.ToolName), "unknown_tool"), + fallbackText(sanitizePermissionDisplayText(state.Request.Operation), "unknown"), + ), + fmt.Sprintf("Target: %s", fallbackText(sanitizePermissionDisplayText(state.Request.Target), "(empty)")), + "Use Up/Down to choose, Enter to confirm (shortcuts: y=once, a=session, n=reject)", } for index, item := range permissionPromptOptions { prefix := " " - if normalizePermissionPromptSelection(state.Selected) == index { + if normalizedIdx == index { prefix = "> " } lines = append(lines, fmt.Sprintf("%s%s - %s", prefix, item.Label, item.Hint)) } if state.Submitting { - lines = append(lines, "正在提交审批结果...") + lines = append(lines, "Submitting permission decision...") } return lines } @@ -106,6 +112,59 @@ func fallbackText(value string, fallback string) string { return trimmed } +// sanitizePermissionDisplayText 清理模型可控的终端展示文本,避免控制字符污染审批界面。 +func sanitizePermissionDisplayText(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + + var builder strings.Builder + lastWasSpace := false + for _, r := range value { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + if !lastWasSpace { + builder.WriteByte(' ') + lastWasSpace = true + } + continue + } + builder.WriteRune(r) + lastWasSpace = unicode.IsSpace(r) + } + + return strings.TrimSpace(builder.String()) +} + +// parsePermissionRequestPayload 解析权限请求事件载荷。 +func parsePermissionRequestPayload(payload any) (agentruntime.PermissionRequestPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionRequestPayload: + return typed, true + case *agentruntime.PermissionRequestPayload: + if typed == nil { + return agentruntime.PermissionRequestPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionRequestPayload{}, false + } +} + +// parsePermissionResolvedPayload 解析权限决议事件载荷。 +func parsePermissionResolvedPayload(payload any) (agentruntime.PermissionResolvedPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionResolvedPayload: + return typed, true + case *agentruntime.PermissionResolvedPayload: + if typed == nil { + return agentruntime.PermissionResolvedPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionResolvedPayload{}, false + } +} + // renderPermissionPrompt 渲染审批输入框内容,替代普通输入框文本编辑状态。 func (a App) renderPermissionPrompt() string { if a.pendingPermission == nil { diff --git a/internal/tui/core/app/permission_prompt_test.go b/internal/tui/core/app/permission_prompt_test.go index 4cb92b36..db85fa81 100644 --- a/internal/tui/core/app/permission_prompt_test.go +++ b/internal/tui/core/app/permission_prompt_test.go @@ -66,13 +66,13 @@ func TestFormatPermissionPromptLines(t *testing.T) { Submitting: true, }) joined := strings.Join(lines, "\n") - if !strings.Contains(joined, "权限审批") { + if !strings.Contains(joined, "Permission request") { t.Fatalf("expected prompt header, got %q", joined) } if !strings.Contains(joined, "> Allow session") { t.Fatalf("expected selected option marker, got %q", joined) } - if !strings.Contains(joined, "正在提交审批结果") { + if !strings.Contains(joined, "Submitting permission decision") { t.Fatalf("expected submitting hint, got %q", joined) } } @@ -91,7 +91,7 @@ func TestRenderPermissionPrompt(t *testing.T) { }, } rendered := app.renderPermissionPrompt() - if !strings.Contains(rendered, "权限审批") { + if !strings.Contains(rendered, "Permission request") { t.Fatalf("expected rendered permission prompt, got %q", rendered) } @@ -135,6 +135,19 @@ func TestParsePermissionPayloadHelpers(t *testing.T) { } } +func TestSanitizePermissionDisplayText(t *testing.T) { + got := sanitizePermissionDisplayText("bash\x1b[31m\n./demo\t\u202egit status") + if strings.ContainsRune(got, '\x1b') { + t.Fatalf("expected escape characters to be removed, got %q", got) + } + if strings.Contains(got, "\u202e") { + t.Fatalf("expected format control characters to be removed, got %q", got) + } + if !strings.Contains(got, "bash [31m ./demo git status") { + t.Fatalf("expected printable content to remain, got %q", got) + } +} + func TestRenderPromptWithPendingPermission(t *testing.T) { input := textarea.New() input.SetValue("normal message") @@ -152,7 +165,7 @@ func TestRenderPromptWithPendingPermission(t *testing.T) { }, } rendered := app.renderPrompt(80) - if !strings.Contains(rendered, "权限审批") { + if !strings.Contains(rendered, "Permission request") { t.Fatalf("expected permission prompt rendering branch, got %q", rendered) } diff --git a/internal/tui/core/app/styles.go b/internal/tui/core/app/styles.go index 15262a71..83c44a47 100644 --- a/internal/tui/core/app/styles.go +++ b/internal/tui/core/app/styles.go @@ -325,4 +325,3 @@ func preview(text string, width int, lines int) string { } return joined } - diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index f8383450..0062908b 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -55,6 +55,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeMsg: transcriptDirty := a.handleRuntimeEvent(typed.Event) + if a.deferredEventCmd != nil { + cmds = append(cmds, a.deferredEventCmd) + a.deferredEventCmd = nil + } _ = a.refreshSessions() a.syncActiveSessionTitle() if transcriptDirty { @@ -97,16 +101,18 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.syncActiveSessionTitle() return a, tea.Batch(cmds...) case permissionResolutionFinishedMsg: - if a.pendingPermission != nil && strings.EqualFold(a.pendingPermission.Request.RequestID, typed.RequestID) { + if a.pendingPermission != nil && a.pendingPermission.Request.RequestID == typed.RequestID { if typed.Err != nil { a.pendingPermission.Submitting = false a.state.ExecutionError = typed.Err.Error() a.state.StatusText = typed.Err.Error() a.appendActivity("permission", "Permission decision submit failed", typed.Err.Error(), true) } else { + a.pendingPermission = nil a.state.ExecutionError = "" - a.state.StatusText = "Permission decision submitted" + a.state.StatusText = statusPermissionSubmitted a.appendActivity("permission", "Permission decision submitted", string(typed.Decision), false) + a.refreshPermissionPromptLayout() } } return a, tea.Batch(cmds...) @@ -449,11 +455,11 @@ func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { switch { case key.Matches(typed, a.keys.ScrollUp): a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected - 1) - a.state.StatusText = "Permission required: choose decision and press Enter" + a.state.StatusText = statusPermissionRequired return nil, true case key.Matches(typed, a.keys.ScrollDown): a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected + 1) - a.state.StatusText = "Permission required: choose decision and press Enter" + a.state.StatusText = statusPermissionRequired return nil, true case key.Matches(typed, a.keys.Send): option := permissionPromptOptionAt(a.pendingPermission.Selected) @@ -480,7 +486,7 @@ func (a *App) submitPermissionDecision(decision agentruntime.PermissionResolutio } a.pendingPermission.Submitting = true - a.state.StatusText = "Submitting permission decision..." + a.state.StatusText = statusPermissionSubmitting a.appendActivity("permission", "Submitting permission decision", string(decision), false) return runResolvePermission(a.runtime, requestID, decision) @@ -1011,36 +1017,6 @@ func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) b return false } -// parsePermissionRequestPayload 解析权限请求事件载荷。 -func parsePermissionRequestPayload(payload any) (agentruntime.PermissionRequestPayload, bool) { - switch typed := payload.(type) { - case agentruntime.PermissionRequestPayload: - return typed, true - case *agentruntime.PermissionRequestPayload: - if typed == nil { - return agentruntime.PermissionRequestPayload{}, false - } - return *typed, true - default: - return agentruntime.PermissionRequestPayload{}, false - } -} - -// parsePermissionResolvedPayload 解析权限决议事件载荷。 -func parsePermissionResolvedPayload(payload any) (agentruntime.PermissionResolvedPayload, bool) { - switch typed := payload.(type) { - case agentruntime.PermissionResolvedPayload: - return typed, true - case *agentruntime.PermissionResolvedPayload: - if typed == nil { - return agentruntime.PermissionResolvedPayload{}, false - } - return *typed, true - default: - return agentruntime.PermissionResolvedPayload{}, false - } -} - // runtimeEventPermissionRequestHandler 处理 permission_request 事件并激活审批面板。 func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := parsePermissionRequestPayload(event.Payload) @@ -1048,6 +1024,20 @@ func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEven return false } + if a.pendingPermission != nil { + currentRequestID := strings.TrimSpace(a.pendingPermission.Request.RequestID) + nextRequestID := strings.TrimSpace(payload.RequestID) + if currentRequestID != "" && currentRequestID != nextRequestID && !a.pendingPermission.Submitting { + a.deferredEventCmd = runResolvePermission(a.runtime, currentRequestID, agentruntime.PermissionResolutionReject) + a.appendActivity( + "permission", + "Auto-rejected superseded permission request", + currentRequestID, + false, + ) + } + } + a.pendingPermission = &permissionPromptState{ Request: payload, Selected: 0, @@ -1055,7 +1045,7 @@ func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEven } a.focus = panelInput a.applyFocus() - a.state.StatusText = "Permission required: choose decision and press Enter" + a.state.StatusText = statusPermissionRequired a.state.ExecutionError = "" a.appendActivity( "permission", @@ -1074,7 +1064,7 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve return false } - if a.pendingPermission != nil && strings.EqualFold(a.pendingPermission.Request.RequestID, payload.RequestID) { + if a.pendingPermission != nil && a.pendingPermission.Request.RequestID == payload.RequestID { a.pendingPermission = nil } a.state.StatusText = fmt.Sprintf("Permission %s", fallbackText(payload.ResolvedAs, "resolved")) diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go index 645a771f..d340f226 100644 --- a/internal/tui/core/app/update_permission_test.go +++ b/internal/tui/core/app/update_permission_test.go @@ -253,6 +253,27 @@ func TestUpdatePermissionResolutionFinishedMessage(t *testing.T) { } } +func TestUpdatePermissionResolutionFinishedMessageSuccessClearsPendingPermission(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-5-success"}, + Selected: 0, + Submitting: true, + } + + model, _ := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-5-success", + Decision: agentruntime.PermissionResolutionAllowOnce, + }) + next := model.(App) + if next.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared on success") + } + if next.state.StatusText != statusPermissionSubmitted { + t.Fatalf("expected submitted status text, got %q", next.state.StatusText) + } +} + func TestUpdateRuntimeClosedClearsPendingPermission(t *testing.T) { app := newPermissionTestApp(&permissionTestRuntime{}) app.pendingPermission = &permissionPromptState{ @@ -265,6 +286,116 @@ func TestUpdateRuntimeClosedClearsPendingPermission(t *testing.T) { } } +func TestRuntimePermissionRequestHandlerAutoRejectsSupersededRequest(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-old"}, + Selected: 1, + } + + event := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-new", + ToolName: "bash", + Target: "pwd", + }, + } + if dirty := runtimeEventPermissionRequestHandler(app, event); dirty { + t.Fatalf("permission request should not mark transcript dirty") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-new" { + t.Fatalf("expected latest permission request to replace old one") + } + if app.deferredEventCmd == nil { + t.Fatalf("expected superseded request to schedule auto-reject command") + } + + msg := app.deferredEventCmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.RequestID != "perm-old" || done.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected auto-reject payload: %+v", done) + } + if runtime.lastResolved.RequestID != "perm-old" || runtime.lastResolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected runtime resolve input: %+v", runtime.lastResolved) + } +} + +func TestRuntimePermissionRequestHandlerDoesNotAutoRejectSubmittingRequest(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-old"}, + Submitting: true, + } + + runtimeEventPermissionRequestHandler(app, agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-new", + }, + }) + if app.deferredEventCmd != nil { + t.Fatalf("expected no auto-reject command when current request is already submitting") + } +} + +func TestHandleRuntimeEventQueuesDeferredCommand(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-old"}, + } + + model, cmd := app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-new", + }, + }}) + next := model.(App) + if next.deferredEventCmd != nil { + t.Fatalf("expected deferred event cmd to be consumed during update") + } + if cmd == nil { + t.Fatalf("expected runtime update to batch deferred command") + } + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("expected deferred command batch, got %T", msg) + } + if len(batch) == 0 { + t.Fatalf("expected deferred command batch to contain work") + } + if _, ok := batch[0]().(permissionResolutionFinishedMsg); !ok { + t.Fatalf("expected deferred batch command to resolve permission") + } + if runtime.lastResolved.RequestID != "perm-old" || runtime.lastResolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected deferred auto-reject to run, got %+v", runtime.lastResolved) + } +} + +func TestRuntimePermissionResolvedHandlerUsesExactRequestIDMatch(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "Perm-1"}, + } + + runtimeEventPermissionResolvedHandler(app, agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionResolved, + Payload: agentruntime.PermissionResolvedPayload{ + RequestID: "perm-1", + }, + }) + if app.pendingPermission == nil { + t.Fatalf("expected mismatched request id case to keep pending permission") + } +} + func TestRunResolvePermissionForwardsRuntimeError(t *testing.T) { runtime := &permissionTestRuntime{resolveErr: errors.New("resolve failed")} cmd := runResolvePermission(runtime, "perm-7", agentruntime.PermissionResolutionReject) diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 315cea44..637ad8e3 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -190,10 +190,7 @@ func (a App) renderPrompt(width int) string { // Account for frame and padding when sizing the composer container. boxWidth := a.composerBoxWidth(width) - if a.pendingPermission != nil { - return box.Width(boxWidth).Render(a.renderPermissionPrompt()) - } - return box.Width(boxWidth).Render(a.input.View()) + return box.Width(boxWidth).Render(a.renderPermissionPrompt()) } func (a App) renderSidebarHeader(width int) string { diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go index 95d6d70a..d4982d4d 100644 --- a/internal/tui/services/runtime_service.go +++ b/internal/tui/services/runtime_service.go @@ -2,12 +2,15 @@ package services import ( "context" + "time" tea "github.com/charmbracelet/bubbletea" agentruntime "neo-code/internal/runtime" ) +const permissionResolveTimeout = 10 * time.Second + // Runner 定义执行 runtime run 所需最小能力。 type Runner interface { Run(ctx context.Context, input agentruntime.UserInput) error @@ -69,7 +72,10 @@ func RunResolvePermissionCmd( doneMsg func(agentruntime.PermissionResolutionInput, error) tea.Msg, ) tea.Cmd { return func() tea.Msg { - err := runtime.ResolvePermission(context.Background(), input) + ctx, cancel := context.WithTimeout(context.Background(), permissionResolveTimeout) + defer cancel() + + err := runtime.ResolvePermission(ctx, input) return doneMsg(input, err) } } diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index ce882222..b5893084 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -34,12 +35,15 @@ func (s *stubCompactor) Compact(ctx context.Context, input agentruntime.CompactI } type stubPermissionResolver struct { - lastInput agentruntime.PermissionResolutionInput - err error + lastInput agentruntime.PermissionResolutionInput + err error + deadline time.Time + hasDeadline bool } func (s *stubPermissionResolver) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { s.lastInput = input + s.deadline, s.hasDeadline = ctx.Deadline() return s.err } @@ -144,6 +148,9 @@ func TestRunResolvePermissionCmd(t *testing.T) { if resolver.lastInput.RequestID != "perm-1" || resolver.lastInput.Decision != agentruntime.PermissionResolutionAllowSession { t.Fatalf("unexpected resolver input: %+v", resolver.lastInput) } + if !resolver.hasDeadline { + t.Fatalf("expected permission resolver context to carry a deadline") + } } func TestProviderCmds(t *testing.T) { From 825f2251ff31f00b63121803f3b21fa870f4e31c Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 03:42:56 +0000 Subject: [PATCH 42/54] fix(runtime): rebuild context after auto compact Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Yumiue <188874804+Yumiue@users.noreply.github.com> --- internal/runtime/runtime.go | 3 + internal/runtime/runtime_test.go | 102 ++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 882e52ae..ff751e93 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -255,6 +255,9 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeManual, false) if compactResult.Applied { s.resetSessionTokenTotals(&session) + // 自动 compact 成功后需要在同一轮重建上下文,避免继续沿用压缩前的请求内容。 + attempt-- + continue } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index c486b65b..cf4edb7d 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3315,8 +3315,8 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if len(compactRunner.calls) != 1 { t.Fatalf("expected auto compact to run once, got %d", len(compactRunner.calls)) } - if len(builder.builds) != 2 { - t.Fatalf("expected 2 build attempts, got %d", len(builder.builds)) + if len(builder.builds) != 3 { + t.Fatalf("expected 3 build attempts, got %d", len(builder.builds)) } if builder.builds[0].Metadata.SessionInputTokens != 100 { t.Fatalf("expected first build to see pre-compact tokens, got %d", builder.builds[0].Metadata.SessionInputTokens) @@ -3333,6 +3333,18 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if builder.builds[1].Metadata.SessionOutputTokens != 0 { t.Fatalf("expected second build to see reset output tokens, got %d", builder.builds[1].Metadata.SessionOutputTokens) } + if len(scripted.requests) != 2 { + t.Fatalf("expected 2 provider requests after tool follow-up, got %d", len(scripted.requests)) + } + if len(scripted.requests[0].Messages) != 2 { + t.Fatalf("expected rebuilt compacted context to be sent, got %+v", scripted.requests[0].Messages) + } + if scripted.requests[0].Messages[0].Content != "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue" { + t.Fatalf("expected first provider request to use compact summary, got %+v", scripted.requests[0].Messages) + } + if scripted.requests[0].Messages[1].Content != "latest answer" { + t.Fatalf("expected first provider request to use compacted latest answer, got %+v", scripted.requests[0].Messages) + } if service.sessionInputTokens != 0 { t.Fatalf("expected service input tokens to reset, got %d", service.sessionInputTokens) @@ -3509,6 +3521,92 @@ func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { } } +func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.MaxLoops = 1 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("reactive-single-loop") + session.ID = "session-reactive-single-loop" + session.TokenInputTotal = 160 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + if len(req.Messages) == 3 { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + } + } + select { + case events <- providertypes.NewTextDeltaStreamEvent("recovered within one loop"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", nil): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 80, + AfterChars: 30, + SavedRatio: 0.625, + TriggerMode: string(contextcompact.ModeReactive), + }, + }, + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-reactive-single-loop", + Content: "continue", + }); err != nil { + t.Fatalf("Run() with MaxLoops=1 should recover, got %v", err) + } + + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called twice within one loop budget, got %d", scripted.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactDone, + EventAgentDone, + }) + assertNoEventType(t, events, EventError) +} + func TestServiceRunReactiveCompactRetriesOnlyOnce(t *testing.T) { t.Parallel() From b3818648979e27140bc131bf5c8ba591e8aa6bec Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 03:50:59 +0000 Subject: [PATCH 43/54] fix(tui): address help modal and review regressions Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: minorcell <120795714+minorcell@users.noreply.github.com> --- internal/tui/core/app/copy_code.go | 56 ++++++++++++++--- internal/tui/core/app/update.go | 23 ++++--- internal/tui/core/app/update_test.go | 94 ++++++++++++++++++++++++++++ internal/tui/core/app/view.go | 41 ++++++++++-- internal/tui/core/app/view_test.go | 25 +++++++- 5 files changed, 219 insertions(+), 20 deletions(-) diff --git a/internal/tui/core/app/copy_code.go b/internal/tui/core/app/copy_code.go index 3f043d35..cc53b2e9 100644 --- a/internal/tui/core/app/copy_code.go +++ b/internal/tui/core/app/copy_code.go @@ -39,7 +39,7 @@ var ( regexp.MustCompile(`^[[:space:]]*(func|if|for|while|switch|case|return|class|def|const|let|var|import|export|package|struct|enum|interface|public|private|static|void|int|string|bool|nil|null|true|false)\b`), regexp.MustCompile(`=>|->|::`), regexp.MustCompile(`[})];?\s*$`), - regexp.MustCompile(`^\s*(//|#|/\*|\*)`), + regexp.MustCompile(`^\s*(//|/\*)`), regexp.MustCompile(`:=|=>`), regexp.MustCompile(`\([a-zA-Z_][a-zA-Z0-9_]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_]*)*\)\s*{?$`), } @@ -163,8 +163,9 @@ func splitIndentedCodeSegments(content string) []markdownSegment { codeFeatureCount = 0 } - for _, line := range lines { + for index, line := range lines { indented := isIndentedCodeLine(line) + startsCode := indented || shouldStartCodeBlock(lines, index) if inCode { if indented || hasCodeFeatures(line) { codeLines = append(codeLines, trimCodeIndent(line)) @@ -183,7 +184,7 @@ func splitIndentedCodeSegments(content string) []markdownSegment { inCode = false } - if indented || hasCodeFeatures(line) { + if startsCode { if !inCode { flushText() inCode = true @@ -234,10 +235,7 @@ func isFenceCloseLine(line string) bool { } func isIndentedCodeLine(line string) bool { - if strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") { - return true - } - return hasCodeFeatures(line) + return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") } func hasCodeFeatures(line string) bool { @@ -253,6 +251,50 @@ func hasCodeFeatures(line string) bool { return false } +// shouldStartCodeBlock 判断未围栏文本中的当前行是否足以开启代码段,避免普通 prose 被误判为代码。 +func shouldStartCodeBlock(lines []string, index int) bool { + line := lines[index] + if !hasCodeFeatures(line) { + return false + } + if hasStandaloneCodeShape(line) { + return true + } + + prev := nearestNonEmptyLine(lines, index, -1) + if prev >= 0 && (isIndentedCodeLine(lines[prev]) || hasCodeFeatures(lines[prev])) { + return true + } + + next := nearestNonEmptyLine(lines, index, 1) + return next >= 0 && (isIndentedCodeLine(lines[next]) || hasCodeFeatures(lines[next])) +} + +// hasStandaloneCodeShape 判断单行本身是否具备足够强的代码结构特征,可直接作为代码段起点。 +func hasStandaloneCodeShape(line string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + return strings.HasPrefix(trimmed, "//") || + strings.HasPrefix(trimmed, "/*") || + strings.ContainsAny(trimmed, "{}()[];") || + strings.Contains(trimmed, ":=") || + strings.Contains(trimmed, "=>") || + strings.Contains(trimmed, "->") || + strings.Contains(trimmed, "::") +} + +// nearestNonEmptyLine 查找当前位置前后最近的非空行,用于辅助判断代码段是否连续。 +func nearestNonEmptyLine(lines []string, index int, direction int) int { + for next := index + direction; next >= 0 && next < len(lines); next += direction { + if strings.TrimSpace(lines[next]) != "" { + return next + } + } + return -1 +} + func trimCodeIndent(line string) string { if strings.HasPrefix(line, "\t") { return strings.TrimPrefix(line, "\t") diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 43102a71..88e37d0d 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -67,6 +67,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.clearPendingPermissionState() a.clearRunProgress() a.state.IsCompacting = false if strings.TrimSpace(a.state.StatusText) == "" { @@ -77,6 +78,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if typed.Err != nil { a.state.IsAgentRunning = false a.state.ActiveRunID = "" + a.clearPendingPermissionState() a.clearRunProgress() a.state.StreamingReply = false a.state.CurrentTool = "" @@ -137,6 +139,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.pendingPermissionSubmitted = false if typed.Err != nil { + a.clearPendingPermissionState() a.state.ExecutionError = typed.Err.Error() a.state.StatusText = statusPermissionFailed a.appendActivity("permission", "Permission approval failed", typed.Err.Error(), true) @@ -151,9 +154,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StatusText = statusPermissionDenied } a.appendActivity("permission", "Permission resolved", decision, false) - a.pendingPermissionID = "" - a.pendingPermissionTool = "" - a.pendingPermissionHint = "" + a.clearPendingPermissionState() return a, tea.Batch(cmds...) case localCommandResultMsg: if typed.Err != nil { @@ -890,10 +891,7 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve if !ok { return false } - a.pendingPermissionID = "" - a.pendingPermissionTool = "" - a.pendingPermissionHint = "" - a.pendingPermissionSubmitted = false + a.clearPendingPermissionState() if strings.EqualFold(payload.Decision, "allow") { a.state.StatusText = statusPermissionApproved } else { @@ -995,6 +993,7 @@ func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.clearPendingPermissionState() a.clearRunProgress() if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady @@ -1012,6 +1011,7 @@ func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) boo a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.clearPendingPermissionState() a.state.ExecutionError = "" a.state.StatusText = statusCanceled a.clearRunProgress() @@ -1026,6 +1026,7 @@ func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.clearPendingPermissionState() a.clearRunProgress() if payload, ok := event.Payload.(string); ok { a.state.ExecutionError = payload @@ -1576,6 +1577,14 @@ func (a *App) clearRunProgress() { a.runProgressLabel = "" } +// clearPendingPermissionState 清理当前等待中的权限审批上下文,避免结束态继续拦截 y/a/n。 +func (a *App) clearPendingPermissionState() { + a.pendingPermissionID = "" + a.pendingPermissionTool = "" + a.pendingPermissionHint = "" + a.pendingPermissionSubmitted = false +} + func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { command, rest := splitFirstWord(strings.ToLower(strings.TrimSpace(input))) switch command { diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 35434320..32534f42 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -286,6 +286,9 @@ func TestUpdatePermissionResolveFlow(t *testing.T) { func TestUpdatePermissionResolvedError(t *testing.T) { app, _ := newTestApp(t) app.pendingPermissionID = "perm-4" + app.pendingPermissionTool = "bash" + app.pendingPermissionHint = "bash write file" + app.pendingPermissionSubmitted = true model, _ := app.Update(permissionResolvedMsg{ RequestID: "perm-4", @@ -297,6 +300,9 @@ func TestUpdatePermissionResolvedError(t *testing.T) { if app.state.StatusText != statusPermissionFailed { t.Fatalf("expected failure status, got %s", app.state.StatusText) } + if app.pendingPermissionID != "" || app.pendingPermissionTool != "" || app.pendingPermissionHint != "" || app.pendingPermissionSubmitted { + t.Fatalf("expected pending permission state to be cleared") + } } func TestRunPermissionResolveCommand(t *testing.T) { @@ -377,6 +383,64 @@ func TestUpdatePermissionRejectFlow(t *testing.T) { } } +func TestUpdateClearsPendingPermissionOnRuntimeClosed(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-close" + app.pendingPermissionTool = "bash" + app.pendingPermissionHint = "bash read file" + app.pendingPermissionSubmitted = true + + model, _ := app.Update(RuntimeClosedMsg{}) + app = model.(App) + + if app.pendingPermissionID != "" || app.pendingPermissionTool != "" || app.pendingPermissionHint != "" || app.pendingPermissionSubmitted { + t.Fatalf("expected pending permission state to be cleared") + } +} + +func TestUpdateClearsPendingPermissionOnRunFinishedError(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-run" + app.pendingPermissionTool = "webfetch" + app.pendingPermissionHint = "webfetch https://example.com" + app.pendingPermissionSubmitted = true + + model, _ := app.Update(runFinishedMsg{Err: context.Canceled}) + app = model.(App) + + if app.pendingPermissionID != "" || app.pendingPermissionTool != "" || app.pendingPermissionHint != "" || app.pendingPermissionSubmitted { + t.Fatalf("expected pending permission state to be cleared") + } +} + +func TestRuntimeEventRunCanceledClearsPendingPermission(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-cancel" + app.pendingPermissionTool = "bash" + app.pendingPermissionHint = "bash write file" + app.pendingPermissionSubmitted = true + + runtimeEventRunCanceledHandler(&app, agentruntime.RuntimeEvent{}) + + if app.pendingPermissionID != "" || app.pendingPermissionTool != "" || app.pendingPermissionHint != "" || app.pendingPermissionSubmitted { + t.Fatalf("expected pending permission state to be cleared") + } +} + +func TestRuntimeEventErrorClearsPendingPermission(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermissionID = "perm-error" + app.pendingPermissionTool = "bash" + app.pendingPermissionHint = "bash write file" + app.pendingPermissionSubmitted = true + + runtimeEventErrorHandler(&app, agentruntime.RuntimeEvent{Payload: "boom"}) + + if app.pendingPermissionID != "" || app.pendingPermissionTool != "" || app.pendingPermissionHint != "" || app.pendingPermissionSubmitted { + t.Fatalf("expected pending permission state to be cleared") + } +} + func TestRuntimeEventToolResultHandlerUpdatesMessages(t *testing.T) { app, _ := newTestApp(t) result := tools.ToolResult{ @@ -510,6 +574,36 @@ func TestSplitIndentedCodeSegmentsDetectsCodeFeaturesInCodeMode(t *testing.T) { } } +func TestSplitIndentedCodeSegmentsKeepsMarkdownHeadingAsText(t *testing.T) { + segments := splitIndentedCodeSegments("# Title\n\nBody") + if len(segments) != 1 { + t.Fatalf("expected one text segment, got %d", len(segments)) + } + if segments[0].Kind != markdownSegmentText { + t.Fatalf("expected heading content to remain text") + } +} + +func TestSplitIndentedCodeSegmentsKeepsMarkdownListAsText(t *testing.T) { + segments := splitIndentedCodeSegments("* item\n* next") + if len(segments) != 1 { + t.Fatalf("expected one text segment, got %d", len(segments)) + } + if segments[0].Kind != markdownSegmentText { + t.Fatalf("expected bullet list to remain text") + } +} + +func TestSplitIndentedCodeSegmentsKeepsSingleKeywordProseAsText(t *testing.T) { + segments := splitIndentedCodeSegments("if we need to retry later") + if len(segments) != 1 { + t.Fatalf("expected one text segment, got %d", len(segments)) + } + if segments[0].Kind != markdownSegmentText { + t.Fatalf("expected prose line to remain text") + } +} + func TestExtractFencedCodeBlocks(t *testing.T) { content := "text\n```go\nfmt.Println(\"ok\")\n```\nend" blocks := extractFencedCodeBlocks(content) diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index b41a0a78..f30b0da2 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -131,19 +131,18 @@ func (a App) renderSidebar(width int, height int) string { func (a App) renderWaterfall(width int, height int) string { if a.state.ActivePicker != pickerNone { + pickerWidth := tuiutils.Clamp(width-10, 36, max(36, a.activePickerWidth()+2)) + pickerHeight := tuiutils.Clamp(height-4, 10, max(10, a.activePickerHeight()+4)) return lipgloss.Place( width, height, lipgloss.Center, lipgloss.Center, - a.renderPicker(tuiutils.Clamp(width-10, 36, 56), tuiutils.Clamp(height-6, 10, 14)), + a.renderPicker(pickerWidth, pickerHeight), ) } - activityHeight := a.activityPreviewHeight() - menuHeight := a.commandMenuHeight(width) - promptHeight := lipgloss.Height(a.renderPrompt(width)) - transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) + transcriptHeight := max(6, a.transcript.Height) transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) @@ -160,6 +159,38 @@ func (a App) renderWaterfall(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } +// activePickerWidth 返回当前激活选择器的内容宽度,用于避免渲染时再次被固定宽度截断。 +func (a App) activePickerWidth() int { + switch a.state.ActivePicker { + case pickerProvider: + return a.providerPicker.Width() + case pickerModel: + return a.modelPicker.Width() + case pickerFile: + return 0 + case pickerHelp: + return a.helpPicker.Width() + default: + return 0 + } +} + +// activePickerHeight 返回当前激活选择器的内容高度,用于让 /help 保持单页可见。 +func (a App) activePickerHeight() int { + switch a.state.ActivePicker { + case pickerProvider: + return a.providerPicker.Height() + case pickerModel: + return a.modelPicker.Height() + case pickerFile: + return 0 + case pickerHelp: + return a.helpPicker.Height() + default: + return 0 + } +} + func (a App) renderPicker(width int, height int) string { frameHeight := a.styles.panelFocused.GetVerticalFrameSize() title := modelPickerTitle diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 42345994..64cdb742 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -19,15 +19,38 @@ func TestRenderPickerHelpMode(t *testing.T) { } } -func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { +func TestRenderWaterfallUsesLayoutTranscriptHeight(t *testing.T) { app, _ := newTestApp(t) app.state.ActivePicker = pickerNone app.state.InputText = "test" app.input.SetValue("test") app.transcript.SetContent("line1\nline2") + app.transcript.Height = 17 view := app.renderWaterfall(80, 24) if strings.TrimSpace(view) == "" { t.Fatalf("expected non-empty waterfall view") } } + +func TestRenderWaterfallUsesHelpPickerDynamicHeight(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + app.state.ActivePicker = pickerHelp + app.helpPicker.SetSize(40, 20) + + view := app.renderWaterfall(80, 30) + if !strings.Contains(view, helpPickerTitle) { + t.Fatalf("expected help picker title in waterfall") + } +} + +func TestActivePickerHeightHelpUsesConfiguredHeight(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerHelp + app.helpPicker.SetSize(30, 18) + + if got := app.activePickerHeight(); got != 18 { + t.Fatalf("expected help picker height 18, got %d", got) + } +} From 45294f5870e1d2c4e92ec3a8a95d29d80f821041 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 04:02:59 +0000 Subject: [PATCH 44/54] =?UTF-8?q?fix(runtime/tools):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=20EmitChunk=20=E9=81=97=E7=95=99=20review=20=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/runtime/permission.go | 3 -- internal/runtime/permission_test.go | 57 +++++++++++++++++++++ internal/runtime/runtime_test.go | 12 +++-- internal/tools/filesystem/read_file.go | 5 +- internal/tools/filesystem/read_file_test.go | 16 ++++-- internal/tools/types.go | 5 +- 6 files changed, 85 insertions(+), 13 deletions(-) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 484a75a0..c1fd70ed 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -105,9 +105,6 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi return err } s.emit(ctx, EventToolChunk, input.RunID, input.SessionID, string(chunk)) - if err := ctx.Err(); err != nil { - return err - } return nil }, } diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 7a960698..3dfab0cc 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -387,3 +387,60 @@ func TestExecuteToolCallWithPermissionReturnsContextCanceledFromEmitChunk(t *tes t.Fatalf("expected context.Canceled, got %v", execErr) } } + +func TestExecuteToolCallWithPermissionDoesNotRecheckContextAfterSuccessfulEmit(t *testing.T) { + t.Parallel() + + var cancel context.CancelFunc + registry := tools.NewRegistry() + registry.Register(&stubTool{ + name: "filesystem_read_file", + executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if input.EmitChunk == nil { + t.Fatalf("expected EmitChunk callback") + } + go cancel() + if err := input.EmitChunk([]byte("stream-chunk")); err != nil { + t.Fatalf("expected successful emit, got %v", err) + } + return tools.ToolResult{Name: input.Name, Content: "ok"}, nil + }, + }) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + + ctx, cancel := context.WithCancel(context.Background()) + service.events = make(chan RuntimeEvent) + + result, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: "run-successful-emit", + SessionID: "session-successful-emit", + Call: providertypes.ToolCall{ + ID: "call-successful-emit", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + ToolTimeout: time.Second, + }) + if execErr != nil { + t.Fatalf("expected nil error after successful emit, got %v", execErr) + } + if result.Content != "ok" { + t.Fatalf("expected successful tool result, got %+v", result) + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index cf4edb7d..0868ccdb 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -221,7 +221,9 @@ func (t *stubTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return t.executeFn(ctx, input) } if input.EmitChunk != nil { - _ = input.EmitChunk([]byte("chunk")) + if err := input.EmitChunk([]byte("chunk")); err != nil { + return tools.NewErrorResult(t.name, "emit failed", "", nil), err + } } return tools.ToolResult{ Name: t.name, @@ -1728,7 +1730,9 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { name: "filesystem_edit", executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if input.EmitChunk != nil { - _ = input.EmitChunk([]byte("chunk")) + if err := input.EmitChunk([]byte("chunk")); err != nil { + return tools.NewErrorResult(input.Name, "emit failed", "", nil), err + } } close(toolStarted) <-ctx.Done() @@ -1788,7 +1792,9 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { name: "filesystem_edit", executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if input.EmitChunk != nil { - _ = input.EmitChunk([]byte("chunk")) + if err := input.EmitChunk([]byte("chunk")); err != nil { + return tools.NewErrorResult(input.Name, "emit failed", "", nil), err + } } close(toolStarted) <-ctx.Done() diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index 1b6b3c45..f99de8ef 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -14,6 +15,8 @@ import ( const emitChunkSize = 4 * 1024 +var errReadFileEmitChunkFailed = errors.New(readFileToolName + ": emit chunk failed") + type ReadFileTool struct { root string } @@ -107,7 +110,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( end = len(content) } if emitErr := input.EmitChunk(content[start:end]); emitErr != nil { - err := errors.New(readFileToolName + ": emit chunk failed: " + emitErr.Error()) + err := fmt.Errorf("%w: %w", errReadFileEmitChunkFailed, emitErr) return tools.NewErrorResult( t.Name(), tools.NormalizeErrorReason(t.Name(), err), diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index 5834b6b9..6f228ede 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -196,6 +196,7 @@ func TestReadFileToolExecuteStopsOnEmitChunkError(t *testing.T) { args := mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}) emitCount := 0 + consumerErr := errors.New("consumer closed") result, err := tool.Execute(context.Background(), tools.ToolCallInput{ Name: tool.Name(), Arguments: args, @@ -203,14 +204,17 @@ func TestReadFileToolExecuteStopsOnEmitChunkError(t *testing.T) { EmitChunk: func(chunk []byte) error { emitCount++ if emitCount == 1 { - return errors.New("consumer closed") + return consumerErr } return nil }, }) - if err == nil || !strings.Contains(err.Error(), "emit chunk failed") { + if err == nil || !errors.Is(err, errReadFileEmitChunkFailed) { t.Fatalf("expected emit chunk failure, got %v", err) } + if !errors.Is(err, consumerErr) { + t.Fatalf("expected wrapped consumer error, got %v", err) + } if !result.IsError { t.Fatalf("expected error result, got %+v", result) } @@ -232,6 +236,7 @@ func TestReadFileToolExecuteEmitsProgressBeforeChunkFailure(t *testing.T) { args := mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}) emitCount := 0 + consumerErr := errors.New("consumer closed on second chunk") result, err := tool.Execute(context.Background(), tools.ToolCallInput{ Name: tool.Name(), Arguments: args, @@ -239,14 +244,17 @@ func TestReadFileToolExecuteEmitsProgressBeforeChunkFailure(t *testing.T) { EmitChunk: func(chunk []byte) error { emitCount++ if emitCount == 2 { - return errors.New("consumer closed on second chunk") + return consumerErr } return nil }, }) - if err == nil || !strings.Contains(err.Error(), "emit chunk failed") { + if err == nil || !errors.Is(err, errReadFileEmitChunkFailed) { t.Fatalf("expected emit chunk failure, got %v", err) } + if !errors.Is(err, consumerErr) { + t.Fatalf("expected wrapped consumer error, got %v", err) + } if !result.IsError { t.Fatalf("expected error result, got %+v", result) } diff --git a/internal/tools/types.go b/internal/tools/types.go index 020a67c6..462ec8d9 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -17,8 +17,9 @@ type Tool interface { // ChunkEmitter 是工具执行过程中向上游发送流式分片的回调。 // 并发语义: -// - 回调可能在一次执行内被调用 0 次或多次; -// - 回调在工具执行 goroutine 中调用; +// - 单次 Execute 内允许调用 0 次或多次; +// - 同一次 Execute 内默认要求串行调用,工具实现不应并发调用同一个 emitter; +// - 若工具确需跨 goroutine 使用,必须自行保证顺序、同步与上游消费方的并发安全契约; // - 调用方若返回非 nil error,工具应停止后续分片发送并尽快中止执行。 // 内存语义: // - 回调返回后不得继续持有传入的 chunk 引用,若需异步使用必须先复制。 From a4a27494ed89f507daf4af1eb9293817fbecd5e3 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 05:02:27 +0000 Subject: [PATCH 45/54] fix(runtime): resolve unresolved EmitChunk reviews Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: Cai-Tang-www <106404101+Cai-Tang-www@users.noreply.github.com> --- internal/runtime/permission.go | 3 +- internal/runtime/permission_test.go | 80 ++++++++++++++++++++++++++++- internal/runtime/runtime.go | 10 ++-- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index c1fd70ed..00a0fb76 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -104,8 +104,7 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi if err := ctx.Err(); err != nil { return err } - s.emit(ctx, EventToolChunk, input.RunID, input.SessionID, string(chunk)) - return nil + return s.emit(ctx, EventToolChunk, input.RunID, input.SessionID, string(chunk)) }, } diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index 3dfab0cc..86513069 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -3,6 +3,7 @@ package runtime import ( "context" "errors" + "sync" "testing" "time" @@ -388,6 +389,20 @@ func TestExecuteToolCallWithPermissionReturnsContextCanceledFromEmitChunk(t *tes } } +type doneSignalContext struct { + context.Context + doneCalled chan struct{} + once sync.Once +} + +// Done 在 runtime.emit 进入阻塞发送分支时发出信号,便于测试精确控制取消时机。 +func (c *doneSignalContext) Done() <-chan struct{} { + c.once.Do(func() { + close(c.doneCalled) + }) + return c.Context.Done() +} + func TestExecuteToolCallWithPermissionDoesNotRecheckContextAfterSuccessfulEmit(t *testing.T) { t.Parallel() @@ -399,10 +414,10 @@ func TestExecuteToolCallWithPermissionDoesNotRecheckContextAfterSuccessfulEmit(t if input.EmitChunk == nil { t.Fatalf("expected EmitChunk callback") } - go cancel() if err := input.EmitChunk([]byte("stream-chunk")); err != nil { t.Fatalf("expected successful emit, got %v", err) } + cancel() return tools.ToolResult{Name: input.Name, Content: "ok"}, nil }, }) @@ -425,7 +440,7 @@ func TestExecuteToolCallWithPermissionDoesNotRecheckContextAfterSuccessfulEmit(t ) ctx, cancel := context.WithCancel(context.Background()) - service.events = make(chan RuntimeEvent) + service.events = make(chan RuntimeEvent, 1) result, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ RunID: "run-successful-emit", @@ -444,3 +459,64 @@ func TestExecuteToolCallWithPermissionDoesNotRecheckContextAfterSuccessfulEmit(t t.Fatalf("expected successful tool result, got %+v", result) } } + +func TestExecuteToolCallWithPermissionReturnsContextCanceledWhenChunkNotDelivered(t *testing.T) { + t.Parallel() + + registry := tools.NewRegistry() + registry.Register(&stubTool{ + name: "filesystem_read_file", + executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if input.EmitChunk == nil { + t.Fatalf("expected EmitChunk callback") + } + if err := input.EmitChunk([]byte("stream-chunk")); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled from emitter, got %v", err) + } + return tools.NewErrorResult(input.Name, "emit failed", "", nil), context.Canceled + }, + }) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + service.events = make(chan RuntimeEvent, 1) + service.events <- RuntimeEvent{Type: EventAgentChunk} + + baseCtx, cancel := context.WithCancel(context.Background()) + ctx := &doneSignalContext{ + Context: baseCtx, + doneCalled: make(chan struct{}), + } + go func() { + <-ctx.doneCalled + cancel() + }() + + _, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: "run-canceled-blocked", + SessionID: "session-canceled-blocked", + Call: providertypes.ToolCall{ + ID: "call-canceled-blocked", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + ToolTimeout: time.Second, + }) + if !errors.Is(execErr, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", execErr) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index ff751e93..4e5a79d2 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -491,10 +491,10 @@ func (s *Service) loadOrCreateSession( return session, nil } -// emit 向事件通道发送事件。 +// emit 向事件通道发送事件,并在通道阻塞且上下文取消时返回对应错误。 // 先尝试非阻塞发送,确保即使 context 已取消,只要通道有空间事件就能被投递; -// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏。 -func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) { +// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏并向调用方反馈未投递状态。 +func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) error { evt := RuntimeEvent{ Type: kind, RunID: runID, @@ -503,12 +503,14 @@ func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessio } select { case s.events <- evt: - return + return nil default: } select { case s.events <- evt: + return nil case <-ctx.Done(): + return ctx.Err() } } From 8b446723dba8df4a5ab5c1404cb833bcd632ecd9 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 05:48:37 +0000 Subject: [PATCH 46/54] fix(tui): resolve pr-208 conflicts with main Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: minorcell <120795714+minorcell@users.noreply.github.com> --- internal/tui/core/app/app.go | 3 + internal/tui/core/app/commands.go | 49 ++--- internal/tui/core/app/permission_prompt.go | 174 ++++++++++++++++++ .../tui/core/app/permission_prompt_test.go | 174 ++++++++++++++++++ internal/tui/core/app/update.go | 92 ++++++++- internal/tui/core/app/view.go | 3 +- internal/tui/services/runtime_service.go | 25 ++- internal/tui/services/services_test.go | 25 +++ internal/tui/state/messages.go | 7 + 9 files changed, 523 insertions(+), 29 deletions(-) create mode 100644 internal/tui/core/app/permission_prompt.go create mode 100644 internal/tui/core/app/permission_prompt_test.go diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 3299a2de..b3f3fff8 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -45,6 +45,7 @@ type RuntimeClosedMsg = tuistate.RuntimeClosedMsg type runFinishedMsg = tuistate.RunFinishedMsg type modelCatalogRefreshMsg = tuistate.ModelCatalogRefreshMsg type compactFinishedMsg = tuistate.CompactFinishedMsg +type permissionResolutionFinishedMsg = tuistate.PermissionResolutionFinishedMsg type permissionResolvedMsg = tuistate.PermissionResolvedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg @@ -88,6 +89,7 @@ type appComponents struct { type appRuntimeState struct { codeCopyBlocks map[int]string pendingCopyID int + deferredEventCmd tea.Cmd nowFn func() time.Time lastInputEditAt time.Time lastPasteLikeAt time.Time @@ -102,6 +104,7 @@ type appRuntimeState struct { runProgressValue float64 runProgressKnown bool runProgressLabel string + pendingPermission *permissionPromptState pendingPermissionID string pendingPermissionTool string pendingPermissionHint string diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 832d5aeb..2d053637 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -54,29 +54,32 @@ const ( emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." emptyMessageText = "(empty)" - 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" - statusApplyingCommand = "Applying local command" - statusRunningCommand = "Running command" - statusCommandDone = "Command finished" - statusCompacting = "Compacting context" - statusChooseProvider = "Choose a provider" - statusChooseModel = "Choose a model" - statusChooseHelp = "Choose a slash command" - statusBrowseFile = "Browse workspace files" - statusAwaitingPermission = "Awaiting permission (y=once, a=session, n=deny)" - statusPermissionApproved = "Permission approved" - statusPermissionDenied = "Permission denied" - statusPermissionFailed = "Permission approval failed" + 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" + statusApplyingCommand = "Applying local command" + statusRunningCommand = "Running command" + statusCommandDone = "Command finished" + statusCompacting = "Compacting context" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" + statusChooseHelp = "Choose a slash command" + statusBrowseFile = "Browse workspace files" + statusPermissionRequired = "Permission required: choose a decision and press Enter" + statusPermissionSubmitting = "Submitting permission decision" + statusPermissionSubmitted = "Permission decision submitted" + statusAwaitingPermission = "Awaiting permission (y=once, a=session, n=deny)" + statusPermissionApproved = "Permission approved" + statusPermissionDenied = "Permission denied" + statusPermissionFailed = "Permission approval failed" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" diff --git a/internal/tui/core/app/permission_prompt.go b/internal/tui/core/app/permission_prompt.go new file mode 100644 index 00000000..0bf31392 --- /dev/null +++ b/internal/tui/core/app/permission_prompt.go @@ -0,0 +1,174 @@ +package tui + +import ( + "fmt" + "strings" + "unicode" + + "github.com/charmbracelet/lipgloss" + + agentruntime "neo-code/internal/runtime" +) + +// permissionPromptOption 表示权限审批面板中的一个可选项。 +type permissionPromptOption struct { + Label string + Hint string + Decision agentruntime.PermissionResolutionDecision +} + +var permissionPromptOptions = []permissionPromptOption{ + { + Label: "Allow once", + Hint: "Approve this request once", + Decision: agentruntime.PermissionResolutionAllowOnce, + }, + { + Label: "Allow session", + Hint: "Approve similar requests for this session", + Decision: agentruntime.PermissionResolutionAllowSession, + }, + { + Label: "Reject", + Hint: "Reject this request", + Decision: agentruntime.PermissionResolutionReject, + }, +} + +// permissionPromptState 保存当前待审批请求与选项状态。 +type permissionPromptState struct { + Request agentruntime.PermissionRequestPayload + Selected int + Submitting bool +} + +// normalizePermissionPromptSelection 保证选项下标始终落在有效范围。 +func normalizePermissionPromptSelection(selected int) int { + if len(permissionPromptOptions) == 0 { + return 0 + } + if selected < 0 { + return len(permissionPromptOptions) - 1 + } + if selected >= len(permissionPromptOptions) { + return 0 + } + return selected +} + +// permissionPromptOptionAt 返回指定下标对应的审批选项。 +func permissionPromptOptionAt(selected int) permissionPromptOption { + index := normalizePermissionPromptSelection(selected) + return permissionPromptOptions[index] +} + +// parsePermissionShortcut 将快捷输入映射为审批决策。 +func parsePermissionShortcut(input string) (agentruntime.PermissionResolutionDecision, bool) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "y", "yes", "once": + return agentruntime.PermissionResolutionAllowOnce, true + case "a", "always": + return agentruntime.PermissionResolutionAllowSession, true + case "n", "no", "reject", "deny": + return agentruntime.PermissionResolutionReject, true + default: + return "", false + } +} + +// formatPermissionPromptLines 构造权限审批面板展示文本。 +func formatPermissionPromptLines(state permissionPromptState) []string { + normalizedIdx := normalizePermissionPromptSelection(state.Selected) + lines := []string{ + fmt.Sprintf( + "Permission request: %s (%s)", + fallbackText(sanitizePermissionDisplayText(state.Request.ToolName), "unknown_tool"), + fallbackText(sanitizePermissionDisplayText(state.Request.Operation), "unknown"), + ), + fmt.Sprintf("Target: %s", fallbackText(sanitizePermissionDisplayText(state.Request.Target), "(empty)")), + "Use Up/Down to choose, Enter to confirm (shortcuts: y=once, a=session, n=reject)", + } + + for index, item := range permissionPromptOptions { + prefix := " " + if normalizedIdx == index { + prefix = "> " + } + lines = append(lines, fmt.Sprintf("%s%s - %s", prefix, item.Label, item.Hint)) + } + + if state.Submitting { + lines = append(lines, "Submitting permission decision...") + } + return lines +} + +// fallbackText 返回去空格后的值;为空时返回默认文案。 +func fallbackText(value string, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fallback + } + return trimmed +} + +// sanitizePermissionDisplayText 清理模型可控的终端展示文本,避免控制字符污染审批界面。 +func sanitizePermissionDisplayText(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + + var builder strings.Builder + lastWasSpace := false + for _, r := range value { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + if !lastWasSpace { + builder.WriteByte(' ') + lastWasSpace = true + } + continue + } + builder.WriteRune(r) + lastWasSpace = unicode.IsSpace(r) + } + + return strings.TrimSpace(builder.String()) +} + +// parsePermissionRequestPayload 解析权限请求事件载荷。 +func parsePermissionRequestPayload(payload any) (agentruntime.PermissionRequestPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionRequestPayload: + return typed, true + case *agentruntime.PermissionRequestPayload: + if typed == nil { + return agentruntime.PermissionRequestPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionRequestPayload{}, false + } +} + +// parsePermissionResolvedPayload 解析权限决议事件载荷。 +func parsePermissionResolvedPayload(payload any) (agentruntime.PermissionResolvedPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionResolvedPayload: + return typed, true + case *agentruntime.PermissionResolvedPayload: + if typed == nil { + return agentruntime.PermissionResolvedPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionResolvedPayload{}, false + } +} + +// renderPermissionPrompt 渲染审批输入框内容,替代普通输入框文本编辑状态。 +func (a App) renderPermissionPrompt() string { + if a.pendingPermission == nil { + return a.input.View() + } + return lipgloss.JoinVertical(lipgloss.Left, formatPermissionPromptLines(*a.pendingPermission)...) +} diff --git a/internal/tui/core/app/permission_prompt_test.go b/internal/tui/core/app/permission_prompt_test.go new file mode 100644 index 00000000..89500265 --- /dev/null +++ b/internal/tui/core/app/permission_prompt_test.go @@ -0,0 +1,174 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + tuistate "neo-code/internal/tui/state" +) + +type permissionPromptRuntime struct { + lastResolved agentruntime.PermissionResolutionInput +} + +func (r *permissionPromptRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *permissionPromptRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *permissionPromptRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + r.lastResolved = input + return nil +} + +func (r *permissionPromptRuntime) CancelActiveRun() bool { + return false +} + +func (r *permissionPromptRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *permissionPromptRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r *permissionPromptRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r *permissionPromptRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func newPermissionPromptApp(runtime agentruntime.Runtime) *App { + input := textarea.New() + spin := spinner.New() + sessionList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + uiStyles := newStyles() + return &App{ + state: tuistate.UIState{Focus: panelInput}, + appServices: appServices{ + runtime: runtime, + }, + appComponents: appComponents{ + keys: newKeyMap(), + spinner: spin, + sessions: sessionList, + commandMenu: newCommandMenuModel(uiStyles), + providerPicker: newSelectionPickerItems(nil), + modelPicker: newSelectionPickerItems(nil), + helpPicker: newHelpPickerItems(nil), + input: input, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + }, + appRuntimeState: appRuntimeState{ + nowFn: time.Now, + codeCopyBlocks: map[int]string{}, + focus: panelInput, + }, + width: 120, + height: 40, + styles: uiStyles, + } +} + +func TestUpdatePendingPermissionInputSelectAndSubmit(t *testing.T) { + runtime := &permissionPromptRuntime{} + app := newPermissionPromptApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-1"}, + Selected: 0, + } + app.pendingPermissionID = "perm-1" + + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyDown}) + if !handled || cmd != nil { + t.Fatalf("expected handled down key without cmd, handled=%v cmd=%v", handled, cmd) + } + if app.pendingPermission.Selected != 1 { + t.Fatalf("expected selection moved to 1, got %d", app.pendingPermission.Selected) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyEnter}) + if !handled || cmd == nil { + t.Fatalf("expected enter key to submit permission decision, handled=%v cmd=%v", handled, cmd) + } + + msg := cmd() + done, ok := msg.(permissionResolvedMsg) + if !ok { + t.Fatalf("expected permissionResolvedMsg, got %T", msg) + } + if done.RequestID != "perm-1" || done.Decision != string(agentruntime.PermissionResolutionAllowSession) { + t.Fatalf("unexpected submitted decision: %+v", done) + } + if runtime.lastResolved.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("runtime decision mismatch: %+v", runtime.lastResolved) + } +} + +func TestRenderPermissionPromptUsesPanelContent(t *testing.T) { + app := newPermissionPromptApp(&permissionPromptRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + ToolName: "bash", + Operation: "write", + Target: "file.txt", + }, + } + rendered := app.renderPermissionPrompt() + if rendered == "" { + t.Fatalf("expected rendered prompt") + } + if !containsAll(rendered, []string{"Permission request:", "Allow once", "Reject"}) { + t.Fatalf("unexpected rendered prompt: %q", rendered) + } +} + +func TestRuntimeEventPermissionRequestCreatesPromptState(t *testing.T) { + app := newPermissionPromptApp(&permissionPromptRuntime{}) + event := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-2", + ToolName: "webfetch", + Target: "https://example.com", + }, + } + + if dirty := runtimeEventPermissionRequestHandler(app, event); dirty { + t.Fatalf("permission request should not mark transcript dirty") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-2" { + t.Fatalf("expected pending permission prompt state to be recorded") + } + if app.pendingPermissionTool != "webfetch" { + t.Fatalf("expected mirrored tool name to be recorded") + } +} + +func containsAll(value string, parts []string) bool { + for _, part := range parts { + if !strings.Contains(value, part) { + return false + } + } + return true +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 88e37d0d..eb7a7f78 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -55,6 +55,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeMsg: transcriptDirty := a.handleRuntimeEvent(typed.Event) + if a.deferredEventCmd != nil { + cmds = append(cmds, a.deferredEventCmd) + a.deferredEventCmd = nil + } _ = a.refreshSessions() a.syncActiveSessionTitle() if transcriptDirty { @@ -341,6 +345,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te now := a.now() effectiveTyped := typed + if a.pendingPermission != nil { + if cmd, handled := a.updatePendingPermissionInput(typed); handled { + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + } + if key.Matches(typed, a.keys.Send) { if a.shouldTreatEnterAsNewline(typed, now) { a.growComposerForNewline() @@ -463,6 +476,55 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } +// updatePendingPermissionInput 处理权限审批面板上的键盘交互(上下选择与回车确认)。 +func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { + if a.pendingPermission == nil { + return nil, false + } + if a.pendingPermission.Submitting { + return nil, true + } + + switch { + case key.Matches(typed, a.keys.ScrollUp): + a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected - 1) + a.state.StatusText = statusPermissionRequired + return nil, true + case key.Matches(typed, a.keys.ScrollDown): + a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected + 1) + a.state.StatusText = statusPermissionRequired + return nil, true + case key.Matches(typed, a.keys.Send): + option := permissionPromptOptionAt(a.pendingPermission.Selected) + return a.submitPermissionDecision(option.Decision), true + } + + if typed.Type == tea.KeyRunes && len(typed.Runes) > 0 { + if decision, ok := parsePermissionShortcut(string(typed.Runes)); ok { + return a.submitPermissionDecision(decision), true + } + } + return nil, true +} + +// submitPermissionDecision 触发一次权限审批提交命令。 +func (a *App) submitPermissionDecision(decision agentruntime.PermissionResolutionDecision) tea.Cmd { + if a.pendingPermission == nil { + return nil + } + + requestID := strings.TrimSpace(a.pendingPermission.Request.RequestID) + if requestID == "" { + return nil + } + + a.pendingPermission.Submitting = true + a.pendingPermissionSubmitted = true + a.state.StatusText = statusPermissionSubmitting + a.appendActivity("permission", "Submitting permission decision", string(decision), false) + return runPermissionResolve(a.runtime, requestID, decision) +} + func (a App) now() time.Time { if a.nowFn == nil { return time.Now() @@ -871,26 +933,47 @@ func runtimeEventUsageHandler(a *App, event agentruntime.RuntimeEvent) bool { // runtimeEventPermissionRequestHandler 处理权限审批请求并提示用户输入。 func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { - payload, ok := event.Payload.(agentruntime.PermissionRequestPayload) + payload, ok := parsePermissionRequestPayload(event.Payload) if !ok { return false } + if a.pendingPermission != nil { + currentRequestID := strings.TrimSpace(a.pendingPermission.Request.RequestID) + nextRequestID := strings.TrimSpace(payload.RequestID) + if currentRequestID != "" && currentRequestID != nextRequestID && !a.pendingPermission.Submitting { + a.deferredEventCmd = runPermissionResolve(a.runtime, currentRequestID, agentruntime.PermissionResolutionReject) + a.appendActivity("permission", "Auto-rejected superseded permission request", currentRequestID, false) + } + } + a.pendingPermission = &permissionPromptState{ + Request: payload, + Selected: 0, + Submitting: false, + } a.pendingPermissionID = strings.TrimSpace(payload.RequestID) a.pendingPermissionTool = strings.TrimSpace(payload.ToolName) a.pendingPermissionHint = formatPermissionPrompt(payload) a.pendingPermissionSubmitted = false + a.focus = panelInput + a.applyFocus() a.state.ExecutionError = "" a.state.StatusText = statusAwaitingPermission a.appendActivity("permission", "Permission required", a.pendingPermissionHint, false) + if a.width > 0 && a.height > 0 { + a.applyComponentLayout(false) + } return false } // runtimeEventPermissionResolvedHandler 处理权限审批结果并更新状态。 func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEvent) bool { - payload, ok := event.Payload.(agentruntime.PermissionResolvedPayload) + payload, ok := parsePermissionResolvedPayload(event.Payload) if !ok { return false } + if a.pendingPermission != nil && strings.TrimSpace(a.pendingPermission.Request.RequestID) == strings.TrimSpace(payload.RequestID) { + a.pendingPermission = nil + } a.clearPendingPermissionState() if strings.EqualFold(payload.Decision, "allow") { a.state.StatusText = statusPermissionApproved @@ -898,6 +981,9 @@ func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEve a.state.StatusText = statusPermissionDenied } a.appendActivity("permission", "Permission resolved", fmt.Sprintf("%s %s", payload.Decision, payload.ToolName), false) + if a.width > 0 && a.height > 0 { + a.applyComponentLayout(false) + } return false } @@ -1579,6 +1665,7 @@ func (a *App) clearRunProgress() { // clearPendingPermissionState 清理当前等待中的权限审批上下文,避免结束态继续拦截 y/a/n。 func (a *App) clearPendingPermissionState() { + a.pendingPermission = nil a.pendingPermissionID = "" a.pendingPermissionTool = "" a.pendingPermissionHint = "" @@ -1696,6 +1783,7 @@ func (a *App) startDraftSession() { a.state.ToolStates = nil a.state.RunContext = tuistate.ContextWindowState{} a.state.TokenUsage = tuistate.TokenUsageState{} + a.clearPendingPermissionState() a.clearRunProgress() a.input.Reset() a.state.InputText = "" diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index f30b0da2..f782948c 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -232,8 +232,7 @@ func (a App) renderPrompt(width int) string { // Account for frame and padding when sizing the composer container. boxWidth := a.composerBoxWidth(width) - - return box.Width(boxWidth).Render(a.input.View()) + return box.Width(boxWidth).Render(a.renderPermissionPrompt()) } func (a App) renderSidebarHeader(width int) string { diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go index 48c93eda..573581b9 100644 --- a/internal/tui/services/runtime_service.go +++ b/internal/tui/services/runtime_service.go @@ -2,12 +2,15 @@ package services import ( "context" + "time" tea "github.com/charmbracelet/bubbletea" agentruntime "neo-code/internal/runtime" ) +const permissionResolveTimeout = 10 * time.Second + // Runner 定义执行 runtime run 所需最小能力。 type Runner interface { Run(ctx context.Context, input agentruntime.UserInput) error @@ -67,9 +70,27 @@ func RunPermissionResolveCmd( runtime PermissionResolver, input agentruntime.PermissionResolutionInput, doneMsg func(error) tea.Msg, +) tea.Cmd { + return RunResolvePermissionCmd( + runtime, + input, + func(_ agentruntime.PermissionResolutionInput, err error) tea.Msg { + return doneMsg(err) + }, + ) +} + +// RunResolvePermissionCmd 提交权限审批决定,并将结果映射为 UI 消息。 +func RunResolvePermissionCmd( + runtime PermissionResolver, + input agentruntime.PermissionResolutionInput, + doneMsg func(agentruntime.PermissionResolutionInput, error) tea.Msg, ) tea.Cmd { return func() tea.Msg { - err := runtime.ResolvePermission(context.Background(), input) - return doneMsg(err) + ctx, cancel := context.WithTimeout(context.Background(), permissionResolveTimeout) + defer cancel() + + err := runtime.ResolvePermission(ctx, input) + return doneMsg(input, err) } } diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index 2f80cb91..6406fd4b 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -126,6 +126,31 @@ func TestRunPermissionResolveCmd(t *testing.T) { } } +func TestRunResolvePermissionCmd(t *testing.T) { + resolver := &stubPermissionResolver{} + input := agentruntime.PermissionResolutionInput{ + RequestID: "perm-2", + Decision: agentruntime.PermissionResolutionReject, + } + msg := RunResolvePermissionCmd( + resolver, + input, + func(got agentruntime.PermissionResolutionInput, err error) tea.Msg { + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + return got + }, + )() + resolved, ok := msg.(agentruntime.PermissionResolutionInput) + if !ok { + t.Fatalf("expected permission resolution input, got %T", msg) + } + if resolved.RequestID != "perm-2" || resolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected resolved input: %+v", resolved) + } +} + func TestProviderCmds(t *testing.T) { svc := &stubProvider{ selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 41184721..c9580539 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -30,6 +30,13 @@ type CompactFinishedMsg struct { Err error } +// PermissionResolutionFinishedMsg 表示一次权限审批提交完成结果。 +type PermissionResolutionFinishedMsg struct { + RequestID string + Decision agentruntime.PermissionResolutionDecision + Err error +} + // PermissionResolvedMsg 表示权限审批结果已回传。 type PermissionResolvedMsg struct { RequestID string From 5736244331e343148987f0b6ef83c28b536bd0d2 Mon Sep 17 00:00:00 2001 From: creatang Date: Wed, 8 Apr 2026 21:09:47 +0800 Subject: [PATCH 47/54] =?UTF-8?q?fix(test):=E8=A1=A5=E5=85=85=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/bootstrap/builder_test.go | 132 +++ internal/tui/core/app/command_menu_test.go | 133 +++ internal/tui/core/app/commands_test.go | 154 +++ internal/tui/core/app/update_test.go | 1086 ++++++++++++++++++++ internal/tui/state/messages.go | 7 + 5 files changed, 1512 insertions(+) create mode 100644 internal/tui/core/app/update_test.go diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..4ed2e903 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "errors" "testing" "neo-code/internal/config" @@ -164,3 +165,134 @@ func TestNormalizeMode(t *testing.T) { }) } } + +type errorFactory struct { + runtimeErr error + providerErr error + runtimeNil bool + providerNil bool +} + +func (f errorFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + if f.runtimeErr != nil { + return nil, f.runtimeErr + } + if f.runtimeNil { + return nil, nil + } + return current, nil +} + +func (f errorFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + if f.providerErr != nil { + return nil, f.providerErr + } + if f.providerNil { + return nil, nil + } + return current, nil +} + +type noopRuntime struct{} + +func (r noopRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r noopRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r noopRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r noopRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r noopRuntime) CancelActiveRun() bool { + return false +} + +func (r noopRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r noopRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r noopRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +type noopProviderService struct{} + +func (s noopProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s noopProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s noopProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s noopProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s noopProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func TestBuildFactoryErrors(t *testing.T) { + manager := &config.Manager{} + runtimeSvc := noopRuntime{} + providerSvc := noopProviderService{} + + _, err := Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{runtimeErr: errors.New("runtime boom")}, + }) + if err == nil { + t.Fatalf("expected runtime factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{providerErr: errors.New("provider boom")}, + }) + if err == nil { + t.Fatalf("expected provider factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{runtimeNil: true}, + }) + if err == nil { + t.Fatalf("expected nil runtime factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{providerNil: true}, + }) + if err == nil { + t.Fatalf("expected nil provider factory error") + } +} diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go index fa1ea274..1aae7bac 100644 --- a/internal/tui/core/app/command_menu_test.go +++ b/internal/tui/core/app/command_menu_test.go @@ -1,8 +1,11 @@ package tui import ( + "path/filepath" "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestCommandMenuItem(t *testing.T) { @@ -80,3 +83,133 @@ func TestCommandMenuView(t *testing.T) { t.Error("View() returned empty string") } } + +func TestBuildCommandMenuItemsForWorkspaceCommand(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentWorkdir = "/workspace/root" + + items, meta := app.buildCommandMenuItems("&", 80) + if meta.Title != shellMenuTitle { + t.Fatalf("expected shell menu title, got %q", meta.Title) + } + if len(items) != 1 { + t.Fatalf("expected one item, got %d", len(items)) + } + if !items[0].useReplaceRange || items[0].replacement != workspaceCommandPrefix+" " { + t.Fatalf("expected workspace replace range") + } +} + +func TestBuildCommandMenuItemsForSlashCommands(t *testing.T) { + app, _ := newTestApp(t) + + items, meta := app.buildCommandMenuItems("/he", 80) + if meta.Title != commandMenuTitle { + t.Fatalf("expected command menu title, got %q", meta.Title) + } + if len(items) == 0 { + t.Fatalf("expected slash command suggestions") + } + found := false + for _, item := range items { + if item.replacement == slashUsageHelp { + found = true + } + } + if !found { + t.Fatalf("expected help suggestion to appear") + } +} + +func TestFileMenuSuggestionsEmptyQueryIncludesBrowse(t *testing.T) { + app, _ := newTestApp(t) + app.fileCandidates = []string{"README.md", "docs/guide.md"} + + items := app.fileMenuSuggestions("@") + if len(items) == 0 || !items[0].openFileBrowser { + t.Fatalf("expected browse file entry") + } +} + +func TestFileMenuSuggestionsMatchesQuery(t *testing.T) { + app, _ := newTestApp(t) + app.fileCandidates = []string{"README.md", "docs/guide.md"} + + items := app.fileMenuSuggestions("@read") + if len(items) == 0 { + t.Fatalf("expected file suggestions") + } + if items[0].replacement == "" { + t.Fatalf("expected replacement to be set") + } +} + +func TestApplySelectedCommandSuggestionReplacesInput(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/he") + app.state.InputText = "/he" + app.transcript.Width = 80 + app.refreshCommandMenu() + + if !app.commandMenuHasSuggestions() { + t.Fatalf("expected suggestions") + } + if !app.applySelectedCommandSuggestion() { + t.Fatalf("expected suggestion to apply") + } + if app.input.Value() == "/he" { + t.Fatalf("expected input to change") + } +} + +func TestApplySelectedCommandSuggestionOpenFileBrowser(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentWorkdir = t.TempDir() + app.fileCandidates = []string{"README.md"} + app.input.SetValue("@") + app.transcript.Width = 80 + app.refreshCommandMenu() + + if !app.commandMenuHasSuggestions() { + t.Fatalf("expected suggestions") + } + applied := app.applySelectedCommandSuggestion() + if !applied { + t.Fatalf("expected browse action to apply") + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker to open") + } +} + +func TestUpdateCommandMenuSelectionHandlesNavigationKeys(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/he") + app.transcript.Width = 80 + app.refreshCommandMenu() + + _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyDown}) + if !handled { + t.Fatalf("expected navigation key to be handled") + } + _, handled = app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if handled { + t.Fatalf("expected non-navigation key to be ignored") + } +} + +func TestOpenFileBrowserUsesAbsoluteWorkdir(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + app.state.CurrentWorkdir = root + + app.openFileBrowser() + + expected, _ := filepath.Abs(root) + if app.fileBrowser.CurrentDirectory != expected { + t.Fatalf("expected absolute directory, got %q", app.fileBrowser.CurrentDirectory) + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker to be active") + } +} diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index cef5e8b4..ead1adb4 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -1,9 +1,15 @@ package tui import ( + "context" + "errors" + "strings" "testing" "github.com/charmbracelet/bubbles/list" + + "neo-code/internal/config" + tuistatus "neo-code/internal/tui/core/status" ) func TestBuiltinSlashCommands(t *testing.T) { @@ -142,3 +148,151 @@ func TestMaxActivityEntries(t *testing.T) { t.Error("maxActivityEntries should not be zero") } } + +type errorProviderService struct { + err error +} + +func (s errorProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, s.err +} + +func (s errorProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, s.err +} + +func (s errorProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, s.err +} + +func TestExecuteLocalCommandErrors(t *testing.T) { + app, _ := newTestApp(t) + snapshot := app.currentStatusSnapshot() + + if _, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, ""); err == nil { + t.Fatalf("expected empty command error") + } + if _, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/unknown"); err == nil { + t.Fatalf("expected unknown command error") + } +} + +func TestExecuteLocalCommandHelpAndStatus(t *testing.T) { + app, _ := newTestApp(t) + snapshot := app.currentStatusSnapshot() + + helpText, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/help") + if err != nil { + t.Fatalf("executeLocalCommand(/help) error = %v", err) + } + if !strings.Contains(helpText, "Available slash commands:") { + t.Fatalf("expected help output, got %q", helpText) + } + + statusText, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/status") + if err != nil { + t.Fatalf("executeLocalCommand(/status) error = %v", err) + } + if !strings.Contains(statusText, "Status:") { + t.Fatalf("expected status output, got %q", statusText) + } +} + +func TestExecuteProviderCommandValidation(t *testing.T) { + app, _ := newTestApp(t) + if _, err := executeProviderCommand(context.Background(), app.providerSvc, ""); err == nil { + t.Fatalf("expected usage error") + } +} + +func TestExecuteProviderCommandSuccess(t *testing.T) { + app, _ := newTestApp(t) + value := app.state.CurrentProvider + if strings.TrimSpace(value) == "" { + t.Fatalf("expected provider id to be set") + } + + message, err := executeProviderCommand(context.Background(), app.providerSvc, value) + if err != nil { + t.Fatalf("executeProviderCommand error = %v", err) + } + if !strings.Contains(message, value) { + t.Fatalf("expected provider id in message, got %q", message) + } +} + +func TestExecuteProviderCommandPropagatesError(t *testing.T) { + providerSvc := errorProviderService{err: errors.New("boom")} + if _, err := executeProviderCommand(context.Background(), providerSvc, "any"); err == nil { + t.Fatalf("expected provider error") + } +} + +func TestRunProviderSelectionCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runProviderSelection(app.providerSvc, app.state.CurrentProvider) + if cmd == nil { + t.Fatalf("expected cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !result.ProviderChanged || !strings.Contains(result.Notice, app.state.CurrentProvider) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestRunModelSelectionCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runModelSelection(app.providerSvc, app.state.CurrentModel) + if cmd == nil { + t.Fatalf("expected cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !result.ModelChanged || !strings.Contains(result.Notice, app.state.CurrentModel) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestRunModelCatalogRefreshCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runModelCatalogRefresh(app.providerSvc, app.state.CurrentProvider) + if cmd == nil { + t.Fatalf("expected refresh cmd") + } + msg := cmd() + result, ok := msg.(modelCatalogRefreshMsg) + if !ok { + t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) + } + if !strings.EqualFold(result.ProviderID, app.state.CurrentProvider) { + t.Fatalf("unexpected provider id: %s", result.ProviderID) + } +} + +func TestExecuteStatusCommandFormatting(t *testing.T) { + snapshot := tuistatus.Snapshot{ + ActiveSessionTitle: "Draft", + CurrentProvider: "test-provider", + CurrentModel: "test-model", + CurrentWorkdir: "/tmp", + } + output := executeStatusCommand(snapshot) + if !strings.Contains(output, "Status:") { + t.Fatalf("expected Status header, got %q", output) + } +} diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go new file mode 100644 index 00000000..50452532 --- /dev/null +++ b/internal/tui/core/app/update_test.go @@ -0,0 +1,1086 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + "neo-code/internal/tools" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" +) + +type stubProviderService struct { + providers []config.ProviderCatalogItem + models []config.ModelDescriptor +} + +func (s stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return s.providers, nil +} + +func (s stubProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + modelID := "" + if len(s.models) > 0 { + modelID = s.models[0].ID + } + return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil +} + +func (s stubProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, nil +} + +func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, nil +} + +func (s stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + providerID := "" + if len(s.providers) > 0 { + providerID = s.providers[0].ID + } + return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil +} + +type stubRuntime struct { + events chan agentruntime.RuntimeEvent + resolveCalls []agentruntime.PermissionResolutionInput + resolveErr error + cancelInvoked bool +} + +func newStubRuntime() *stubRuntime { + return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} +} + +func (s *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (s *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (s *stubRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + s.resolveCalls = append(s.resolveCalls, input) + return s.resolveErr +} + +func (s *stubRuntime) CancelActiveRun() bool { + s.cancelInvoked = true + return true +} + +func (s *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { + return s.events +} + +func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.NewWithWorkdir("draft", ""), nil +} + +func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.NewWithWorkdir("draft", workdir), nil +} + +func newTestApp(t *testing.T) (App, *stubRuntime) { + t.Helper() + + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + runtime := newStubRuntime() + app, err := newApp(tuibootstrap.Container{ + Config: *cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{providers: providers, models: models}, + }) + if err != nil { + t.Fatalf("newApp() error = %v", err) + } + + return app, runtime +} + +func TestAppUpdateBasic(t *testing.T) { + app, _ := newTestApp(t) + + windowMsg := tea.WindowSizeMsg{Width: 100, Height: 30} + model, cmd := app.Update(windowMsg) + if model == nil { + t.Error("Update returned nil model for WindowSizeMsg") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for WindowSizeMsg") + } + + app.state.StatusText = "" + closedMsg := RuntimeClosedMsg{} + model, cmd = app.Update(closedMsg) + if model == nil { + t.Error("Update returned nil model for RuntimeClosedMsg") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for RuntimeClosedMsg") + } + if app.state.StatusText != statusRuntimeClosed { + t.Errorf("Expected status %s, got %s", statusRuntimeClosed, app.state.StatusText) + } + + runErrMsg := runFinishedMsg{Err: errors.New("test error")} + model, cmd = app.Update(runErrMsg) + if model == nil { + t.Error("Update returned nil model for runFinishedMsg with error") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for runFinishedMsg with error") + } + + canceledMsg := runFinishedMsg{Err: context.Canceled} + model, cmd = app.Update(canceledMsg) + if model == nil { + t.Error("Update returned nil model for runFinishedMsg with canceled error") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for runFinishedMsg with canceled error") + } +} + +func TestParsePermissionShortcutFromKeyInput(t *testing.T) { + if decision, ok := parsePermissionShortcut("y"); !ok || decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("expected allow_once, got %v (ok=%v)", decision, ok) + } + if decision, ok := parsePermissionShortcut("a"); !ok || decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("expected allow_session, got %v (ok=%v)", decision, ok) + } + if decision, ok := parsePermissionShortcut("n"); !ok || decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject, got %v (ok=%v)", decision, ok) + } + if _, ok := parsePermissionShortcut("x"); ok { + t.Fatalf("expected unsupported key to return false") + } +} + +func TestRuntimeEventPermissionRequestHandler(t *testing.T) { + app, _ := newTestApp(t) + + payload := agentruntime.PermissionRequestPayload{ + RequestID: "perm-1", + ToolName: "bash", + Operation: "write", + Target: "file.txt", + } + handled := runtimeEventPermissionRequestHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected handler to return false") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-1" { + t.Fatalf("expected pending permission request to be set") + } + if app.state.StatusText != statusPermissionRequired { + t.Fatalf("expected permission required status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventPermissionResolvedHandler(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-2"}, + } + + payload := agentruntime.PermissionResolvedPayload{ + RequestID: "perm-2", + ToolName: "bash", + Decision: "allow", + ResolvedAs: "approved", + } + handled := runtimeEventPermissionResolvedHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected handler to return false") + } + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared") + } + if app.state.StatusText != "Permission approved" { + t.Fatalf("expected resolved status text, got %s", app.state.StatusText) + } +} + +func TestUpdatePermissionResolveFlow(t *testing.T) { + app, runtime := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-3"}, + } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if model == nil { + t.Fatalf("expected non-nil model") + } + app = model.(App) + if cmd == nil { + t.Fatalf("expected command to resolve permission") + } + if app.state.StatusText != statusPermissionSubmitting { + t.Fatalf("expected submitting status, got %s", app.state.StatusText) + } + + msg := cmd() + if len(runtime.resolveCalls) != 1 || runtime.resolveCalls[0].RequestID != "perm-3" { + t.Fatalf("expected ResolvePermission to be called") + } + if runtime.resolveCalls[0].Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("unexpected decision forwarded: %s", runtime.resolveCalls[0].Decision) + } + + next, _ := app.Update(msg) + app = next.(App) + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared after submit") + } + if app.state.StatusText != statusPermissionSubmitted { + t.Fatalf("expected submitted status, got %s", app.state.StatusText) + } +} + +func TestUpdatePermissionResolvedError(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-4"}, + Submitting: true, + } + + model, _ := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-4", + Decision: agentruntime.PermissionResolutionAllowOnce, + Err: errors.New("boom"), + }) + app = model.(App) + + if app.pendingPermission == nil || app.pendingPermission.Submitting { + t.Fatalf("expected pending permission to remain but leave submitting state") + } + if app.state.StatusText != "boom" { + t.Fatalf("expected failure status, got %s", app.state.StatusText) + } +} + +func TestRunResolvePermissionCommand(t *testing.T) { + runtime := newStubRuntime() + cmd := runResolvePermission(runtime, "perm-5", agentruntime.PermissionResolutionAllowSession) + if cmd == nil { + t.Fatalf("expected command") + } + msg := cmd() + resolved, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if resolved.RequestID != "perm-5" || resolved.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("unexpected resolved msg: %#v", resolved) + } + if len(runtime.resolveCalls) != 1 { + t.Fatalf("expected resolve call recorded") + } +} + +func TestRenderPermissionPromptInUpdateFlow(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + RequestID: "perm-6", + ToolName: "bash", + Operation: "write", + Target: "file.txt", + }, + } + got := app.renderPermissionPrompt() + if !strings.Contains(got, "Permission request: bash (write)") { + t.Fatalf("expected permission prompt header, got %q", got) + } +} + +func TestUpdatePermissionResolutionFinishedMsgIgnoresMismatch(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-7"}, + } + model, cmd := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-8", + Decision: agentruntime.PermissionResolutionAllowOnce, + }) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-7" { + t.Fatalf("expected pending permission to remain") + } +} + +func TestRuntimeEventPermissionRequestUsesToolName(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.PermissionRequestPayload{ + RequestID: "perm-9", + ToolName: "webfetch", + } + runtimeEventPermissionRequestHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.pendingPermission == nil || app.pendingPermission.Request.ToolName != "webfetch" { + t.Fatalf("expected pending permission tool to be set") + } +} + +func TestUpdatePermissionRejectFlow(t *testing.T) { + app, runtime := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-10"}, + } + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + if cmd == nil { + t.Fatalf("expected resolve cmd") + } + app = model.(App) + msg := cmd() + next, _ := app.Update(msg) + app = next.(App) + if len(runtime.resolveCalls) != 1 || runtime.resolveCalls[0].Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject decision to be submitted") + } + if app.state.StatusText != statusPermissionSubmitted { + t.Fatalf("expected submitted status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventToolResultHandlerUpdatesMessages(t *testing.T) { + app, _ := newTestApp(t) + result := tools.ToolResult{ + Name: "bash", + Content: "ok", + IsError: false, + ToolCallID: "tool-1", + } + handled := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{Payload: result}) + if !handled { + t.Fatalf("expected handler to return true") + } + last := app.activeMessages[len(app.activeMessages)-1] + if last.Role != roleTool || last.Content != "ok" { + t.Fatalf("unexpected tool message: %#v", last) + } +} + +func TestRuntimeEventToolResultHandlerError(t *testing.T) { + app, _ := newTestApp(t) + result := tools.ToolResult{ + Name: "bash", + Content: "boom", + IsError: true, + ToolCallID: "tool-2", + } + handled := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{Payload: result}) + if !handled { + t.Fatalf("expected handler to return true") + } + if app.state.StatusText != statusToolError { + t.Fatalf("expected tool error status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventAgentDoneHandlerAppendsMessage(t *testing.T) { + app, _ := newTestApp(t) + payload := providertypes.Message{Role: roleAssistant, Content: "done"} + handled := runtimeEventAgentDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected handler to return true") + } + if len(app.activeMessages) == 0 { + t.Fatalf("expected message appended") + } +} + +func TestParseFenceOpenLine(t *testing.T) { + info, ok := parseFenceOpenLine("```go") + if !ok || info != "go" { + t.Fatalf("expected fence info, got %q ok=%v", info, ok) + } + info, ok = parseFenceOpenLine(" not a fence") + if ok || info != "" { + t.Fatalf("expected no fence") + } +} + +func TestIsFenceCloseLine(t *testing.T) { + if !isFenceCloseLine("```") { + t.Fatalf("expected fence close") + } + if isFenceCloseLine("```go") { + t.Fatalf("expected not fence close") + } +} + +func TestIsIndentedCodeLine(t *testing.T) { + if !isIndentedCodeLine("\tcode") { + t.Fatalf("expected tab-indented code") + } + if !isIndentedCodeLine(" code") { + t.Fatalf("expected space-indented code") + } + if isIndentedCodeLine("code") { + t.Fatalf("expected non-indented line") + } +} + +func TestTrimCodeIndent(t *testing.T) { + if got := trimCodeIndent("\tcode"); got != "code" { + t.Fatalf("expected trimmed tab indent, got %q", got) + } + if got := trimCodeIndent(" code"); got != "code" { + t.Fatalf("expected trimmed space indent, got %q", got) + } + if got := trimCodeIndent("code"); got != "code" { + t.Fatalf("expected unchanged line, got %q", got) + } +} + +func TestSplitMarkdownSegmentsFenced(t *testing.T) { + content := "hello\n```go\nfmt.Println(\"ok\")\n```\nworld" + segments := splitMarkdownSegments(content) + if len(segments) < 2 { + t.Fatalf("expected multiple segments, got %d", len(segments)) + } + if segments[1].Kind != markdownSegmentCode || segments[1].Code == "" { + t.Fatalf("expected code segment") + } +} + +func TestSplitMarkdownSegmentsIndented(t *testing.T) { + content := "hello\n code line\nworld" + segments := splitMarkdownSegments(content) + if len(segments) < 2 { + t.Fatalf("expected multiple segments, got %d", len(segments)) + } + foundCode := false + for _, seg := range segments { + if seg.Kind == markdownSegmentCode && seg.Code != "" { + foundCode = true + } + } + if !foundCode { + t.Fatalf("expected indented code segment") + } +} + +func TestExtractFencedCodeBlocks(t *testing.T) { + content := "text\n```go\nfmt.Println(\"ok\")\n```\nend" + blocks := extractFencedCodeBlocks(content) + if len(blocks) != 1 || blocks[0] == "" { + t.Fatalf("expected one code block") + } +} + +func TestParseCopyCodeButton(t *testing.T) { + id, start, end, ok := parseCopyCodeButton("[Copy code #12]") + if !ok || id != 12 || start >= end { + t.Fatalf("unexpected parse result: id=%d start=%d end=%d ok=%v", id, start, end, ok) + } + if _, _, _, ok := parseCopyCodeButton("no button"); ok { + t.Fatalf("expected no button parse") + } +} + +func TestCopyCodeBlockByIDSuccess(t *testing.T) { + app, _ := newTestApp(t) + + var got string + originalClipboard := clipboardWriteAll + clipboardWriteAll = func(text string) error { + got = text + return nil + } + defer func() { clipboardWriteAll = originalClipboard }() + + app.setCodeCopyBlocks([]copyCodeButtonBinding{{ID: 1, Code: "code"}}) + ok := app.copyCodeBlockByID(1) + if !ok { + t.Fatalf("expected handled copy") + } + if got != "code" { + t.Fatalf("expected clipboard content, got %q", got) + } + if app.state.StatusText == "" { + t.Fatalf("expected status text to be set") + } +} + +func TestCopyCodeBlockByIDMissing(t *testing.T) { + app, _ := newTestApp(t) + + ok := app.copyCodeBlockByID(99) + if !ok { + t.Fatalf("expected handled copy") + } + if app.state.StatusText != statusCodeCopyError { + t.Fatalf("expected error status, got %s", app.state.StatusText) + } +} + +func TestCopyCodeBlockByIDClipboardError(t *testing.T) { + app, _ := newTestApp(t) + + originalClipboard := clipboardWriteAll + clipboardWriteAll = func(text string) error { + return errors.New("fail") + } + defer func() { clipboardWriteAll = originalClipboard }() + + app.setCodeCopyBlocks([]copyCodeButtonBinding{{ID: 2, Code: "code"}}) + ok := app.copyCodeBlockByID(2) + if !ok { + t.Fatalf("expected handled copy") + } + if app.state.StatusText != statusCodeCopyError { + t.Fatalf("expected error status, got %s", app.state.StatusText) + } +} + +func TestIsWorkspaceCommandInput(t *testing.T) { + if !isWorkspaceCommandInput("& ls -la") { + t.Fatalf("expected workspace command prefix to be detected") + } + if isWorkspaceCommandInput("ls -la") { + t.Fatalf("expected non-workspace command to be false") + } +} + +func TestExtractWorkspaceCommand(t *testing.T) { + command, err := extractWorkspaceCommand("& git status") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if command != "git status" { + t.Fatalf("expected command to be extracted, got %q", command) + } + + if _, err := extractWorkspaceCommand("&"); err == nil { + t.Fatalf("expected error for empty command") + } + if _, err := extractWorkspaceCommand("git status"); err == nil { + t.Fatalf("expected error for missing prefix") + } +} + +func TestFormatWorkspaceCommandResult(t *testing.T) { + output := "clean\n" + got := formatWorkspaceCommandResult("git status", output, nil) + if !strings.Contains(got, "Command: & git status") { + t.Fatalf("expected success header, got %q", got) + } + if !strings.Contains(got, "clean") { + t.Fatalf("expected output to be included") + } + + errResult := formatWorkspaceCommandResult("git status", "", errors.New("boom")) + if !strings.Contains(errResult, "Command Failed: & git status") { + t.Fatalf("expected failure header, got %q", errResult) + } + if !strings.Contains(errResult, "boom") { + t.Fatalf("expected error message in result") + } +} + +func TestTokenRangeFirstToken(t *testing.T) { + start, end, token, ok := tokenRange(" /help now", tokenSelectorFirst) + if !ok { + t.Fatalf("expected token range to be found") + } + if token != "/help" { + t.Fatalf("expected first token to be /help, got %q", token) + } + if start < 0 || end <= start { + t.Fatalf("expected valid range, got %d-%d", start, end) + } +} + +func TestTokenRangeLastToken(t *testing.T) { + start, end, token, ok := tokenRange("one two three", tokenSelectorLast) + if !ok { + t.Fatalf("expected token range to be found") + } + if token != "three" { + t.Fatalf("expected last token to be three, got %q", token) + } + if start < 0 || end <= start { + t.Fatalf("expected valid range, got %d-%d", start, end) + } +} + +func TestCollectFileSuggestionMatches(t *testing.T) { + candidates := []string{"README.md", "docs/guide.md", "internal/app.go"} + matches := collectFileSuggestionMatches("read", candidates, 2) + if len(matches) == 0 { + t.Fatalf("expected matches for read") + } +} + +func TestShellArgsAndPowerShellUTF8(t *testing.T) { + args := shellArgs("bash", "echo hi") + if len(args) == 0 { + t.Fatalf("expected shell args to be returned") + } + utf8 := powershellUTF8Command("echo hi") + if utf8 == "" { + t.Fatalf("expected powershell utf8 command") + } +} + +func TestSanitizeAndDecodeWorkspaceOutput(t *testing.T) { + raw := []byte("hello\u0000world") + sanitized := sanitizeWorkspaceOutput(raw) + if sanitized == "" { + t.Fatalf("expected sanitized output") + } + decoded := decodeWorkspaceOutput(raw) + if decoded == "" { + t.Fatalf("expected decoded output") + } +} + +func TestViewSmallWindow(t *testing.T) { + app, _ := newTestApp(t) + app.width = 60 + app.height = 20 + + view := app.View() + if !strings.Contains(view, "Window too small") { + t.Fatalf("expected small window warning, got %q", view) + } +} + +func TestComputeLayoutStackedAndWide(t *testing.T) { + app, _ := newTestApp(t) + + app.width = 90 + app.height = 40 + layout := app.computeLayout() + if !layout.stacked { + t.Fatalf("expected stacked layout for narrow width") + } + if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { + t.Fatalf("expected positive layout widths, got %+v", layout) + } + + app.width = 140 + app.height = 40 + layout = app.computeLayout() + if layout.stacked { + t.Fatalf("expected non-stacked layout for wide width") + } + if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { + t.Fatalf("expected positive layout widths, got %+v", layout) + } +} + +func TestStatusBadgeVariants(t *testing.T) { + app, _ := newTestApp(t) + + errorBadge := app.statusBadge("Error occurred") + if strings.TrimSpace(errorBadge) == "" { + t.Fatalf("expected error badge to render") + } + + cancelBadge := app.statusBadge("Canceled") + if strings.TrimSpace(cancelBadge) == "" { + t.Fatalf("expected cancel badge to render") + } + + app.state.IsAgentRunning = true + runningBadge := app.statusBadge("Running") + if strings.TrimSpace(runningBadge) == "" { + t.Fatalf("expected running badge to render") + } + + app.state.IsAgentRunning = false + okBadge := app.statusBadge("Ready") + if strings.TrimSpace(okBadge) == "" { + t.Fatalf("expected success badge to render") + } +} + +func TestHelpHeightAndRenderHelp(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + + app.state.ShowHelp = false + helpHeight := app.helpHeight(80) + if helpHeight <= 0 { + t.Fatalf("expected help height to be positive") + } + rendered := app.renderHelp(80) + if strings.TrimSpace(rendered) == "" { + t.Fatalf("expected renderHelp output") + } + + app.state.ShowHelp = true + helpHeight = app.helpHeight(80) + if helpHeight <= 0 { + t.Fatalf("expected help height to be positive when help is shown") + } +} + +func TestNewWithBootstrapSuccess(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + runtime := newStubRuntime() + app, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{providers: providers, models: models}, + }) + if err != nil { + t.Fatalf("NewWithBootstrap() error = %v", err) + } + + cmd := app.Init() + if cmd == nil { + t.Fatalf("expected Init() to return command") + } +} + +func TestNewWithBootstrapMissingDependencies(t *testing.T) { + cfg := config.DefaultConfig() + + manager := config.NewManager(config.NewLoader(t.TempDir(), cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: manager, + Runtime: nil, + ProviderService: stubProviderService{}, + }); err == nil { + t.Fatalf("expected error for nil runtime") + } + + if _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: nil, + Runtime: newStubRuntime(), + ProviderService: stubProviderService{}, + }); err == nil { + t.Fatalf("expected error for nil config manager") + } +} + +func TestNewUsesBootstrap(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + app, err := New(cfg, manager, newStubRuntime(), stubProviderService{providers: providers, models: models}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if app.state.CurrentProvider == "" { + t.Fatalf("expected CurrentProvider to be set") + } +} + +func TestRuntimeEventUserMessageHandler(t *testing.T) { + app, _ := newTestApp(t) + event := agentruntime.RuntimeEvent{RunID: "run-1"} + handled := runtimeEventUserMessageHandler(&app, event) + if handled { + t.Fatalf("expected false") + } + if app.state.ActiveRunID != "run-1" { + t.Fatalf("expected run id to be set") + } + if app.state.StatusText != statusThinking { + t.Fatalf("expected thinking status") + } +} + +func TestRuntimeEventRunContextHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeRunContextPayload{ + Provider: "p1", + Model: "m1", + Workdir: "/tmp", + } + event := agentruntime.RuntimeEvent{RunID: "run-2", SessionID: "s1", Payload: payload} + handled := runtimeEventRunContextHandler(&app, event) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentProvider != "p1" || app.state.CurrentModel != "m1" { + t.Fatalf("expected provider/model to update") + } +} + +func TestRuntimeEventToolStatusHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeToolStatusPayload{ToolCallID: "tool-1", ToolName: "bash", Status: string(tuistate.ToolLifecyclePlanned)} + handled := runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentTool != "bash" { + t.Fatalf("expected current tool to be set") + } + payload.Status = string(tuistate.ToolLifecycleSucceeded) + _ = runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.state.CurrentTool != "" { + t.Fatalf("expected current tool to be cleared") + } +} + +func TestRuntimeEventUsageHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeUsagePayload{Run: tuiservices.RuntimeUsageSnapshot{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}} + handled := runtimeEventUsageHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected false") + } + if app.state.TokenUsage.RunTotalTokens != 3 { + t.Fatalf("expected token usage to update") + } +} + +func TestRuntimeEventToolCallThinkingHandler(t *testing.T) { + app, _ := newTestApp(t) + handled := runtimeEventToolCallThinkingHandler(&app, agentruntime.RuntimeEvent{Payload: "bash"}) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentTool != "bash" { + t.Fatalf("expected current tool to be set") + } +} + +func TestRuntimeEventToolStartHandler(t *testing.T) { + app, _ := newTestApp(t) + call := providertypes.ToolCall{Name: "bash"} + handled := runtimeEventToolStartHandler(&app, agentruntime.RuntimeEvent{Payload: call}) + if handled { + t.Fatalf("expected false") + } + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status") + } +} + +func TestRuntimeEventToolChunkHandler(t *testing.T) { + app, _ := newTestApp(t) + _ = runtimeEventToolChunkHandler(&app, agentruntime.RuntimeEvent{Payload: "chunk"}) + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status") + } +} + +func TestRuntimeEventAgentChunkHandler(t *testing.T) { + app, _ := newTestApp(t) + handled := runtimeEventAgentChunkHandler(&app, agentruntime.RuntimeEvent{Payload: "hello"}) + if !handled { + t.Fatalf("expected true") + } + if len(app.activeMessages) == 0 { + t.Fatalf("expected message appended") + } +} + +func TestRuntimeEventRunCanceledHandler(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveRunID = "run-3" + runtimeEventRunCanceledHandler(&app, agentruntime.RuntimeEvent{}) + if app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled status") + } + if app.state.ActiveRunID != "" { + t.Fatalf("expected run id cleared") + } +} + +func TestRuntimeEventErrorHandler(t *testing.T) { + app, _ := newTestApp(t) + runtimeEventErrorHandler(&app, agentruntime.RuntimeEvent{Payload: "boom"}) + if app.state.StatusText != "boom" { + t.Fatalf("expected status to be set to error") + } +} + +func TestRuntimeEventProviderRetryHandler(t *testing.T) { + app, _ := newTestApp(t) + runtimeEventProviderRetryHandler(&app, agentruntime.RuntimeEvent{Payload: "retry"}) + if app.state.StatusText != statusThinking { + t.Fatalf("expected thinking status") + } +} + +func TestRuntimeEventCompactDoneHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.CompactDonePayload{TriggerMode: "auto", SavedRatio: 0.5, BeforeChars: 10, AfterChars: 5, TranscriptPath: "path"} + handled := runtimeEventCompactDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected true") + } + if !strings.Contains(app.state.StatusText, "Compact(") { + t.Fatalf("expected compact status") + } +} + +func TestRuntimeEventCompactErrorHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.CompactErrorPayload{TriggerMode: "auto", Message: "fail"} + handled := runtimeEventCompactErrorHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected true") + } + if app.state.ExecutionError == "" { + t.Fatalf("expected error message") + } +} + +func TestAppendAssistantAndInlineMessage(t *testing.T) { + app, _ := newTestApp(t) + app.appendAssistantChunk("hi") + app.appendAssistantChunk(" there") + if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "there") { + t.Fatalf("expected assistant chunk to append") + } + app.appendInlineMessage(roleSystem, " note ") + if len(app.activeMessages) < 2 { + t.Fatalf("expected inline message appended") + } +} + +func TestShouldHandleTabAsInput(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelInput + app.state.ActivePicker = pickerNone + app.input.SetValue("/he") + if !app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be handled as input") + } + app.input.SetValue("") + if app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be ignored for empty input") + } +} + +func TestFocusNextPrev(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelSessions + app.focusNext() + if app.focus == panelSessions { + t.Fatalf("expected focus to move") + } + app.focusPrev() +} + +func TestHandleViewportKeys(t *testing.T) { + app, _ := newTestApp(t) + app.transcript.SetContent("line1\nline2\nline3") + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) +} diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 6bd8d47f..c3347f75 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -30,6 +30,13 @@ type CompactFinishedMsg struct { Err error } +// PermissionResolvedMsg 表示权限审批结果已回传。 +type PermissionResolvedMsg struct { + RequestID string + Decision string + Err error +} + // LocalCommandResultMsg 表示本地命令执行结果。 type LocalCommandResultMsg struct { Notice string From f947e2f1d12ca3d35ddbc8d82d2f0733899346f2 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 16:07:13 +0800 Subject: [PATCH 48/54] =?UTF-8?q?fix=EF=BC=9A=E4=BB=A3=E7=A0=81=E5=9D=97?= =?UTF-8?q?=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/copy_code.go | 49 ++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/internal/tui/core/app/copy_code.go b/internal/tui/core/app/copy_code.go index 2bdb89ad..3f043d35 100644 --- a/internal/tui/core/app/copy_code.go +++ b/internal/tui/core/app/copy_code.go @@ -34,6 +34,15 @@ var ( copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) copyCodeANSIPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) clipboardWriteAll = tuiinfra.CopyText + + codeFeaturePatterns = []*regexp.Regexp{ + regexp.MustCompile(`^[[:space:]]*(func|if|for|while|switch|case|return|class|def|const|let|var|import|export|package|struct|enum|interface|public|private|static|void|int|string|bool|nil|null|true|false)\b`), + regexp.MustCompile(`=>|->|::`), + regexp.MustCompile(`[})];?\s*$`), + regexp.MustCompile(`^\s*(//|#|/\*|\*)`), + regexp.MustCompile(`:=|=>`), + regexp.MustCompile(`\([a-zA-Z_][a-zA-Z0-9_]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_]*)*\)\s*{?$`), + } ) func splitMarkdownSegments(content string) []markdownSegment { @@ -123,6 +132,7 @@ func splitIndentedCodeSegments(content string) []markdownSegment { textLines := make([]string, 0, len(lines)) codeLines := make([]string, 0, len(lines)) inCode := false + codeFeatureCount := 0 flushText := func() { if len(textLines) == 0 { @@ -150,27 +160,38 @@ func splitIndentedCodeSegments(content string) []markdownSegment { Code: code, }) codeLines = codeLines[:0] + codeFeatureCount = 0 } for _, line := range lines { indented := isIndentedCodeLine(line) if inCode { - if indented { + if indented || hasCodeFeatures(line) { codeLines = append(codeLines, trimCodeIndent(line)) + if hasCodeFeatures(line) { + codeFeatureCount++ + } continue } if strings.TrimSpace(line) == "" { codeLines = append(codeLines, "") continue } - flushCode() + if len(codeLines) > 0 { + flushCode() + } inCode = false } - if indented { - flushText() - inCode = true + if indented || hasCodeFeatures(line) { + if !inCode { + flushText() + inCode = true + } codeLines = append(codeLines, trimCodeIndent(line)) + if hasCodeFeatures(line) { + codeFeatureCount++ + } continue } @@ -213,7 +234,23 @@ func isFenceCloseLine(line string) bool { } func isIndentedCodeLine(line string) bool { - return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") + if strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") { + return true + } + return hasCodeFeatures(line) +} + +func hasCodeFeatures(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + return false + } + for _, pattern := range codeFeaturePatterns { + if pattern.MatchString(line) { + return true + } + } + return false } func trimCodeIndent(line string) string { From 6c92d0401f4ed56b134473978dd66b74a6d5baf9 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 22:35:49 +0800 Subject: [PATCH 49/54] =?UTF-8?q?fix=EF=BC=88tui=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E8=BE=93=E5=85=A5=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/command_menu.go | 53 ++++++++------- internal/tui/core/app/update.go | 23 +++++-- internal/tui/core/app/view.go | 14 +++- internal/tui/docs/LAYERING.md | 94 --------------------------- 4 files changed, 58 insertions(+), 126 deletions(-) delete mode 100644 internal/tui/docs/LAYERING.md diff --git a/internal/tui/core/app/command_menu.go b/internal/tui/core/app/command_menu.go index 9e593b79..25db49d1 100644 --- a/internal/tui/core/app/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -186,11 +186,36 @@ func (a *App) resizeCommandMenu() { } func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, tuistate.CommandMenuMeta) { + trimmed := strings.TrimSpace(input) + + // 1. 优先检查 Slash 命令 + if strings.HasPrefix(trimmed, slashPrefix) { + suggestions := a.matchingSlashCommands(trimmed) + if len(suggestions) > 0 { + start, end, _, _ := tokenRange(input, tokenSelectorFirst) + items := make([]commandMenuItem, 0, len(suggestions)) + for _, suggestion := range suggestions { + items = append(items, commandMenuItem{ + title: suggestion.Command.Usage, + description: suggestion.Command.Description, + filter: suggestion.Command.Usage + " " + suggestion.Command.Description, + highlight: suggestion.Match, + replacement: suggestion.Command.Usage, + useReplaceRange: true, + replaceStart: start, + replaceEnd: end, + }) + } + return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} + } + } + + // 2. 检查文件建议 (如果 Slash 命令不匹配) if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 { return suggestions, tuistate.CommandMenuMeta{Title: fileMenuTitle} } - trimmed := strings.TrimSpace(input) + // 3. 检查工作区命令 (如果 Slash 命令和文件建议都不匹配) if isWorkspaceCommandInput(trimmed) { replacement := trimmed item := commandMenuItem{ @@ -209,26 +234,8 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, return []commandMenuItem{item}, tuistate.CommandMenuMeta{Title: shellMenuTitle} } - suggestions := a.matchingSlashCommands(trimmed) - if len(suggestions) == 0 { - return nil, tuistate.CommandMenuMeta{} - } - - start, end, _, _ := tokenRange(input, tokenSelectorFirst) - items := make([]commandMenuItem, 0, len(suggestions)) - for _, suggestion := range suggestions { - items = append(items, commandMenuItem{ - title: suggestion.Command.Usage, - description: suggestion.Command.Description, - filter: suggestion.Command.Usage + " " + suggestion.Command.Description, - highlight: suggestion.Match, - replacement: suggestion.Command.Usage, - useReplaceRange: true, - replaceStart: start, - replaceEnd: end, - }) - } - return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} + // 如果没有任何匹配的建议 + return nil, tuistate.CommandMenuMeta{} } func (a App) fileMenuSuggestions(input string) []commandMenuItem { @@ -309,7 +316,7 @@ func (a *App) applySelectedCommandSuggestion() bool { func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { if !a.commandMenuHasSuggestions() { - return nil, false + return nil, false // 让按键继续传递 } switch msg.Type { @@ -318,7 +325,7 @@ func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { a.commandMenu, cmd = a.commandMenu.Update(msg) return cmd, true default: - return nil, false + return nil, false // 非导航键,让它们继续传递 } } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 0062908b..b28980fd 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -343,19 +343,26 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - a.input.Reset() - a.state.InputText = "" - a.applyComponentLayout(true) - a.refreshCommandMenu() - a.resetPasteHeuristics() - + // 先检查是否是立即执行的命令,如果处理了,就直接返回 if handled, cmd := a.handleImmediateSlashCommand(input); handled { + a.input.Reset() // 只有在命令被处理后才清空输入 + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() if cmd != nil { cmds = append(cmds, cmd) } return a, tea.Batch(cmds...) } + // 如果不是立即执行的命令,再执行常规的输入重置 + a.input.Reset() + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() + switch strings.ToLower(input) { case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { @@ -1488,7 +1495,9 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) a.input.SetHeight(a.composerHeight()) promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) - a.transcript.Height = max(6, lay.rightHeight-activityHeight-menuHeight-promptHeight) + availableHeight := lay.rightHeight - activityHeight - menuHeight - promptHeight + minTranscriptHeight := max(6, lay.rightHeight/2) + a.transcript.Height = max(minTranscriptHeight, availableHeight) if activityHeight > 0 { panelStyle := a.styles.panelFocused diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 637ad8e3..65b95c49 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -140,7 +140,12 @@ func (a App) renderWaterfall(width int, height int) string { ) } - transcript := a.styles.streamContent.Width(width).Height(a.transcript.Height).Render(a.transcript.View()) + activityHeight := a.activityPreviewHeight() + menuHeight := a.commandMenuHeight(width) + promptHeight := lipgloss.Height(a.renderPrompt(width)) + transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) + + transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) parts := []string{transcript} if activity := a.renderActivityPreview(width); activity != "" { @@ -151,7 +156,12 @@ func (a App) renderWaterfall(width int, height int) string { } parts = append(parts, a.renderPrompt(width)) - return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, parts...)) + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + contentHeight := lipgloss.Height(content) + if contentHeight < height { + content = content + "\n" + lipgloss.NewStyle().Height(height-contentHeight).Render("") + } + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } func (a App) renderPicker(width int, height int) string { diff --git a/internal/tui/docs/LAYERING.md b/internal/tui/docs/LAYERING.md deleted file mode 100644 index 4ff7b040..00000000 --- a/internal/tui/docs/LAYERING.md +++ /dev/null @@ -1,94 +0,0 @@ -# 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 本地推导。 From e9b490d7e4089e25e13c20730e37004315ac8f59 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 22:50:02 +0800 Subject: [PATCH 50/54] =?UTF-8?q?refactor:=E7=BE=8E=E5=8C=96/help=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/app.go | 6 ++ internal/tui/core/app/commands.go | 46 ++++++++++++ internal/tui/core/app/commands_test.go | 22 ++++++ internal/tui/core/app/update.go | 73 +++++++++++++++++++ internal/tui/core/app/update_test.go | 74 ++++++++++++++++++++ internal/tui/core/app/view.go | 5 ++ internal/tui/core/utils/view_helpers.go | 2 + internal/tui/core/utils/view_helpers_test.go | 1 + internal/tui/state/state_test.go | 11 ++- internal/tui/state/ui_state.go | 1 + 10 files changed, 239 insertions(+), 2 deletions(-) diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 1a32a54c..48702d4f 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -37,6 +37,7 @@ const ( pickerProvider pickerMode = tuistate.PickerProvider pickerModel pickerMode = tuistate.PickerModel pickerFile pickerMode = tuistate.PickerFile + pickerHelp pickerMode = tuistate.PickerHelp ) type RuntimeMsg = tuistate.RuntimeMsg @@ -74,6 +75,7 @@ type appComponents struct { commandMenuMeta tuistate.CommandMenuMeta providerPicker list.Model modelPicker list.Model + helpPicker list.Model fileBrowser filepicker.Model progress progress.Model transcript viewport.Model @@ -224,6 +226,7 @@ func newApp(container tuibootstrap.Container) (App, error) { commandMenu: commandMenu, providerPicker: newSelectionPickerItems(nil), modelPicker: newSelectionPickerItems(nil), + helpPicker: newHelpPickerItems(nil), fileBrowser: fileBrowser, progress: progressBar, transcript: viewport.New(0, 0), @@ -258,6 +261,9 @@ func newApp(container tuibootstrap.Container) (App, error) { if err := app.refreshModelPicker(); err != nil { return App{}, err } + if err := app.refreshHelpPicker(); err != nil { + return App{}, err + } app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.modelRefreshID = cfg.SelectedProvider diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 9cf1c67a..13e4de90 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -39,6 +39,8 @@ const ( providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + helpPickerTitle = "Slash Commands" + helpPickerSubtitle = "Up/Down choose, Enter run, Esc cancel" filePickerTitle = "Browse Files" filePickerSubtitle = "Navigate folders, Enter choose file, Esc cancel" @@ -69,6 +71,7 @@ const ( statusCompacting = "Compacting context" statusChooseProvider = "Choose a provider" statusChooseModel = "Choose a model" + statusChooseHelp = "Choose a slash command" statusBrowseFile = "Browse workspace files" statusPermissionRequired = "Permission required: choose a decision and press Enter" statusPermissionSubmitting = "Submitting permission decision" @@ -122,6 +125,13 @@ func newSelectionPicker(items []list.Item) list.Model { return picker } +// newHelpPicker 创建 /help 专用选择器,禁用分页以保持单页展示体验。 +func newHelpPicker(items []list.Item) list.Model { + picker := newSelectionPicker(items) + picker.SetShowPagination(false) + return picker +} + func newCommandMenuModel(uiStyles styles) list.Model { delegate := commandMenuDelegate{styles: uiStyles} menu := list.New([]list.Item{}, delegate, 0, 0) @@ -144,6 +154,15 @@ func newSelectionPickerItems(items []selectionItem) list.Model { return newSelectionPicker(listItems) } +// newHelpPickerItems 将 slash 命令映射为 /help 弹层列表项。 +func newHelpPickerItems(items []selectionItem) list.Model { + listItems := make([]list.Item, 0, len(items)) + for _, item := range items { + listItems = append(listItems, item) + } + return newHelpPicker(listItems) +} + func mapProviderItems(items []config.ProviderCatalogItem) []selectionItem { mapped := make([]selectionItem, 0, len(items)) for _, item := range items { @@ -174,6 +193,13 @@ func replacePickerItems(current *list.Model, items []selectionItem) { *current = next } +// replaceHelpPickerItems 替换 /help 弹层条目并保持尺寸。 +func replaceHelpPickerItems(current *list.Model, items []selectionItem) { + next := newHelpPickerItems(items) + next.SetSize(current.Width(), current.Height()) + *current = next +} + func (a *App) refreshProviderPicker() error { items, err := a.providerSvc.ListProviders(context.Background()) if err != nil { @@ -196,6 +222,21 @@ func (a *App) refreshModelPicker() error { return nil } +// refreshHelpPicker 刷新 /help 弹层中的 slash 命令列表。 +func (a *App) refreshHelpPicker() error { + items := make([]selectionItem, 0, len(builtinSlashCommands)) + for _, command := range builtinSlashCommands { + items = append(items, selectionItem{ + id: command.Usage, + name: command.Usage, + description: command.Description, + }) + } + replaceHelpPickerItems(&a.helpPicker, items) + selectPickerItemByID(&a.helpPicker, "") + return nil +} + func (a *App) openProviderPicker() { a.openPicker(pickerProvider, statusChooseProvider, &a.providerPicker, a.state.CurrentProvider) } @@ -204,6 +245,11 @@ func (a *App) openModelPicker() { a.openPicker(pickerModel, statusChooseModel, &a.modelPicker, a.state.CurrentModel) } +// openHelpPicker 打开 slash 命令帮助弹层并进入可选择状态。 +func (a *App) openHelpPicker() { + a.openPicker(pickerHelp, statusChooseHelp, &a.helpPicker, "") +} + func (a *App) openPicker(mode pickerMode, statusText string, picker *list.Model, selectedID string) { a.state.ActivePicker = mode a.state.StatusText = statusText diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index ead1adb4..051f38e7 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -74,6 +74,7 @@ func TestStatusConstants(t *testing.T) { {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -296,3 +297,24 @@ func TestExecuteStatusCommandFormatting(t *testing.T) { t.Fatalf("expected Status header, got %q", output) } } + +func TestRefreshHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + if err := app.refreshHelpPicker(); err != nil { + t.Fatalf("refreshHelpPicker() error = %v", err) + } + if len(app.helpPicker.Items()) != len(builtinSlashCommands) { + t.Fatalf("expected %d help items, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) + } +} + +func TestOpenHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.openHelpPicker() + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected help picker to open") + } + if app.state.StatusText != statusChooseHelp { + t.Fatalf("expected help picker status, got %q", app.state.StatusText) + } +} diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index b28980fd..c36e01f8 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -364,6 +364,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te a.resetPasteHeuristics() switch strings.ToLower(input) { + case slashCommandHelp: + if err := a.refreshHelpPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) + return a, tea.Batch(cmds...) + } + a.openHelpPicker() + return a, tea.Batch(cmds...) case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() @@ -613,6 +622,13 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerHelp: + item, ok := a.helpPicker.SelectedItem().(selectionItem) + a.closePicker() + if !ok { + return a, nil + } + return a, a.runSlashCommandSelection(item.id) } } @@ -622,6 +638,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerHelp: + a.helpPicker, cmd = a.helpPicker.Update(msg) case pickerFile: a.fileBrowser, cmd = a.fileBrowser.Update(msg) if didSelect, path := a.fileBrowser.DidSelectFile(msg); didSelect { @@ -1516,6 +1534,12 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + helpPickerMaxHeight := max(8, lay.rightHeight-6) + helpPickerDesiredHeight := (len(a.helpPicker.Items()) * 3) + 1 + a.helpPicker.SetSize( + max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), + max(6, tuiutils.Clamp(helpPickerDesiredHeight, 6, helpPickerMaxHeight)), + ) a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.rightHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() @@ -1663,6 +1687,55 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } +// runSlashCommandSelection 根据 /help 弹层选中的命令执行对应 slash 行为。 +func (a *App) runSlashCommandSelection(command string) tea.Cmd { + command = strings.ToLower(strings.TrimSpace(command)) + if command == "" { + return nil + } + + if handled, cmd := a.handleImmediateSlashCommand(command); handled { + return cmd + } + + switch command { + case slashCommandHelp: + if err := a.refreshHelpPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) + return nil + } + a.openHelpPicker() + return nil + case slashCommandProvider: + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) + return nil + } + a.openProviderPicker() + return nil + case slashCommandModelPick: + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh models", err.Error(), true) + return nil + } + a.openModelPicker() + return a.requestModelCatalogRefresh(a.state.CurrentProvider) + default: + a.state.StatusText = statusApplyingCommand + a.state.ExecutionError = "" + if isWorkspaceSlashCommand(command) { + return runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, command) + } + return runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), command) + } +} + func (a App) currentStatusSnapshot() tuistatus.Snapshot { return tuistatus.BuildFromUIState( a.state, diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 50452532..40b5723e 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1084,3 +1084,77 @@ func TestHandleViewportKeys(t *testing.T) { app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) } + +func TestUpdateEnterHelpOpensHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/help") + app.state.InputText = "/help" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected non-nil model") + } + app = model.(App) + if cmd != nil { + t.Fatalf("expected no async cmd when opening help picker") + } + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected help picker to be active") + } + if app.state.StatusText != statusChooseHelp { + t.Fatalf("expected status %q, got %q", statusChooseHelp, app.state.StatusText) + } + if len(app.helpPicker.Items()) != len(builtinSlashCommands) { + t.Fatalf("expected %d help options, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) + } +} + +func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { + app, _ := newTestApp(t) + if err := app.refreshHelpPicker(); err != nil { + t.Fatalf("refreshHelpPicker() error = %v", err) + } + app.openHelpPicker() + selectPickerItemByID(&app.helpPicker, slashCommandModelPick) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if cmd != nil { + _ = cmd() + } + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected model picker to open from help selection") + } +} + +func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { + app, _ := newTestApp(t) + if err := app.refreshHelpPicker(); err != nil { + t.Fatalf("refreshHelpPicker() error = %v", err) + } + app.openHelpPicker() + selectPickerItemByID(&app.helpPicker, slashCommandStatus) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected help picker to close after selecting /status") + } + if cmd == nil { + t.Fatalf("expected local slash command cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !strings.Contains(result.Notice, "Status:") { + t.Fatalf("expected status output in slash result, got %q", result.Notice) + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 65b95c49..a0a2750c 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -179,6 +179,11 @@ func (a App) renderPicker(width int, height int) string { subtitle = filePickerSubtitle body = a.fileBrowser.View() } + if a.state.ActivePicker == pickerHelp { + title = helpPickerTitle + subtitle = helpPickerSubtitle + body = a.helpPicker.View() + } content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(title), diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 4c0d029a..58c0373c 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -15,6 +15,8 @@ func PickerLabelFromMode(mode tuistate.PickerMode) string { return "model" case tuistate.PickerFile: return "file" + case tuistate.PickerHelp: + return "help" default: return "none" } diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index 9a37e084..d700f1fc 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -14,6 +14,7 @@ func TestPickerLabelFromMode(t *testing.T) { {tuistate.PickerProvider, "provider"}, {tuistate.PickerModel, "model"}, {tuistate.PickerFile, "file"}, + {tuistate.PickerHelp, "help"}, {tuistate.PickerMode(999), "none"}, } diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go index 73e34cda..599ddfe1 100644 --- a/internal/tui/state/state_test.go +++ b/internal/tui/state/state_test.go @@ -6,8 +6,15 @@ 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) + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 || PickerHelp != 4 { + t.Fatalf( + "unexpected picker constants: %d %d %d %d %d", + PickerNone, + PickerProvider, + PickerModel, + PickerFile, + PickerHelp, + ) } } diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index 9fa071a3..706b99dc 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -20,6 +20,7 @@ const ( PickerProvider PickerModel PickerFile + PickerHelp ) // UIState 保存顶层界面状态快照,仅作为数据容器使用。 From 760fa46ce336e4c6def36ccc858d457978a5a15f Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 23:09:57 +0800 Subject: [PATCH 51/54] =?UTF-8?q?fix(tui):=20=E4=BF=AE=E5=A4=8D=20/help=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E4=B8=8E=E8=A6=86=E7=9B=96=E7=8E=87=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=88UTF-8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/app.go | 4 +-- internal/tui/core/app/commands.go | 3 +- internal/tui/core/app/commands_test.go | 4 +-- internal/tui/core/app/update.go | 14 ++------- internal/tui/core/app/update_test.go | 39 ++++++++++++++++++++++---- internal/tui/core/app/view.go | 4 --- internal/tui/core/app/view_test.go | 33 ++++++++++++++++++++++ 7 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 internal/tui/core/app/view_test.go diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index 48702d4f..957eec99 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -261,9 +261,7 @@ func newApp(container tuibootstrap.Container) (App, error) { if err := app.refreshModelPicker(); err != nil { return App{}, err } - if err := app.refreshHelpPicker(); err != nil { - return App{}, err - } + app.refreshHelpPicker() app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.modelRefreshID = cfg.SelectedProvider diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index 13e4de90..50d8d0bc 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -223,7 +223,7 @@ func (a *App) refreshModelPicker() error { } // refreshHelpPicker 刷新 /help 弹层中的 slash 命令列表。 -func (a *App) refreshHelpPicker() error { +func (a *App) refreshHelpPicker() { items := make([]selectionItem, 0, len(builtinSlashCommands)) for _, command := range builtinSlashCommands { items = append(items, selectionItem{ @@ -234,7 +234,6 @@ func (a *App) refreshHelpPicker() error { } replaceHelpPickerItems(&a.helpPicker, items) selectPickerItemByID(&a.helpPicker, "") - return nil } func (a *App) openProviderPicker() { diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index 051f38e7..db1b0f6a 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -300,9 +300,7 @@ func TestExecuteStatusCommandFormatting(t *testing.T) { func TestRefreshHelpPicker(t *testing.T) { app, _ := newTestApp(t) - if err := app.refreshHelpPicker(); err != nil { - t.Fatalf("refreshHelpPicker() error = %v", err) - } + app.refreshHelpPicker() if len(app.helpPicker.Items()) != len(builtinSlashCommands) { t.Fatalf("expected %d help items, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) } diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index c36e01f8..3553db83 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -365,12 +365,7 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te switch strings.ToLower(input) { case slashCommandHelp: - if err := a.refreshHelpPicker(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) - return a, tea.Batch(cmds...) - } + a.refreshHelpPicker() a.openHelpPicker() return a, tea.Batch(cmds...) case slashCommandProvider: @@ -1700,12 +1695,7 @@ func (a *App) runSlashCommandSelection(command string) tea.Cmd { switch command { case slashCommandHelp: - if err := a.refreshHelpPicker(); err != nil { - a.state.ExecutionError = err.Error() - a.state.StatusText = err.Error() - a.appendActivity("system", "Failed to refresh slash help", err.Error(), true) - return nil - } + a.refreshHelpPicker() a.openHelpPicker() return nil case slashCommandProvider: diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 40b5723e..25021baf 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -516,6 +516,20 @@ func TestSplitMarkdownSegmentsIndented(t *testing.T) { } } +func TestSplitIndentedCodeSegmentsDetectsCodeFeaturesInCodeMode(t *testing.T) { + content := "func main() {\nreturn 1\n}\nplain text" + segments := splitIndentedCodeSegments(content) + if len(segments) < 2 { + t.Fatalf("expected code and text segments, got %d", len(segments)) + } + if segments[0].Kind != markdownSegmentCode { + t.Fatalf("expected first segment to be code") + } + if !strings.Contains(segments[0].Code, "return 1") { + t.Fatalf("expected code segment to include return statement, got %q", segments[0].Code) + } +} + func TestExtractFencedCodeBlocks(t *testing.T) { content := "text\n```go\nfmt.Println(\"ok\")\n```\nend" blocks := extractFencedCodeBlocks(content) @@ -1111,9 +1125,7 @@ func TestUpdateEnterHelpOpensHelpPicker(t *testing.T) { func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { app, _ := newTestApp(t) - if err := app.refreshHelpPicker(); err != nil { - t.Fatalf("refreshHelpPicker() error = %v", err) - } + app.refreshHelpPicker() app.openHelpPicker() selectPickerItemByID(&app.helpPicker, slashCommandModelPick) @@ -1132,9 +1144,7 @@ func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { app, _ := newTestApp(t) - if err := app.refreshHelpPicker(); err != nil { - t.Fatalf("refreshHelpPicker() error = %v", err) - } + app.refreshHelpPicker() app.openHelpPicker() selectPickerItemByID(&app.helpPicker, slashCommandStatus) @@ -1158,3 +1168,20 @@ func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { t.Fatalf("expected status output in slash result, got %q", result.Notice) } } + +func TestRunSlashCommandSelectionModelReturnsRefreshCmd(t *testing.T) { + app, _ := newTestApp(t) + app.modelRefreshID = "" + + cmd := app.runSlashCommandSelection(slashCommandModelPick) + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected model picker to open") + } + if cmd == nil { + t.Fatalf("expected model refresh cmd") + } + msg := cmd() + if _, ok := msg.(modelCatalogRefreshMsg); !ok { + t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index a0a2750c..667dcafa 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -157,10 +157,6 @@ func (a App) renderWaterfall(width int, height int) string { parts = append(parts, a.renderPrompt(width)) content := lipgloss.JoinVertical(lipgloss.Left, parts...) - contentHeight := lipgloss.Height(content) - if contentHeight < height { - content = content + "\n" + lipgloss.NewStyle().Height(height-contentHeight).Render("") - } return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go new file mode 100644 index 00000000..42345994 --- /dev/null +++ b/internal/tui/core/app/view_test.go @@ -0,0 +1,33 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestRenderPickerHelpMode(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + app.state.ActivePicker = pickerHelp + + view := app.renderPicker(48, 14) + if !strings.Contains(view, helpPickerTitle) { + t.Fatalf("expected help picker title in view") + } + if !strings.Contains(view, helpPickerSubtitle) { + t.Fatalf("expected help picker subtitle in view") + } +} + +func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerNone + app.state.InputText = "test" + app.input.SetValue("test") + app.transcript.SetContent("line1\nline2") + + view := app.renderWaterfall(80, 24) + if strings.TrimSpace(view) == "" { + t.Fatalf("expected non-empty waterfall view") + } +} From 90391e18c7a56e19ba932445a198086e78bf6ef0 Mon Sep 17 00:00:00 2001 From: creatang Date: Thu, 9 Apr 2026 23:27:25 +0800 Subject: [PATCH 52/54] =?UTF-8?q?test(tui):=E8=A1=A5=E5=85=85=20update=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E8=A6=86=E7=9B=96=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20codecov=20patch=20=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/update_test.go | 142 +++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index 25021baf..f324dd42 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1185,3 +1185,145 @@ func TestRunSlashCommandSelectionModelReturnsRefreshCmd(t *testing.T) { t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) } } + +func TestRunSlashCommandSelectionProviderRefreshError(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("provider refresh failed")} + + cmd := app.runSlashCommandSelection(slashCommandProvider) + if cmd != nil { + t.Fatalf("expected nil cmd when provider refresh fails") + } + if !strings.Contains(app.state.StatusText, "provider refresh failed") { + t.Fatalf("expected provider refresh error status, got %q", app.state.StatusText) + } +} + +func TestRunSlashCommandSelectionModelRefreshError(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("model refresh failed")} + + cmd := app.runSlashCommandSelection(slashCommandModelPick) + if cmd != nil { + t.Fatalf("expected nil cmd when model refresh fails") + } + if !strings.Contains(app.state.StatusText, "model refresh failed") { + t.Fatalf("expected model refresh error status, got %q", app.state.StatusText) + } +} + +func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveSessionID = "" + app.state.CurrentWorkdir = t.TempDir() + + workspaceCmd := app.runSlashCommandSelection("/cwd") + if workspaceCmd == nil { + t.Fatalf("expected workspace slash cmd") + } + workspaceMsg := workspaceCmd() + workspaceResult, ok := workspaceMsg.(sessionWorkdirResultMsg) + if !ok { + t.Fatalf("expected sessionWorkdirResultMsg, got %T", workspaceMsg) + } + if workspaceResult.Err != nil { + t.Fatalf("expected no workspace error, got %v", workspaceResult.Err) + } + + localCmd := app.runSlashCommandSelection(slashCommandStatus) + if localCmd == nil { + t.Fatalf("expected local slash cmd") + } + localMsg := localCmd() + localResult, ok := localMsg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", localMsg) + } + if !strings.Contains(localResult.Notice, "Status:") { + t.Fatalf("expected status output in local command result") + } +} + +func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + + handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") + if !handled || cmd != nil { + t.Fatalf("expected compact with args to be handled without cmd") + } + if !strings.Contains(app.state.StatusText, "usage:") { + t.Fatalf("expected usage error for compact with args") + } + + app.state.ExecutionError = "" + app.state.IsCompacting = true + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd != nil { + t.Fatalf("expected compact busy branch to return handled with nil cmd") + } + if !strings.Contains(app.state.StatusText, "already running") { + t.Fatalf("expected busy message") + } + + app.state.IsCompacting = false + app.state.IsAgentRunning = false + app.state.StatusText = "" + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd == nil { + t.Fatalf("expected compact success branch to return cmd") + } + msg := cmd() + if _, ok := msg.(compactFinishedMsg); !ok { + t.Fatalf("expected compactFinishedMsg, got %T", msg) + } + if len(runtime.resolveCalls) != 0 { + t.Fatalf("compact should not resolve permissions") + } +} + +func TestHandleImmediateSlashCommandDefault(t *testing.T) { + app, _ := newTestApp(t) + handled, cmd := app.handleImmediateSlashCommand("/unknown") + if handled || cmd != nil { + t.Fatalf("expected unknown slash command to be ignored") + } +} + +func TestFormatPermissionPromptToolOnly(t *testing.T) { + got := formatPermissionPrompt(agentruntime.PermissionRequestPayload{ToolName: "bash"}) + if got != "bash" { + t.Fatalf("expected tool-only prompt, got %q", got) + } +} + +func TestStartDraftSessionResetsRunState(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveSessionID = "session-1" + app.state.ActiveSessionTitle = "Session 1" + app.state.ActiveRunID = "run-1" + app.state.CurrentTool = "bash" + app.state.ToolStates = []tuistate.ToolState{{ToolCallID: "tool-1", ToolName: "bash"}} + app.state.RunContext = tuistate.ContextWindowState{Provider: "openai"} + app.state.TokenUsage = tuistate.TokenUsageState{RunTotalTokens: 123} + app.activities = []tuistate.ActivityEntry{{Title: "activity"}} + app.state.CurrentWorkdir = t.TempDir() + + app.startDraftSession() + + if app.state.ActiveRunID != "" { + t.Fatalf("expected run id to be reset") + } + if app.state.CurrentTool != "" { + t.Fatalf("expected current tool to be reset") + } + if len(app.state.ToolStates) != 0 { + t.Fatalf("expected tool states to be reset") + } + if app.state.ActiveSessionID != "" || app.state.ActiveSessionTitle != draftSessionTitle { + t.Fatalf("expected draft session state") + } + if len(app.activities) != 0 { + t.Fatalf("expected activities to be cleared") + } +} From aa1e6d1322413dfdac6bf337fa804972afe2c4c2 Mon Sep 17 00:00:00 2001 From: creatang Date: Fri, 10 Apr 2026 15:14:17 +0800 Subject: [PATCH 53/54] =?UTF-8?q?fix(test):=20rebase=E5=90=8E=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E6=9D=83=E9=99=90=E6=8F=90=E7=A4=BA=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tui/core/app/update_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index f324dd42..ae196767 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -1291,9 +1291,11 @@ func TestHandleImmediateSlashCommandDefault(t *testing.T) { } func TestFormatPermissionPromptToolOnly(t *testing.T) { - got := formatPermissionPrompt(agentruntime.PermissionRequestPayload{ToolName: "bash"}) - if got != "bash" { - t.Fatalf("expected tool-only prompt, got %q", got) + lines := formatPermissionPromptLines(permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ToolName: "bash"}, + }) + if len(lines) == 0 || !strings.Contains(lines[0], "Permission request: bash") { + t.Fatalf("expected tool-only prompt header, got %#v", lines) } } From 3fe66f00c1b4762f566621ac7b8cdc772e34901b Mon Sep 17 00:00:00 2001 From: xgopilot Date: Fri, 10 Apr 2026 07:51:02 +0000 Subject: [PATCH 54/54] fix: address PR review regressions Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: creatang <165447160+creatang@users.noreply.github.com> --- internal/context/compact/runner.go | 4 +- internal/context/compact/runner_test.go | 48 ++++++++ internal/provider/errors.go | 22 ++++ internal/provider/errors_test.go | 28 +++++ internal/provider/openai/events.go | 15 ++- internal/provider/openai/openai_test.go | 49 ++++++++ internal/provider/openai/response.go | 17 ++- internal/runtime/runtime.go | 4 +- internal/runtime/runtime_test.go | 141 +++++++++++++++++++++++- internal/tui/core/app/update.go | 8 +- internal/tui/core/app/view.go | 14 ++- internal/tui/core/app/view_test.go | 43 ++++++++ 12 files changed, 374 insertions(+), 19 deletions(-) diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 6ae8dc9c..dffa4bcd 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -18,6 +18,8 @@ type Mode string const ( // ModeManual runs the explicit user-triggered compact flow. ModeManual Mode = "manual" + // ModeAuto runs the token-threshold-triggered compact flow. + ModeAuto Mode = "auto" // ModeReactive runs the provider-error-triggered compact flow. ModeReactive Mode = "reactive" ) @@ -111,7 +113,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { return Result{}, err } - if input.Mode != ModeManual && input.Mode != ModeReactive { + if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive { return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode) } diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index 4ab50825..5be1ef31 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -185,6 +185,54 @@ func TestReactiveCompactUsesKeepRecentAndReportsReactiveMode(t *testing.T) { } } +func TestAutoCompactUsesManualStrategyAndReportsAutoMode(t *testing.T) { + t.Parallel() + + generator := &stubSummaryGenerator{summary: validSemanticSummary()} + runner := NewRunner(generator) + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old requirement"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "middle request"}, + {Role: providertypes.RoleAssistant, Content: "middle answer"}, + {Role: providertypes.RoleUser, Content: "recent request"}, + {Role: providertypes.RoleAssistant, Content: "recent answer"}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeAuto, + SessionID: "session-auto", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 4, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected auto compact applied") + } + if result.Metrics.TriggerMode != string(ModeAuto) { + t.Fatalf("expected trigger mode %q, got %q", ModeAuto, result.Metrics.TriggerMode) + } + if len(generator.calls) != 1 { + t.Fatalf("expected generator to run once, got %d", len(generator.calls)) + } + if generator.calls[0].Mode != ModeAuto { + t.Fatalf("expected summary input mode %q, got %q", ModeAuto, generator.calls[0].Mode) + } + if generator.calls[0].Config.ManualStrategy != config.CompactManualStrategyKeepRecent { + t.Fatalf("expected auto compact to retain manual strategy, got %q", generator.calls[0].Config.ManualStrategy) + } +} + func TestManualCompactKeepRecentProtectsLatestExplicitUserInstruction(t *testing.T) { t.Parallel() diff --git a/internal/provider/errors.go b/internal/provider/errors.go index 3a4e2964..8107f190 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -40,10 +40,22 @@ var contextTooLongFragments = []string{ "maximum context length", "maximum prompt length", "prompt is too long", +} + +var contextTokenFragments = []string{ "requested too many tokens", "too many tokens", } +var contextTokenHints = []string{ + "context", + "prompt", + "message", + "input", + "history", + "window", +} + // ProviderError 是 provider 层的领域错误类型。 type ProviderError struct { StatusCode int // HTTP 状态码,0 表示非 HTTP 错误(如网络超时) @@ -164,5 +176,15 @@ func matchesContextTooLong(message string) bool { return true } } + for _, fragment := range contextTokenFragments { + if !strings.Contains(normalized, fragment) { + continue + } + for _, hint := range contextTokenHints { + if strings.Contains(normalized, hint) { + return true + } + } + } return false } diff --git a/internal/provider/errors_test.go b/internal/provider/errors_test.go index bbcd247b..54ddf0a2 100644 --- a/internal/provider/errors_test.go +++ b/internal/provider/errors_test.go @@ -132,6 +132,16 @@ func TestNewProviderErrorFromStatus(t *testing.T) { if err.Code != ErrorCodeRateLimit { t.Fatalf("429 with token-count message: expected code %q, got %q", ErrorCodeRateLimit, err.Code) } + + err = NewProviderErrorFromStatus(400, "requested too many tokens in the prompt context window") + if err.Code != ErrorCodeContextTooLong { + t.Fatalf("expected contextual token-count message to map to %q, got %q", ErrorCodeContextTooLong, err.Code) + } + + err = NewProviderErrorFromStatus(400, "requested too many tokens for max_tokens") + if err.Code != ErrorCodeClient { + t.Fatalf("expected output-token validation message to stay %q, got %q", ErrorCodeClient, err.Code) + } } func TestNewNetworkProviderError(t *testing.T) { @@ -227,6 +237,24 @@ func TestIsContextTooLong(t *testing.T) { }, want: false, }, + { + name: "output token validation message is not context_too_long", + err: &ProviderError{ + StatusCode: 400, + Code: ErrorCodeClient, + Message: "requested too many tokens for max_tokens", + }, + want: false, + }, + { + name: "contextual token-count message is context_too_long", + err: &ProviderError{ + StatusCode: 400, + Code: ErrorCodeClient, + Message: "requested too many tokens for prompt context window", + }, + want: true, + }, } for _, tt := range tests { diff --git a/internal/provider/openai/events.go b/internal/provider/openai/events.go index 50e13338..8e5f9024 100644 --- a/internal/provider/openai/events.go +++ b/internal/provider/openai/events.go @@ -33,7 +33,20 @@ func emitToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEv // emitMessageDone 发送消息完成事件。 func emitMessageDone(ctx context.Context, events chan<- providertypes.StreamEvent, finishReason string, usage *providertypes.Usage) error { - return emitStreamEvent(ctx, events, providertypes.NewMessageDoneStreamEvent(finishReason, usage)) + event := providertypes.NewMessageDoneStreamEvent(finishReason, usage) + if ctx == nil || ctx.Err() == nil { + return emitStreamEvent(ctx, events, event) + } + if events == nil { + return nil + } + + select { + case events <- event: + return nil + default: + return nil + } } // emitStreamEvent 通过 channel 安全发送流式事件,支持上下文取消和 nil channel 保护。 diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index a41fa279..e8e73442 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -595,6 +595,38 @@ func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *tes } } +func TestConsumeStream_DoneThenCancellationStillFinishes(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + body := &cancelAfterDoneReader{ + payload: []byte("data: [DONE]\n"), + cancel: cancel, + err: io.ErrClosedPipe, + } + events := make(chan providertypes.StreamEvent, 4) + + err = p.consumeStream(ctx, body, events) + if err != nil { + t.Fatalf("expected completed stream after [DONE], got %v", err) + } + + var foundDone bool + for _, evt := range drainStreamEvents(events) { + if evt.Type == providertypes.StreamEventMessageDone { + foundDone = true + } + } + if !foundDone { + t.Fatal("expected message_done event after cancellation race post-[DONE]") + } +} + func TestConsumeStream_FinishReasonAccumulation(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") @@ -1351,6 +1383,23 @@ func (r *cancelOnEOFReader) Read(p []byte) (int, error) { return n, err } +type cancelAfterDoneReader struct { + payload []byte + cancel func() + err error + read bool +} + +func (r *cancelAfterDoneReader) Read(p []byte) (int, error) { + if !r.read { + r.read = true + n := copy(p, r.payload) + return n, nil + } + r.cancel() + return 0, r.err +} + type failingReadCloser struct{ err error } func (f *failingReadCloser) Read(_ []byte) (int, error) { return 0, f.err } diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index c9cb41d5..ad9fe23d 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -80,14 +80,23 @@ func (p *Provider) consumeStream( for { // 每次读取前优先响应上下文取消,避免取消请求被误判为流中断。 - select { - case <-ctx.Done(): - return ctx.Err() - default: + // 一旦收到 [DONE],后续取消不应覆盖已完成的流收尾。 + if !done { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } } line, err := reader.ReadLine() if err != nil && !errors.Is(err, io.EOF) { + if done { + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + return finishStream() + } if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 4e5a79d2..02c9442d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -250,10 +250,10 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } if builtContext.ShouldAutoCompact && !autoCompacted { - autoCompacted = true var compactResult contextcompact.Result - session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeManual, false) + session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeAuto, false) if compactResult.Applied { + autoCompacted = true s.resetSessionTokenTotals(&session) // 自动 compact 成功后需要在同一轮重建上下文,避免继续沿用压缩前的请求内容。 attempt-- diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 0868ccdb..6f1cb87c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -3302,7 +3302,7 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { BeforeChars: 60, AfterChars: 24, SavedRatio: 0.6, - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(contextcompact.ModeAuto), }, TranscriptID: "transcript_auto", TranscriptPath: "/tmp/auto.jsonl", @@ -3321,6 +3321,9 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if len(compactRunner.calls) != 1 { t.Fatalf("expected auto compact to run once, got %d", len(compactRunner.calls)) } + if compactRunner.calls[0].Mode != contextcompact.ModeAuto { + t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeAuto, compactRunner.calls[0].Mode) + } if len(builder.builds) != 3 { t.Fatalf("expected 3 build attempts, got %d", len(builder.builds)) } @@ -3383,6 +3386,142 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { EventAgentDone, }) assertNoEventType(t, events, EventCompactError) + + foundAutoDone := false + for _, event := range events { + if event.Type != EventCompactDone { + continue + } + payload, ok := event.Payload.(CompactDonePayload) + if !ok { + t.Fatalf("expected CompactDonePayload, got %T", event.Payload) + } + if payload.TriggerMode != string(contextcompact.ModeAuto) { + t.Fatalf("expected trigger mode %q, got %q", contextcompact.ModeAuto, payload.TriggerMode) + } + foundAutoDone = true + } + if !foundAutoDone { + t.Fatalf("expected auto compact_done event in %+v", events) + } +} + +func TestServiceRunAutoCompactNoopDoesNotDisableReactiveRetry(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.AutoCompact.Enabled = true + cfg.Context.AutoCompact.InputTokenThreshold = 100 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("auto-noop-reactive") + session.ID = "session-auto-noop-reactive" + session.TokenInputTotal = 100 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "auto compact prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), + ShouldAutoCompact: input.Metadata.SessionInputTokens >= input.Compact.AutoCompactThreshold, + }, nil + }, + } + + callCount := 0 + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + callCount++ + if callCount == 1 { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + } + } + select { + case events <- providertypes.NewTextDeltaStreamEvent("recovered after reactive compact"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", nil): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + compactRunner := &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + switch input.Mode { + case contextcompact.ModeAuto: + return contextcompact.Result{ + Messages: append([]providertypes.Message(nil), input.Messages...), + Applied: false, + Metrics: contextcompact.Metrics{ + BeforeChars: 40, + AfterChars: 40, + TriggerMode: string(contextcompact.ModeAuto), + }, + }, nil + case contextcompact.ModeReactive: + return contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 80, + AfterChars: 30, + SavedRatio: 0.625, + TriggerMode: string(contextcompact.ModeReactive), + }, + }, nil + default: + t.Fatalf("unexpected compact mode %q", input.Mode) + return contextcompact.Result{}, nil + } + }, + } + service.compactRunner = compactRunner + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-auto-noop-reactive", + Content: "continue", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(compactRunner.calls) != 2 { + t.Fatalf("expected auto noop then reactive compact, got %d calls", len(compactRunner.calls)) + } + if compactRunner.calls[0].Mode != contextcompact.ModeAuto { + t.Fatalf("expected first compact mode %q, got %q", contextcompact.ModeAuto, compactRunner.calls[0].Mode) + } + if compactRunner.calls[1].Mode != contextcompact.ModeReactive { + t.Fatalf("expected second compact mode %q, got %q", contextcompact.ModeReactive, compactRunner.calls[1].Mode) + } + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called twice, got %d", scripted.callCount) + } } func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index 3553db83..192ceabe 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -1503,14 +1503,10 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) a.transcript.Width = max(24, lay.rightWidth) a.resizeCommandMenu() - menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) - activityHeight := a.activityPreviewHeight() a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) a.input.SetHeight(a.composerHeight()) - promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) - availableHeight := lay.rightHeight - activityHeight - menuHeight - promptHeight - minTranscriptHeight := max(6, lay.rightHeight/2) - a.transcript.Height = max(minTranscriptHeight, availableHeight) + transcriptHeight, activityHeight, _, _ := a.waterfallMetrics(a.transcript.Width, lay.rightHeight) + a.transcript.Height = transcriptHeight if activityHeight > 0 { panelStyle := a.styles.panelFocused diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 667dcafa..7247f218 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -129,6 +129,15 @@ func (a App) renderSidebar(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } +// waterfallMetrics 统一计算瀑布区各组件高度,确保渲染、布局与命中区域使用同一组尺寸。 +func (a App) waterfallMetrics(width int, height int) (int, int, int, int) { + activityHeight := a.activityPreviewHeight() + menuHeight := a.commandMenuHeight(width) + promptHeight := lipgloss.Height(a.renderPrompt(width)) + transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) + return transcriptHeight, activityHeight, menuHeight, promptHeight +} + func (a App) renderWaterfall(width int, height int) string { if a.state.ActivePicker != pickerNone { return lipgloss.Place( @@ -140,10 +149,7 @@ func (a App) renderWaterfall(width int, height int) string { ) } - activityHeight := a.activityPreviewHeight() - menuHeight := a.commandMenuHeight(width) - promptHeight := lipgloss.Height(a.renderPrompt(width)) - transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) + transcriptHeight, _, _, _ := a.waterfallMetrics(width, height) transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 42345994..cb638676 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -3,6 +3,10 @@ package tui import ( "strings" "testing" + + "github.com/charmbracelet/bubbles/list" + + tuistate "neo-code/internal/tui/state" ) func TestRenderPickerHelpMode(t *testing.T) { @@ -31,3 +35,42 @@ func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { t.Fatalf("expected non-empty waterfall view") } } + +func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.focus = panelInput + app.activities = []tuistate.ActivityEntry{{Kind: "tool", Title: "running", Detail: "tool call"}} + app.commandMenu.SetItems([]list.Item{ + commandMenuItem{title: "/help", description: "show help"}, + commandMenuItem{title: "/model", description: "switch model"}, + }) + app.commandMenuMeta = tuistate.CommandMenuMeta{Title: commandMenuTitle} + app.input.SetValue(strings.Repeat("line\n", 5)) + app.input.SetHeight(app.composerHeight()) + + app.applyComponentLayout(false) + + lay := app.computeLayout() + wantTranscriptHeight, activityHeight, menuHeight, _ := app.waterfallMetrics(app.transcript.Width, lay.rightHeight) + if app.transcript.Height != wantTranscriptHeight { + t.Fatalf("expected transcript height %d, got %d", wantTranscriptHeight, app.transcript.Height) + } + + _, transcriptY, _, transcriptHeight := app.transcriptBounds() + _, activityY, _, gotActivityHeight := app.activityBounds() + _, inputY, _, _ := app.inputBounds() + if transcriptHeight != wantTranscriptHeight { + t.Fatalf("expected transcript bounds height %d, got %d", wantTranscriptHeight, transcriptHeight) + } + if activityY != transcriptY+wantTranscriptHeight { + t.Fatalf("expected activity Y %d, got %d", transcriptY+wantTranscriptHeight, activityY) + } + if gotActivityHeight != activityHeight { + t.Fatalf("expected activity height %d, got %d", activityHeight, gotActivityHeight) + } + if inputY != transcriptY+wantTranscriptHeight+activityHeight+menuHeight { + t.Fatalf("expected input Y %d, got %d", transcriptY+wantTranscriptHeight+activityHeight+menuHeight, inputY) + } +}